From 931ae1d450dc7490559ddf5481a18bb26c51a17a Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 21 Aug 2026 09:31:27 +0800 Subject: [PATCH] feat(blender): add multiview cutaway adapter v2 --- CHANGELOG.md | 2 +- DESIGN.md | 2 +- PROGRESS.md | 4 +- RUN.md | 4 +- core/software_contracts.py | 2 +- ...r.v1.json => blender.scene.author.v2.json} | 144 ++++++-- tests/test_blender_adapter.py | 50 ++- tests/test_software_job_tools.py | 29 ++ tests/test_software_nodes.py | 2 +- tests/test_software_output_publish.py | 38 +++ tests/test_windows_node_source.py | 8 +- tools/software_jobs.py | 2 +- web/routers/software_nodes.py | 5 +- windows-node/README.md | 4 +- .../SoftwareRuntimeManager.cs | 2 +- .../Zcbot.WindowsNode.csproj | 4 +- .../adapter.json | 6 +- .../blender_worker.py | 311 ++++++++++++++++-- .../requirements.txt | 0 .../worker.py | 4 +- windows-node/package-blender-adapter.bat | 12 +- 21 files changed, 549 insertions(+), 86 deletions(-) rename software-contracts/{blender.scene.author.v1.json => blender.scene.author.v2.json} (53%) rename windows-node/adapters/{blender.scene.author@v1 => blender.scene.author@v2}/adapter.json (58%) rename windows-node/adapters/{blender.scene.author@v1 => blender.scene.author@v2}/blender_worker.py (63%) rename windows-node/adapters/{blender.scene.author@v1 => blender.scene.author@v2}/requirements.txt (100%) rename windows-node/adapters/{blender.scene.author@v1 => blender.scene.author@v2}/worker.py (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c201e..0500afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ - Windows Node 的任务、Workspace 和受管运行时现在可以从界面整体迁移到其他本机磁盘;迁移完成前会校验全部文件并保留旧目录,管理员也可以用机器级环境变量统一指定位置。 -- Windows Node 新增 Blender 三维场景创作能力,首批可按长度、直径、斜度、支撑档数和附属部件生成参数化回转窑,并可继续调整工程、预览或导出 GLB。 +- Windows Node 的 Blender 三维场景创作现在可在一次任务中生成回转窑等轴测、侧视、窑头、窑尾、纵向剖切和横截面图,并继续提供可调整的 Blender 工程及可选 GLB;无输入的纯生成任务也可直接提交。 - 软件作业列表不再因历史任务使用的旧能力版本已经下线而整体消失;旧任务仍可查看,列表请求失败时页面也会明确提示刷新重试。 diff --git a/DESIGN.md b/DESIGN.md index 121040a..5d6660c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -476,7 +476,7 @@ 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 的能力上限。 +Blender 入口为 `blender.scene.author@v2`,以设备目标状态而非 Python、逐步按键或任意节点脚本表达三维创作;不再保留 v1 调度能力,目标执行版本为 Blender 5.2 LTS。adapter 0.2.0 的 `rotary_kiln` feature 接受长度、直径、壳体/衬里厚度、斜度、支撑档数及驱动、燃烧器、窑头尾罩、平台、物料等受控参数,由固定 `bpy` Worker 一次建模,再按声明的视图连续输出等轴测、侧视、窑头、窑尾、纵向剖切和横截面 PNG。剖切使用仅在渲染态启用的半壳体与衬里几何,保存的 `.blend` 始终恢复完整模型;固定等轴测预览之外的视图和 GLB 由请求显式选择。受管 Python 只负责发现并以 `--background --factory-startup --disable-autoexec --python-exit-code 1` 拉起已安装的 Blender,实际 `bpy` 在 Blender 自带 Python 内执行;请求不能提供脚本、命令行、本机路径、URL 或插件。`.blend` 作为 Workspace state,续作先验证并打开上一版工程,再以完整目标状态生成新版本。 专业软件的连续加工以独立 `software_workspaces` 为稳定边界,Job 只记录一次操作。首个 Job 创建独立 `workspace_id`,两者不共用 ID;服务端保存 user 归属、capability、`home_node_id`、`head_job_id`、容量与留存元数据,Node 物理目录只使用 `/workspaces//`,不再按 user 建上层目录。全新节点的 `data_root` 默认是当前专用账号的 `%LocalAppData%/Zcbot/WindowsNode`,以便无需提权即可从 UI 完成首次配置;升级节点若已有 `%ProgramData%/Zcbot/WindowsNode` 则继续原位使用,不做隐式迁移。专用运行账号可在 UI 中选择其他本机固定磁盘,机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 拥有最高优先级并使 UI 只读;账号级选择保存在 HKCU,避免把根目录定位信息放入其自身。迁移先停止调度并等待本机任务及导出收尾,在目标同级临时目录复制并逐文件校验后才原子切换,旧目录不自动删除。这样用户统计集中在数据库完成,节点只管理实际落到本机的 workspace,也避免多节点各自维护一套用户目录树。Workspace 一旦首次调度即粘在 home node;后续 Job 必须以当前 head 为 source、串行回到同一节点,节点离线时等待而不复制工程或静默改派。 diff --git a/PROGRESS.md b/PROGRESS.md index 39b04b3..5930120 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-21(Windows Node 软件地址选择与 Origin 检测修正,未发版) +最后更新:2026-08-21(Blender 多视图与剖切 v2,未发版) --- @@ -20,6 +20,8 @@ --- ## 已完成关键能力 +- **08-21 / Unreleased / Blender 多视图与剖切 v2**:以 `blender.scene.author@v2` 直接替换 v1,目标节点为 Blender 5.2 LTS;`rotary_kiln` 一次建模后可连续渲染等轴测、侧视、窑头、窑尾、纵向剖切和横截面,完整工程保持未剖切状态;固定等轴测预览外,其他选定 PNG 可动态自动回传,GLB 排除仅供剖切渲染的辅助几何。专业软件提交工具改为区分缺失参数与合法空数组,允许 Blender 这类无输入、无额外导出请求的纯生成任务进入合同校验;90 项 Blender/合同/Job/Node/预览专项 unittest、.NET build、独立 adapter 打包和 Blender 3.6.12 兼容烟测通过,全量 698 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),目标节点 5.2 真机验收待部署后执行,未连接或迁移生产数据库。 + - **08-21 / Unreleased / Windows Node 软件地址选择与 Origin 检测修正**:专业软件改为带边界的独立配置行和通栏只读地址框,Origin、ANSYS、Blender 均可手选位置。Origin 自动检测由单一 COM 路径扩展为 COM、卸载注册表和标准目录回退,手选的 `Origin*.exe` 同时用于 Worker 版本识别,COM 执行健康仍由 probe 独立校验。默认窗口扩大为桌面控制台比例,节点状态改为独立状态徽标,能力概览只保留软件与 adapter 的可扫读摘要,完整错误继续通过诊断复制提供。Windows Node 源码与 Origin Worker 专项 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。 - **08-20 / Unreleased / Windows Node 专业软件独立配置与 runtime 安装**:主窗口新增 Origin、ANSYS Mechanical 2024 R2、Blender 软件卡,分别显示应用位置来源和受管 runtime 状态;三种软件均可由 UI 选择自定义位置,清除后按各自的环境变量、COM、卸载注册表、PATH 或标准目录自动检测。每个软件可单独创建或更新隔离的 Python 3.12 runtime,安装只使用内置 runtime 定义与固定 requirements,拒绝活动任务期间替换,并先在临时目录完成后再切换;配置后的路径由 Host 作为固定子进程环境传给 Worker,任务请求仍不能提供路径或命令。移除重复承担软件安装和开机启动的统一 BAT,发布包直接运行 EXE,当前账号的开机启动也完全由 UI 管理;全新节点默认使用可写的 `%LocalAppData%` 数据根目录,已有 `%ProgramData%` 节点原位兼容。 diff --git a/RUN.md b/RUN.md index 473dbd1..6ffadf1 100644 --- a/RUN.md +++ b/RUN.md @@ -1101,7 +1101,7 @@ Workspace 目录固定为 `\workspaces\\`,不使用 u 主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2 和 Blender 分别管理应用位置与隔离 runtime。每张软件卡都可单独执行“安装/更新运行环境”,只会读取安装包内固定 requirements,并先在临时目录完成后再替换该软件的 `runtimes/`;有对应活动任务时拒绝替换。安装要求本机存在 Python 3.12,可通过 `py -3.12` 或 PATH 中的 `python.exe` 找到。三种软件均可用“选择位置”保存当前专用账号的配置,点“自动检测”会清除手工值并重新探测;Origin 按 `ZCBOT_ORIGIN_EXE`、COM 注册、Windows 卸载注册表和 OriginLab 标准目录查找 `Origin*.exe`,ANSYS 与 Blender 继续按各自环境变量、注册表、PATH 或标准目录查找。环境变量 `ZCBOT_ORIGIN_EXE`、`ZCBOT_BLENDER_EXE` 与 `AWP_ROOT242` 继续兼容。选择 Origin 程序只确定安装位置和版本读取来源,实际执行仍要求 Origin COM probe 通过。配置或安装完成后不必重新注册节点,后续 probe/心跳会刷新能力状态。 -Blender 能力为 `blender.scene.author@v1`。首版 `rotary_kiln` 接受完整的回转窑目标状态,生成 `scene.blend`、`preview.png`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`;`.blend` 留在 Workspace 作为下一次修改的事实源。Node 会优先使用 UI 中指定的 `blender.exe`,否则从 `ZCBOT_BLENDER_EXE`、Windows 卸载注册表、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender。启动器固定使用 Blender 后台、factory startup 和禁止自动脚本模式,不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL 或本机路径。 +Blender 能力为 `blender.scene.author@v2`,v1 不再调度,目标节点版本为 Blender 5.2 LTS。`rotary_kiln` 接受完整的回转窑目标状态,`render.views` 必须包含 `isometric`,还可选择 `side`、`kiln_head`、`kiln_tail`、`longitudinal_cutaway` 和 `cross_section`;除固定生成的 `preview-isometric.png` 外,每个附加视图需在 `outputs` 请求对应的 `preview_side`、`preview_kiln_head`、`preview_kiln_tail`、`preview_cutaway` 或 `preview_cross_section`。任务同时生成 `scene.blend`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`;剖切只切换渲染几何,保存的 `.blend` 保持完整并留在 Workspace 作为下一次修改的事实源。Node 会优先使用 UI 中指定的 `blender.exe`,否则从 `ZCBOT_BLENDER_EXE`、Windows 卸载注册表、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender。启动器固定使用 Blender 后台、factory startup、禁止自动脚本和 Python 异常退出码,渲染优先使用 5.2 的 `BLENDER_EEVEE` 标识并向旧版标识回退;不读取用户启动文件,也不接受请求传入 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`。 @@ -1146,7 +1146,7 @@ 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` 变化时无需更新 runtime。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 能力时在 Blender 软件卡中创建轻量启动 runtime,`bpy` 不安装到该 runtime。 +Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v2-adapter.zip`,替换规则与 Origin 相同。升级时退出托盘 Node,删除旧 `adapters\blender.scene.author@v1\` 并整体放入 v2 目录后重启;Blender 的 `requirements.txt` 当前不安装第三方包,`bpy` 不安装到轻量启动 runtime。 注册配置写入 `\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。 diff --git a/core/software_contracts.py b/core/software_contracts.py index 50e475d..bfa94a4 100644 --- a/core/software_contracts.py +++ b/core/software_contracts.py @@ -225,7 +225,7 @@ def _load_contract(path: Path) -> CapabilityContract: or not preview_outputs or any(not isinstance(item, str) or item not in outputs for item in preview_outputs) or len(set(preview_outputs)) != len(preview_outputs) - or any(not outputs[item].required for item in preview_outputs) + or not any(outputs[item].required for item in preview_outputs) or state_output in preview_outputs or not isinstance(required_adapter_version, str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", required_adapter_version) diff --git a/software-contracts/blender.scene.author.v1.json b/software-contracts/blender.scene.author.v2.json similarity index 53% rename from software-contracts/blender.scene.author.v1.json rename to software-contracts/blender.scene.author.v2.json index 239502a..341458a 100644 --- a/software-contracts/blender.scene.author.v1.json +++ b/software-contracts/blender.scene.author.v2.json @@ -1,5 +1,5 @@ { - "capability": "blender.scene.author@v1", + "capability": "blender.scene.author@v2", "display_name": "Blender 三维场景创作", "default_enrollment": false, "output_namespace": "blender", @@ -17,13 +17,48 @@ "publish": true, "required": true }, - "preview": { - "filename": "preview.png", + "preview_isometric": { + "filename": "preview-isometric.png", "media_type": "image/png", - "relative_path": "preview.png", + "relative_path": "preview-isometric.png", "publish": true, "required": true }, + "preview_side": { + "filename": "preview-side.png", + "media_type": "image/png", + "relative_path": "preview-side.png", + "publish": true, + "required": false + }, + "preview_kiln_head": { + "filename": "preview-kiln-head.png", + "media_type": "image/png", + "relative_path": "preview-kiln-head.png", + "publish": true, + "required": false + }, + "preview_kiln_tail": { + "filename": "preview-kiln-tail.png", + "media_type": "image/png", + "relative_path": "preview-kiln-tail.png", + "publish": true, + "required": false + }, + "preview_cutaway": { + "filename": "preview-cutaway.png", + "media_type": "image/png", + "relative_path": "preview-cutaway.png", + "publish": true, + "required": false + }, + "preview_cross_section": { + "filename": "preview-cross-section.png", + "media_type": "image/png", + "relative_path": "preview-cross-section.png", + "publish": true, + "required": false + }, "scene_manifest": { "filename": "scene-manifest.json", "media_type": "application/json", @@ -48,7 +83,7 @@ }, "feature_path": ["operation", "scene", "type"], "features": { - "rotary_kiln": "0.1.0" + "rotary_kiln": "0.2.0" }, "summary": { "title_path": ["operation", "scene", "title"] @@ -56,8 +91,15 @@ "legacy_runtime": null, "workspace": { "state_output": "project", - "preview_outputs": ["preview"], - "required_adapter_version": "0.1.0" + "preview_outputs": [ + "preview_isometric", + "preview_side", + "preview_kiln_head", + "preview_kiln_tail", + "preview_cutaway", + "preview_cross_section" + ], + "required_adapter_version": "0.2.0" }, "request_schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -66,7 +108,7 @@ "required": ["schema_version", "inputs", "operation", "outputs"], "additionalProperties": false, "properties": { - "schema_version": {"const": 1}, + "schema_version": {"const": 2}, "inputs": { "type": "array", "maxItems": 0 @@ -81,17 +123,18 @@ }, "outputs": { "type": "array", - "maxItems": 1, + "maxItems": 6, "uniqueItems": true, + "description": "为 render.views 中除 isometric 外的每个视图请求对应 PNG,可另选 GLB。", "items": { - "type": "object", - "required": ["key", "type", "format"], - "additionalProperties": false, - "properties": { - "key": {"const": "scene_glb"}, - "type": {"const": "model"}, - "format": {"const": "glb"} - } + "oneOf": [ + {"$ref": "#/$defs/sideOutput"}, + {"$ref": "#/$defs/headOutput"}, + {"$ref": "#/$defs/tailOutput"}, + {"$ref": "#/$defs/cutawayOutput"}, + {"$ref": "#/$defs/crossSectionOutput"}, + {"$ref": "#/$defs/glbOutput"} + ] } } }, @@ -100,9 +143,59 @@ "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" }, + "pngOutput": { + "type": "object", + "required": ["key", "type", "format"], + "additionalProperties": false, + "properties": { + "key": {"type": "string"}, + "type": {"const": "figure"}, + "format": {"const": "png"} + } + }, + "sideOutput": { + "allOf": [ + {"$ref": "#/$defs/pngOutput"}, + {"properties": {"key": {"const": "preview_side"}}} + ] + }, + "headOutput": { + "allOf": [ + {"$ref": "#/$defs/pngOutput"}, + {"properties": {"key": {"const": "preview_kiln_head"}}} + ] + }, + "tailOutput": { + "allOf": [ + {"$ref": "#/$defs/pngOutput"}, + {"properties": {"key": {"const": "preview_kiln_tail"}}} + ] + }, + "cutawayOutput": { + "allOf": [ + {"$ref": "#/$defs/pngOutput"}, + {"properties": {"key": {"const": "preview_cutaway"}}} + ] + }, + "crossSectionOutput": { + "allOf": [ + {"$ref": "#/$defs/pngOutput"}, + {"properties": {"key": {"const": "preview_cross_section"}}} + ] + }, + "glbOutput": { + "type": "object", + "required": ["key", "type", "format"], + "additionalProperties": false, + "properties": { + "key": {"const": "scene_glb"}, + "type": {"const": "model"}, + "format": {"const": "glb"} + } + }, "rotaryKilnScene": { "type": "object", - "required": ["type", "title", "kiln"], + "required": ["type", "title", "kiln", "render"], "additionalProperties": false, "properties": { "type": {"const": "rotary_kiln"}, @@ -148,12 +241,25 @@ }, "render": { "type": "object", + "required": ["views"], "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"]} + "views": { + "type": "array", + "minItems": 1, + "maxItems": 6, + "uniqueItems": true, + "contains": {"const": "isometric"}, + "items": { + "enum": [ + "isometric", "side", "kiln_head", "kiln_tail", + "longitudinal_cutaway", "cross_section" + ] + } + } } } } diff --git a/tests/test_blender_adapter.py b/tests/test_blender_adapter.py index d11e2b4..5e0c89a 100644 --- a/tests/test_blender_adapter.py +++ b/tests/test_blender_adapter.py @@ -14,7 +14,7 @@ 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" +ADAPTER = ROOT / "windows-node" / "adapters" / "blender.scene.author@v2" LAUNCHER_PATH = ADAPTER / "worker.py" spec = importlib.util.spec_from_file_location("blender_adapter_launcher", LAUNCHER_PATH) @@ -25,7 +25,7 @@ spec.loader.exec_module(launcher) def request() -> dict[str, object]: return { - "schema_version": 1, + "schema_version": 2, "inputs": [], "operation": { "scene": { @@ -58,30 +58,50 @@ def request() -> dict[str, object]: "width": 1280, "height": 720, "transparent_background": False, - "view": "isometric", + "views": ["isometric", "longitudinal_cutaway"], }, } }, - "outputs": [{"key": "scene_glb", "type": "model", "format": "glb"}], + "outputs": [ + {"key": "preview_cutaway", "type": "figure", "format": "png"}, + {"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") + contract = get_contract("blender.scene.author@v2") 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( + contract.workspace.preview_outputs, + ( + "preview_isometric", + "preview_side", + "preview_kiln_head", + "preview_kiln_tail", + "preview_cutaway", + "preview_cross_section", + ), + ) + self.assertEqual(contract.required_adapter_version(normalized), "0.2.0") self.assertEqual( set(contract.expected_outputs(normalized)), - {"project", "preview", "scene_manifest", "scene_glb", "provenance"}, + { + "project", + "preview_isometric", + "preview_cutaway", + "scene_manifest", + "scene_glb", + "provenance", + }, ) def test_contract_rejects_arbitrary_execution_and_unsafe_scale(self) -> None: - contract = get_contract("blender.scene.author@v1") + contract = get_contract("blender.scene.author@v2") malicious = request() malicious["operation"]["scene"]["python"] = "import os" # type: ignore[index] with self.assertRaises(SoftwareContractError): @@ -103,14 +123,14 @@ class BlenderLauncherTests(unittest.TestCase): with patch.object( launcher, "_find_blender", - return_value=(Path(r"C:\Program Files\Blender\blender.exe"), "4.5.3"), + return_value=(Path(r"C:\Program Files\Blender\blender.exe"), "5.2.0"), ): 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") + self.assertEqual(payload["software_version"], "5.2.0") def test_launcher_uses_fixed_background_and_autoexec_flags(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -140,6 +160,8 @@ class BlenderLauncherTests(unittest.TestCase): self.assertIn("--background", arguments) self.assertIn("--factory-startup", arguments) self.assertIn("--disable-autoexec", arguments) + self.assertIn("--python-exit-code", arguments) + self.assertEqual(arguments[arguments.index("--python-exit-code") + 1], "1") self.assertIn("--python", arguments) terminal = json.loads((job_dir / "terminal.json").read_text(encoding="utf-8")) self.assertEqual(terminal["status"], "failed") @@ -152,8 +174,12 @@ class BlenderLauncherTests(unittest.TestCase): '"GirthGear"', '"SupportRoller_', '"scene.blend"', - '"preview.png"', + '"preview-isometric.png"', + '"CutawayShell"', + '"CutawayLining"', + '"longitudinal_cutaway"', '"scene.glb"', + "use_renderable=True", "bpy.ops.wm.open_mainfile", ): self.assertIn(marker, source) diff --git a/tests/test_software_job_tools.py b/tests/test_software_job_tools.py index 4e85209..162b9d6 100644 --- a/tests/test_software_job_tools.py +++ b/tests/test_software_job_tools.py @@ -106,6 +106,35 @@ class SoftwareJobToolTests(unittest.TestCase): ) self.assertIn("call register_artifact first", result) + def test_submit_accepts_contracts_with_empty_inputs_and_outputs(self): + created = {"job_id": str(uuid4()), "status": "queued"} + with patch("tools.software_jobs.create_job", return_value=(created, True)) as create: + result = json.loads( + SoftwareJobSubmitTool(self.user_id, self.task_id).execute( + "blender.scene.author@v2", + inputs=[], + operation={ + "scene": { + "type": "rotary_kiln", + "title": "Kiln", + "kiln": { + "length_m": 60, + "outer_diameter_m": 4, + "shell_thickness_m": 0.05, + "lining_thickness_m": 0.2, + "slope_percent": 3.5, + "support_stations": 3, + }, + "render": {"views": ["isometric"]}, + } + }, + outputs=[], + ) + ) + self.assertTrue(result["created"]) + self.assertEqual(create.call_args.kwargs["request"]["inputs"], []) + self.assertEqual(create.call_args.kwargs["request"]["outputs"], []) + def test_submit_schema_requires_artifact_input(self): required = SoftwareJobSubmitTool.parameters["required"] self.assertIn("inputs", required) diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index 3a6674b..f981cef 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -85,7 +85,7 @@ class SoftwareNodeSecurityTests(unittest.TestCase): "origin.plot@v2", "ansys.geometry.inspect@v1", "ansys.mechanical.static_structural@v2", - "blender.scene.author@v1", + "blender.scene.author@v2", }, ) self.assertEqual(set(result["capabilities"]), set(enrollment.allowed_capabilities)) diff --git a/tests/test_software_output_publish.py b/tests/test_software_output_publish.py index 6293602..f5b9c40 100644 --- a/tests/test_software_output_publish.py +++ b/tests/test_software_output_publish.py @@ -50,6 +50,44 @@ class SoftwareOutputPublishTests(unittest.TestCase): content, ) + def test_workspace_publishes_optional_previews_requested_by_the_job(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + job_id = uuid4() + staging = root / ".zcbot_software_job_staging" / str(job_id) + staging.mkdir(parents=True) + files = { + "preview_isometric": ("preview-isometric.png", b"isometric"), + "preview_cutaway": ("preview-cutaway.png", b"cutaway"), + } + manifest = [] + for output_id, (filename, content) in files.items(): + (staging / filename).write_bytes(content) + manifest.append({ + "artifact_id": output_id, + "filename": filename, + "media_type": "image/png", + "size_bytes": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + }) + context = { + "capability": "blender.scene.author@v2", + "user_id": uuid4(), + "working_dir": str(root / "research"), + "request": { + "outputs": [ + {"key": "preview_cutaway", "type": "figure", "format": "png"} + ] + }, + } + (root / "research").mkdir() + with patch("web.routers.software_nodes.load_user_root", return_value=root): + previews = _publish_software_job_previews(job_id, context, manifest) + self.assertEqual( + {item["preview_id"] for item in previews}, + {"preview_isometric", "preview_cutaway"}, + ) + def test_explicit_export_registers_an_existing_preview_file(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 94f85d8..8cde8e5 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -278,11 +278,11 @@ class WindowsNodeSourceTests(unittest.TestCase): project, ) self.assertIn( - "..\\..\\software-contracts\\blender.scene.author.v1.json", + "..\\..\\software-contracts\\blender.scene.author.v2.json", project, ) self.assertIn( - "adapters\\blender.scene.author@v1\\blender.scene.author.v1.json", + "adapters\\blender.scene.author@v2\\blender.scene.author.v2.json", project, ) self.assertNotIn("software-contracts\\%(Filename)%(Extension)", project) @@ -495,10 +495,10 @@ class WindowsNodeSourceTests(unittest.TestCase): 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("adapters\\blender.scene.author@v2", script) self.assertIn("\\worker.py", script) self.assertIn("\\blender_worker.py", script) - self.assertIn("..\\software-contracts\\blender.scene.author.v1.json", script) + self.assertIn("..\\software-contracts\\blender.scene.author.v2.json", script) self.assertIn("tar.exe -a -c -f", script) self.assertNotIn("dotnet", script.lower()) diff --git a/tools/software_jobs.py b/tools/software_jobs.py index ec786ae..db0ca10 100644 --- a/tools/software_jobs.py +++ b/tools/software_jobs.py @@ -129,7 +129,7 @@ class SoftwareJobSubmitTool(_SoftwareJobTool): idempotency_key: str = "", ) -> str: try: - if not inputs or operation is None or not outputs: + if inputs is None or operation is None or outputs is None: return "[Error] inputs, operation, and outputs are required for a software job" canonical_inputs = [] for item in inputs: diff --git a/web/routers/software_nodes.py b/web/routers/software_nodes.py index 61223d9..e3a471e 100644 --- a/web/routers/software_nodes.py +++ b/web/routers/software_nodes.py @@ -302,7 +302,9 @@ def _publish_software_job_previews( contract = get_contract(context["capability"]) if contract.workspace is None: raise SoftwareJobError("capability does not define workspace previews") - preview_ids = set(contract.workspace.preview_outputs) + preview_ids = set(contract.workspace.preview_outputs).intersection( + contract.expected_outputs(context.get("request") or {}) + ) selected = [item for item in manifest if item["artifact_id"] in preview_ids] if len(selected) != len(preview_ids): raise SoftwareJobError("software job preview manifest is incomplete") @@ -498,6 +500,7 @@ def register_software_node_routes(app, *, require_user, require_admin) -> None: workspace_preview = ( contract.workspace is not None and artifact_id in contract.workspace.preview_outputs + and artifact_id in requested_ids ) workspace_export = ( contract.workspace is not None diff --git a/windows-node/README.md b/windows-node/README.md index f18ee5f..12f3635 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、ANSYS 和 `blender.scene.author@v1` Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;Origin OPJU 与 Blender `.blend` 工程保存在各自的 `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@v2` Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;Origin OPJU 与 Blender `.blend` 工程保存在各自的 `workspaces//current`,只保留一份 `rollback`,完成时自动上传轻量预览,用户明确请求导出时才上传并发布所选正式产物。 在仓库根目录执行一条命令生成可分发 ZIP: @@ -22,7 +22,7 @@ Windows Node 不再提供统一安装脚本。解压发布包后直接运行 `Zc 主窗口“运行设置”可把完整数据根目录迁移到其他本机固定磁盘。Node 会先停止接收任务,已有未完成任务时拒绝开始;随后把身份、任务、Workspace 和 runtime 复制到临时目录并逐文件校验,校验成功后才切换账号级配置并重启,旧目录不会自动删除。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先于 UI 设置;设置后 UI 只读,适合统一运维。软件卡始终把 runtime 安装到当前数据根目录,后续更新不会写回默认目录。 -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`,在 Blender 软件卡中单独安装即可。 +Blender adapter 0.2.0 面向 Blender 5.2 LTS,以 `rotary_kiln` 目标状态生成参数化回转窑,支持长度、直径、壳体/耐火衬里厚度、斜度、2–8 档支撑,以及驱动、燃烧器、窑头尾罩、检修平台和窑内物料开关;一次任务可输出等轴测、侧视、窑头、窑尾、纵向剖切和横截面 PNG,并生成可续作 `scene.blend`、场景清单、溯源和可选 GLB。剖切只影响渲染态,工程保存完整模型。启动命令固定包含 background、factory startup、禁止自动脚本和 Python 异常退出码,渲染优先使用 5.2 的 Eevee 标识;请求不接受 Python、命令行、路径、URL 或插件。只更新该 adapter 时运行 `package-blender-adapter.bat`,退出托盘 Node 后移除旧 v1 目录、放入 `adapters\blender.scene.author@v2\` 并重启;若节点没有 `runtimes\blender`,在 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/SoftwareRuntimeManager.cs b/windows-node/Zcbot.WindowsNode/SoftwareRuntimeManager.cs index ce0bb94..9a6b5f2 100644 --- a/windows-node/Zcbot.WindowsNode/SoftwareRuntimeManager.cs +++ b/windows-node/Zcbot.WindowsNode/SoftwareRuntimeManager.cs @@ -61,7 +61,7 @@ internal static class SoftwareRuntimeManager SoftwarePathKind.Executable, "ZCBOT_BLENDER_EXE", "blender.exe", - @"adapters\blender.scene.author@v1\requirements.txt"), + @"adapters\blender.scene.author@v2\requirements.txt"), ]; internal static SoftwareDefinition ByRuntimeId(string runtimeId) => diff --git a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj index 22d729a..43345cc 100644 --- a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj +++ b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj @@ -35,8 +35,8 @@ PreserveNewest PreserveNewest - - adapters\blender.scene.author@v1\blender.scene.author.v1.json + + adapters\blender.scene.author@v2\blender.scene.author.v2.json PreserveNewest PreserveNewest diff --git a/windows-node/adapters/blender.scene.author@v1/adapter.json b/windows-node/adapters/blender.scene.author@v2/adapter.json similarity index 58% rename from windows-node/adapters/blender.scene.author@v1/adapter.json rename to windows-node/adapters/blender.scene.author@v2/adapter.json index 7df4a10..7e77e05 100644 --- a/windows-node/adapters/blender.scene.author@v1/adapter.json +++ b/windows-node/adapters/blender.scene.author@v2/adapter.json @@ -1,10 +1,10 @@ { - "capability": "blender.scene.author@v1", - "adapter_version": "0.1.0", + "capability": "blender.scene.author@v2", + "adapter_version": "0.2.0", "runtime": "python", "runtime_id": "blender", "entrypoint": "worker.py", - "contract": "blender.scene.author.v1.json", + "contract": "blender.scene.author.v2.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@v2/blender_worker.py similarity index 63% rename from windows-node/adapters/blender.scene.author@v1/blender_worker.py rename to windows-node/adapters/blender.scene.author@v2/blender_worker.py index d3ad84e..5090775 100644 --- a/windows-node/adapters/blender.scene.author@v1/blender_worker.py +++ b/windows-node/adapters/blender.scene.author@v2/blender_worker.py @@ -14,7 +14,16 @@ from typing import Any import bpy from mathutils import Vector -ADAPTER_VERSION = "0.1.0" +ADAPTER_VERSION = "0.2.0" + +VIEW_OUTPUTS = { + "isometric": ("preview_isometric", "preview-isometric.png"), + "side": ("preview_side", "preview-side.png"), + "kiln_head": ("preview_kiln_head", "preview-kiln-head.png"), + "kiln_tail": ("preview_kiln_tail", "preview-kiln-tail.png"), + "longitudinal_cutaway": ("preview_cutaway", "preview-cutaway.png"), + "cross_section": ("preview_cross_section", "preview-cross-section.png"), +} def atomic_json(path: Path, value: Any) -> None: @@ -85,6 +94,53 @@ def cube(name: str, location: Vector, dimensions: tuple[float, float, float], ma return obj +def material_bed( + name: str, + location: Vector, + direction: Vector, + radius: float, + length: float, + mat: Any, + segments: int = 48, +) -> Any: + """Create a longitudinal bed with a flat free surface and curved kiln-side bottom.""" + axis = direction.normalized() + lateral = Vector((0, 1, 0)) + vertical = axis.cross(lateral).normalized() + start = location - axis * length / 2 + end = location + axis * length / 2 + angle_offset = math.asin(0.22) + angles = [ + math.pi + angle_offset + + (math.pi - 2 * angle_offset) * index / segments + for index in range(segments + 1) + ] + vertices: list[tuple[float, float, float]] = [] + for angle in angles: + radial = lateral * math.cos(angle) + vertical * math.sin(angle) + vertices.extend((tuple(start + radial * radius), tuple(end + radial * radius))) + faces: list[tuple[int, ...]] = [] + for index in range(segments): + current = index * 2 + following = (index + 1) * 2 + faces.append((current, following, following + 1, current + 1)) + last = segments * 2 + faces.extend( + [ + tuple(range(last, -1, -2)), + tuple(range(1, last + 2, 2)), + (0, 1, last + 1, last), + ] + ) + mesh = bpy.data.meshes.new(name + "Mesh") + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(obj) + assign(obj, mat) + return obj + + def cylinder( name: str, location: Vector, @@ -119,6 +175,66 @@ def torus(name: str, location: Vector, direction: Vector, major: float, minor: f return obj +def partial_tube( + name: str, + location: Vector, + direction: Vector, + outer_radius: float, + inner_radius: float, + length: float, + mat: Any, + segments: int = 64, + start_angle: float = -math.pi / 2, + angle_span: float = math.pi, +) -> Any: + """Create the far half of a tube for non-destructive cutaway renders.""" + axis = direction.normalized() + lateral = Vector((0, 1, 0)) + vertical = axis.cross(lateral).normalized() + start = location - axis * length / 2 + end = location + axis * length / 2 + vertices: list[tuple[float, float, float]] = [] + faces: list[tuple[int, ...]] = [] + for index in range(segments + 1): + angle = start_angle + angle_span * index / segments + radial = lateral * math.cos(angle) + vertical * math.sin(angle) + vertices.extend( + [ + tuple(start + radial * outer_radius), + tuple(end + radial * outer_radius), + tuple(start + radial * inner_radius), + tuple(end + radial * inner_radius), + ] + ) + for index in range(segments): + current = index * 4 + following = (index + 1) * 4 + faces.extend( + [ + (current, following, following + 1, current + 1), + (current + 2, current + 3, following + 3, following + 2), + (current, current + 2, following + 2, following), + (current + 1, following + 1, following + 3, current + 3), + ] + ) + last = segments * 4 + faces.extend( + [ + (0, 1, 3, 2), + (last, last + 2, last + 3, last + 1), + ] + ) + mesh = bpy.data.meshes.new(name + "Mesh") + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(obj) + assign(obj, mat) + obj.hide_render = True + obj.hide_viewport = True + return obj + + def look_at(obj: Any, target: Vector) -> None: obj.rotation_euler = (target - obj.location).to_track_quat("-Z", "Y").to_euler() @@ -161,16 +277,71 @@ def build_rotary_kiln(spec: dict[str, Any]) -> dict[str, Any]: hot_mat = material("HotMaterial", appearance.get("material_color", "#E35D20"), 0.0, 0.55) concrete_mat = material("Concrete", "#77736B", 0.0, 0.9) + shell_thickness = float(kiln["shell_thickness_m"]) + lining_thickness = float(kiln["lining_thickness_m"]) 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"])) + lining_outer_radius = max(0.06, radius - shell_thickness) + inner_radius = max(0.05, lining_outer_radius - lining_thickness) + partial_tube( + "CutawayShell", + center, + direction, + radius, + lining_outer_radius, + length, + shell_mat, + 96, + ) + partial_tube( + "CutawayLining", + center, + direction, + lining_outer_radius - 0.002, + inner_radius, + length * 0.99, + lining_mat, + 96, + ) + section_center = axis_at(length / 2 + 0.04) + partial_tube( + "SectionShell", + section_center, + direction, + radius, + lining_outer_radius, + 0.06, + shell_mat, + 96, + 0, + math.tau, + ) + partial_tube( + "SectionLining", + section_center + direction * 0.035, + direction, + lining_outer_radius - 0.002, + inner_radius, + 0.06, + lining_mat, + 96, + 0, + math.tau, + ) 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) + material_bed( + "KilnMaterial", + center, + direction, + inner_radius * 0.92, + length * 0.985, + hot_mat, + ) station_positions: list[float] = [] for index in range(stations): @@ -239,6 +410,7 @@ def build_rotary_kiln(spec: dict[str, Any]) -> dict[str, Any]: "support_stations": stations, "rotational_speed_rpm": float(kiln.get("rotational_speed_rpm", 3.0)), "center": list(center), + "direction": list(direction), } @@ -253,7 +425,7 @@ def configure_scene(spec: dict[str, Any], dimensions: dict[str, Any]) -> None: 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"): + for engine in ("BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"): try: scene.render.engine = engine break @@ -272,23 +444,6 @@ def configure_scene(spec: dict[str, Any], dimensions: dict[str, Any]) -> None: 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" @@ -303,6 +458,93 @@ def configure_scene(spec: dict[str, Any], dimensions: dict[str, Any]) -> None: look_at(area, target) +def set_render_mode(view: str) -> None: + for obj in bpy.context.scene.objects: + obj.hide_render = False + for name in ("CutawayShell", "CutawayLining", "SectionShell", "SectionLining"): + obj = bpy.data.objects.get(name) + if obj is not None: + obj.hide_render = True + helper_names = ( + ("SectionShell", "SectionLining") + if view == "cross_section" + else ("CutawayShell", "CutawayLining") + ) + if view in {"longitudinal_cutaway", "cross_section"}: + for name in helper_names: + obj = bpy.data.objects.get(name) + if obj is not None: + obj.hide_render = False + shell = bpy.data.objects.get("KilnShell") + if shell is not None: + shell.hide_render = view in {"longitudinal_cutaway", "cross_section"} + for name in ("HeadLiningFace", "TailLiningFace", "KilnHeadHood", "KilnTailHood"): + obj = bpy.data.objects.get(name) + if obj is not None: + obj.hide_render = view in {"longitudinal_cutaway", "cross_section"} + if view == "cross_section": + hidden_prefixes = ( + "Burner", + "DriveMotor", + "Foundation_", + "Gearbox", + "GirthGear", + "Ground", + "InspectionPlatform", + "PlatformLeg", + "SupportRoller_", + "Tyre_", + ) + for obj in bpy.context.scene.objects: + if obj.name.startswith(hidden_prefixes): + obj.hide_render = True + + +def configure_camera(view: str, dimensions: dict[str, Any]) -> None: + scene = bpy.context.scene + for obj in [item for item in scene.objects if item.type == "CAMERA"]: + bpy.data.objects.remove(obj, do_unlink=True) + length = dimensions["length_m"] + diameter = dimensions["outer_diameter_m"] + target = Vector(dimensions["center"]) + direction = Vector(dimensions["direction"]) + offsets = { + "isometric": Vector((length * 0.62, -length * 0.68, length * 0.34)), + "side": Vector((0, -length * 0.82, length * 0.12)), + "kiln_head": direction * length * 0.72, + "kiln_tail": direction * -length * 0.72, + "longitudinal_cutaway": Vector((length * 0.55, -length * 0.66, length * 0.3)), + "cross_section": direction * length * 0.72, + } + 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 + if view == "cross_section": + camera.data.ortho_scale = diameter * 2.4 + else: + 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 + + +def validate_render_request(request: dict[str, Any], spec: dict[str, Any]) -> list[str]: + views = list((spec.get("render") or {}).get("views") or []) + if not views or len(views) != len(set(views)) or "isometric" not in views: + raise ValueError("BLENDER_RENDER_VIEWS_INVALID") + requested = { + item.get("key") + for item in request["outputs"] + if isinstance(item, dict) and isinstance(item.get("key"), str) + } + expected = {VIEW_OUTPUTS[view][0] for view in views if view != "isometric"} + if requested - {"scene_glb"} != expected: + raise ValueError("BLENDER_RENDER_OUTPUTS_MISMATCH") + return views + + 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}") @@ -329,16 +571,25 @@ def execute(job_dir: Path, record: dict[str, Any]) -> list[dict[str, Any]]: clear_scene(base) dimensions = build_rotary_kiln(spec) configure_scene(spec, dimensions) + views = validate_render_request(request, spec) 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) + + rendered: list[tuple[Path, str]] = [] + for view in views: + set_render_mode(view) + configure_camera(view, dimensions) + output_id, filename = VIEW_OUTPUTS[view] + preview = output / filename + bpy.context.scene.render.filepath = str(preview) + bpy.ops.render.render(write_still=True) + rendered.append((preview, output_id)) + set_render_mode("isometric") meshes = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] atomic_json( @@ -347,6 +598,7 @@ def execute(job_dir: Path, record: dict[str, Any]) -> list[dict[str, Any]]: "title": spec["title"], "scene_type": "rotary_kiln", "dimensions": dimensions, + "rendered_views": views, "objects": [ { "name": obj.name, @@ -369,18 +621,23 @@ def execute(job_dir: Path, record: dict[str, Any]) -> list[dict[str, Any]]: "blender_version": bpy.app.version_string, "request_digest": record["request_digest"], "workspace_mode": workspace.get("mode"), - "generator": "rotary_kiln@v1", + "generator": "rotary_kiln@v2", }, ) artifacts = [ artifact(project, "project", "application/x-blender"), - artifact(preview, "preview", "image/png"), + *[artifact(path, output_id, "image/png") for path, output_id in rendered], 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) + bpy.ops.export_scene.gltf( + filepath=str(glb), + export_format="GLB", + export_apply=True, + use_renderable=True, + ) artifacts.append(artifact(glb, "scene_glb", "model/gltf-binary")) artifacts.append(artifact(provenance, "provenance", "application/json")) return artifacts diff --git a/windows-node/adapters/blender.scene.author@v1/requirements.txt b/windows-node/adapters/blender.scene.author@v2/requirements.txt similarity index 100% rename from windows-node/adapters/blender.scene.author@v1/requirements.txt rename to windows-node/adapters/blender.scene.author@v2/requirements.txt diff --git a/windows-node/adapters/blender.scene.author@v1/worker.py b/windows-node/adapters/blender.scene.author@v2/worker.py similarity index 98% rename from windows-node/adapters/blender.scene.author@v1/worker.py rename to windows-node/adapters/blender.scene.author@v2/worker.py index 2070678..6b9171b 100644 --- a/windows-node/adapters/blender.scene.author@v1/worker.py +++ b/windows-node/adapters/blender.scene.author@v2/worker.py @@ -12,7 +12,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any -ADAPTER_VERSION = "0.1.0" +ADAPTER_VERSION = "0.2.0" BLENDER_ENV = "ZCBOT_BLENDER_EXE" @@ -148,6 +148,8 @@ def run(job_dir: Path) -> int: "--background", "--factory-startup", "--disable-autoexec", + "--python-exit-code", + "1", "--python", str(script), "--", diff --git a/windows-node/package-blender-adapter.bat b/windows-node/package-blender-adapter.bat index 140b1be..2d94727 100644 --- a/windows-node/package-blender-adapter.bat +++ b/windows-node/package-blender-adapter.bat @@ -4,10 +4,10 @@ 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" +set "SOURCE_DIR=%~dp0adapters\blender.scene.author@v2" +set "CONTRACT=%~dp0..\software-contracts\blender.scene.author.v2.json" +set "PACKAGE_DIR=!OUTPUT_ROOT!\blender.scene.author@v2" +set "ARCHIVE_PATH=!OUTPUT_ROOT!\blender.scene.author@v2-adapter.zip" if not exist "%SOURCE_DIR%\adapter.json" goto :failed if not exist "%SOURCE_DIR%\worker.py" goto :failed @@ -25,10 +25,10 @@ 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 +copy /y "%CONTRACT%" "!PACKAGE_DIR!\blender.scene.author.v2.json" >nul if errorlevel 1 goto :failed -tar.exe -a -c -f "!ARCHIVE_PATH!" -C "!OUTPUT_ROOT!" "blender.scene.author@v1" +tar.exe -a -c -f "!ARCHIVE_PATH!" -C "!OUTPUT_ROOT!" "blender.scene.author@v2" if errorlevel 1 goto :failed echo [OK] Blender adapter package: !ARCHIVE_PATH! exit /b 0