diff --git a/DESIGN.md b/DESIGN.md index 7c290ed..42bea27 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -476,7 +476,9 @@ 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@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,续作先验证并打开上一版工程,再以完整目标状态生成新版本。 +Blender 入口为 `blender.scene.author@v3`,唯一场景类型是 `recipe`;它是 Blender 专属的声明式静态场景目标状态,不是跨软件万能 DSL,也不是 Python、逐步按键或任意节点脚本。Recipe v1 组合带稳定 ID、父子层级和集合归属的 geometry/generator/text/empty 对象:geometry 覆盖 box/cylinder/cone/sphere/torus/plane、二维轮廓拉伸、轴向旋转、路径扫掠和有壁厚直管,受控 modifier 覆盖 boolean/bevel/mirror/array/solidify,并统一声明材质、变换、灯光、世界背景和 1–8 个相机视图;`rotary_kiln` 只是 generator 组件,可与其他对象处于同一场景,不再占据顶层 capability。adapter 1.0.0 把合同校验后的 Recipe 编译为固定 `bpy` 调用,限制请求 256 KiB、200 个声明对象、展开后 1000 个 Blender 对象、100 万 mesh polygon、材质/灯光/视图和修改器数量;Worker 还机械校验 ID 唯一性、引用、父子关系、布尔目标、管壁和请求预览的一致性。输出固定包含 `.blend`、首张 PNG、场景清单和溯源,可再选择最多七张 PNG 与 GLB;预览用 `preview_01`–`preview_08` 稳定槽位,视图业务名称记录在 manifest。 + +Scene Recipe 每次按完整目标状态从 factory-empty 场景确定性重建,不把上一版 `.blend` 中的隐式手工状态当作事实源;Workspace 继续保存当前与 rollback 两份工程供结果留存和失败恢复,完整 Recipe 保存在 Job 请求中作为续作事实源。为避免旧的回转窑专用状态与新语义混用,本次按用户明确授权直接移除 v2 合同与调度,旧 v2 Workspace 文件不删除但不能作为 v3 source。目标执行版本仍为 Blender 5.2 LTS;受管 Python 只负责发现并以 `--background --factory-startup --disable-autoexec --python-exit-code 1` 拉起已安装的 Blender,实际 `bpy` 在 Blender 自带 Python 内执行,请求不能提供脚本、命令行、本机路径、URL、插件、driver 或任意 Geometry Nodes。 专业软件的连续加工以独立 `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/RUN.md b/RUN.md index 433f979..50f1bf9 100644 --- a/RUN.md +++ b/RUN.md @@ -1107,7 +1107,9 @@ 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@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 或本机路径。 +Blender 能力为 `blender.scene.author@v3`,目标节点版本为 Blender 5.2 LTS。请求固定使用 `scene.type=recipe`、`recipe_version=1`,通过 `objects[]` 组合 geometry/generator/text/empty:geometry 支持 box、cylinder、cone、sphere、torus、plane、extrude、revolve、sweep 和 pipe,generator 当前提供可与其他对象组合的 `rotary_kiln`;对象可声明父级、集合、材质、变换及 boolean/bevel/mirror/array/solidify 修改器。`render.views` 提供 1–8 个透视或正交相机,每个视图绑定唯一的 `preview_01`–`preview_08`,必须包含 `preview_01`;首张预览固定生成,其余视图必须在 `outputs` 请求同名 PNG,GLB 仍按需请求。任务同时生成 `scene.blend`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`。 + +Recipe 是完整目标状态,每次从空场景重建;修改已有 v3 结果时,从 `software_job_status` 的 `editable_request` 形成完整下一版 `operation/outputs` 后调用 `software_job_revise`。v2 回转窑合同已破坏性移除,旧 Workspace 不能作为 v3 source,节点磁盘上的旧 `.blend` 不会自动删除;确需保留时应在部署前从 `\workspaces\\current\scene.blend` 备份。Node 会优先使用 UI 中指定的 `blender.exe`,否则从 `ZCBOT_BLENDER_EXE`、Windows 卸载注册表、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender。启动器固定使用 Blender 后台、factory startup、禁止自动脚本和 Python 异常退出码,渲染优先使用 5.2 的 Eevee Next 标识并向旧 Eevee 标识回退;不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL、本机路径、driver 或任意节点脚本。 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`。 @@ -1152,7 +1154,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@v2-adapter.zip`,替换规则与 Origin 相同。升级时退出托盘 Node,删除旧 `adapters\blender.scene.author@v1\` 并整体放入 v2 目录后重启;Blender 的 `requirements.txt` 当前不安装第三方包,`bpy` 不安装到轻量启动 runtime。 +Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v3-adapter.zip`,替换规则与 Origin 相同。升级时先从托盘退出 Node,删除程序目录中的 `adapters\blender.scene.author@v1\`、`adapters\blender.scene.author@v2\`,整体放入 v3 目录后重启;数据根目录中的旧 Workspace 不在清理范围。Blender 的 `requirements.txt` 当前不安装第三方包,`bpy` 不安装到轻量启动 runtime。 注册配置写入 `\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。 diff --git a/software-contracts/blender.scene.author.v2.json b/software-contracts/blender.scene.author.v2.json deleted file mode 100644 index 341458a..0000000 --- a/software-contracts/blender.scene.author.v2.json +++ /dev/null @@ -1,269 +0,0 @@ -{ - "capability": "blender.scene.author@v2", - "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_isometric": { - "filename": "preview-isometric.png", - "media_type": "image/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", - "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.2.0" - }, - "summary": { - "title_path": ["operation", "scene", "title"] - }, - "legacy_runtime": null, - "workspace": { - "state_output": "project", - "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", - "type": "object", - "x-maxBytes": 65536, - "required": ["schema_version", "inputs", "operation", "outputs"], - "additionalProperties": false, - "properties": { - "schema_version": {"const": 2}, - "inputs": { - "type": "array", - "maxItems": 0 - }, - "operation": { - "type": "object", - "required": ["scene"], - "additionalProperties": false, - "properties": { - "scene": {"$ref": "#/$defs/rotaryKilnScene"} - } - }, - "outputs": { - "type": "array", - "maxItems": 6, - "uniqueItems": true, - "description": "为 render.views 中除 isometric 外的每个视图请求对应 PNG,可另选 GLB。", - "items": { - "oneOf": [ - {"$ref": "#/$defs/sideOutput"}, - {"$ref": "#/$defs/headOutput"}, - {"$ref": "#/$defs/tailOutput"}, - {"$ref": "#/$defs/cutawayOutput"}, - {"$ref": "#/$defs/crossSectionOutput"}, - {"$ref": "#/$defs/glbOutput"} - ] - } - } - }, - "$defs": { - "color": { - "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", "render"], - "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", - "required": ["views"], - "additionalProperties": false, - "properties": { - "width": {"type": "integer", "minimum": 320, "maximum": 3840}, - "height": {"type": "integer", "minimum": 240, "maximum": 2160}, - "transparent_background": {"type": "boolean"}, - "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/software-contracts/blender.scene.author.v3.json b/software-contracts/blender.scene.author.v3.json new file mode 100644 index 0000000..38406f8 --- /dev/null +++ b/software-contracts/blender.scene.author.v3.json @@ -0,0 +1,407 @@ +{ + "capability": "blender.scene.author@v3", + "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_01": { + "filename": "preview-01.png", + "media_type": "image/png", + "relative_path": "preview-01.png", + "publish": true, + "required": true + }, + "preview_02": { + "filename": "preview-02.png", + "media_type": "image/png", + "relative_path": "preview-02.png", + "publish": true, + "required": false + }, + "preview_03": { + "filename": "preview-03.png", + "media_type": "image/png", + "relative_path": "preview-03.png", + "publish": true, + "required": false + }, + "preview_04": { + "filename": "preview-04.png", + "media_type": "image/png", + "relative_path": "preview-04.png", + "publish": true, + "required": false + }, + "preview_05": { + "filename": "preview-05.png", + "media_type": "image/png", + "relative_path": "preview-05.png", + "publish": true, + "required": false + }, + "preview_06": { + "filename": "preview-06.png", + "media_type": "image/png", + "relative_path": "preview-06.png", + "publish": true, + "required": false + }, + "preview_07": { + "filename": "preview-07.png", + "media_type": "image/png", + "relative_path": "preview-07.png", + "publish": true, + "required": false + }, + "preview_08": { + "filename": "preview-08.png", + "media_type": "image/png", + "relative_path": "preview-08.png", + "publish": true, + "required": false + }, + "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": { + "recipe": "1.0.0" + }, + "summary": { + "title_path": ["operation", "scene", "title"] + }, + "legacy_runtime": null, + "workspace": { + "state_output": "project", + "preview_outputs": [ + "preview_01", "preview_02", "preview_03", "preview_04", + "preview_05", "preview_06", "preview_07", "preview_08" + ], + "required_adapter_version": "1.0.0" + }, + "request_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "x-maxBytes": 262144, + "required": ["schema_version", "inputs", "operation", "outputs"], + "additionalProperties": false, + "properties": { + "schema_version": {"const": 3}, + "inputs": {"type": "array", "maxItems": 0}, + "operation": { + "type": "object", + "required": ["scene"], + "additionalProperties": false, + "properties": { + "scene": {"$ref": "#/$defs/recipeScene"} + } + }, + "outputs": { + "type": "array", + "maxItems": 8, + "uniqueItems": true, + "description": "preview_01 固定生成;为其余 render.views 请求对应预览,可另选 GLB。", + "items": { + "oneOf": [ + {"$ref": "#/$defs/preview02Output"}, + {"$ref": "#/$defs/preview03Output"}, + {"$ref": "#/$defs/preview04Output"}, + {"$ref": "#/$defs/preview05Output"}, + {"$ref": "#/$defs/preview06Output"}, + {"$ref": "#/$defs/preview07Output"}, + {"$ref": "#/$defs/preview08Output"}, + {"$ref": "#/$defs/glbOutput"} + ] + } + } + }, + "$defs": { + "identifier": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$"}, + "color": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"}, + "positive": {"type": "number", "exclusiveMinimum": 0, "maximum": 10000}, + "vector2": { + "type": "array", "minItems": 2, "maxItems": 2, + "items": {"type": "number", "minimum": -10000, "maximum": 10000} + }, + "vector3": { + "type": "array", "minItems": 3, "maxItems": 3, + "items": {"type": "number", "minimum": -10000, "maximum": 10000} + }, + "scale3": { + "type": "array", "minItems": 3, "maxItems": 3, + "items": {"type": "number", "exclusiveMinimum": 0, "maximum": 1000} + }, + "transform": { + "type": "object", + "additionalProperties": false, + "properties": { + "position": {"$ref": "#/$defs/vector3"}, + "rotation_deg": {"$ref": "#/$defs/vector3"}, + "scale": {"$ref": "#/$defs/scale3"} + } + }, + "material": { + "type": "object", + "required": ["id", "base_color"], + "additionalProperties": false, + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "base_color": {"$ref": "#/$defs/color"}, + "metallic": {"type": "number", "minimum": 0, "maximum": 1}, + "roughness": {"type": "number", "minimum": 0, "maximum": 1}, + "emission_color": {"$ref": "#/$defs/color"}, + "emission_strength": {"type": "number", "minimum": 0, "maximum": 100}, + "opacity": {"type": "number", "exclusiveMinimum": 0, "maximum": 1} + } + }, + "boxGeometry": { + "type": "object", "required": ["type", "size"], "additionalProperties": false, + "properties": {"type": {"const": "box"}, "size": {"$ref": "#/$defs/scale3"}} + }, + "cylinderGeometry": { + "type": "object", "required": ["type", "radius", "depth"], "additionalProperties": false, + "properties": { + "type": {"const": "cylinder"}, "radius": {"$ref": "#/$defs/positive"}, + "depth": {"$ref": "#/$defs/positive"}, + "vertices": {"type": "integer", "minimum": 8, "maximum": 256} + } + }, + "coneGeometry": { + "type": "object", "required": ["type", "radius1", "radius2", "depth"], "additionalProperties": false, + "properties": { + "type": {"const": "cone"}, "radius1": {"type": "number", "minimum": 0, "maximum": 10000}, + "radius2": {"type": "number", "minimum": 0, "maximum": 10000}, + "depth": {"$ref": "#/$defs/positive"}, + "vertices": {"type": "integer", "minimum": 8, "maximum": 256} + } + }, + "sphereGeometry": { + "type": "object", "required": ["type", "radius"], "additionalProperties": false, + "properties": { + "type": {"const": "sphere"}, "radius": {"$ref": "#/$defs/positive"}, + "segments": {"type": "integer", "minimum": 8, "maximum": 256}, + "rings": {"type": "integer", "minimum": 4, "maximum": 128} + } + }, + "torusGeometry": { + "type": "object", "required": ["type", "major_radius", "minor_radius"], "additionalProperties": false, + "properties": { + "type": {"const": "torus"}, "major_radius": {"$ref": "#/$defs/positive"}, + "minor_radius": {"$ref": "#/$defs/positive"}, + "major_segments": {"type": "integer", "minimum": 8, "maximum": 256}, + "minor_segments": {"type": "integer", "minimum": 4, "maximum": 128} + } + }, + "planeGeometry": { + "type": "object", "required": ["type", "size"], "additionalProperties": false, + "properties": {"type": {"const": "plane"}, "size": {"$ref": "#/$defs/vector2"}} + }, + "extrudeGeometry": { + "type": "object", "required": ["type", "profile", "depth"], "additionalProperties": false, + "properties": { + "type": {"const": "extrude"}, "depth": {"$ref": "#/$defs/positive"}, + "profile": {"type": "array", "minItems": 3, "maxItems": 128, "items": {"$ref": "#/$defs/vector2"}} + } + }, + "revolveGeometry": { + "type": "object", "required": ["type", "profile"], "additionalProperties": false, + "properties": { + "type": {"const": "revolve"}, + "profile": {"type": "array", "minItems": 2, "maxItems": 128, "items": {"$ref": "#/$defs/vector2"}}, + "angle_deg": {"type": "number", "exclusiveMinimum": 0, "maximum": 360}, + "segments": {"type": "integer", "minimum": 8, "maximum": 256} + } + }, + "sweepGeometry": { + "type": "object", "required": ["type", "path", "radius"], "additionalProperties": false, + "properties": { + "type": {"const": "sweep"}, "radius": {"$ref": "#/$defs/positive"}, + "resolution": {"type": "integer", "minimum": 0, "maximum": 12}, + "path": {"type": "array", "minItems": 2, "maxItems": 128, "items": {"$ref": "#/$defs/vector3"}} + } + }, + "pipeGeometry": { + "type": "object", "required": ["type", "start", "end", "outer_radius", "wall_thickness"], "additionalProperties": false, + "properties": { + "type": {"const": "pipe"}, "start": {"$ref": "#/$defs/vector3"}, "end": {"$ref": "#/$defs/vector3"}, + "outer_radius": {"$ref": "#/$defs/positive"}, "wall_thickness": {"$ref": "#/$defs/positive"}, + "vertices": {"type": "integer", "minimum": 8, "maximum": 256} + } + }, + "geometry": { + "oneOf": [ + {"$ref": "#/$defs/boxGeometry"}, {"$ref": "#/$defs/cylinderGeometry"}, + {"$ref": "#/$defs/coneGeometry"}, {"$ref": "#/$defs/sphereGeometry"}, + {"$ref": "#/$defs/torusGeometry"}, {"$ref": "#/$defs/planeGeometry"}, + {"$ref": "#/$defs/extrudeGeometry"}, {"$ref": "#/$defs/revolveGeometry"}, + {"$ref": "#/$defs/sweepGeometry"}, {"$ref": "#/$defs/pipeGeometry"} + ] + }, + "modifier": { + "oneOf": [ + { + "type": "object", "required": ["type", "width"], "additionalProperties": false, + "properties": {"type": {"const": "bevel"}, "width": {"$ref": "#/$defs/positive"}, "segments": {"type": "integer", "minimum": 1, "maximum": 12}} + }, + { + "type": "object", "required": ["type", "axes"], "additionalProperties": false, + "properties": {"type": {"const": "mirror"}, "axes": {"type": "array", "minItems": 1, "maxItems": 3, "uniqueItems": true, "items": {"enum": ["x", "y", "z"]}}} + }, + { + "type": "object", "required": ["type", "count", "offset"], "additionalProperties": false, + "properties": {"type": {"const": "array"}, "count": {"type": "integer", "minimum": 2, "maximum": 100}, "offset": {"$ref": "#/$defs/vector3"}} + }, + { + "type": "object", "required": ["type", "thickness"], "additionalProperties": false, + "properties": {"type": {"const": "solidify"}, "thickness": {"type": "number", "minimum": -1000, "maximum": 1000, "not": {"const": 0}}} + }, + { + "type": "object", "required": ["type", "operation", "target"], "additionalProperties": false, + "properties": {"type": {"const": "boolean"}, "operation": {"enum": ["union", "difference", "intersect"]}, "target": {"$ref": "#/$defs/identifier"}} + } + ] + }, + "object": { + "type": "object", + "required": ["id", "kind"], + "additionalProperties": false, + "properties": { + "id": {"$ref": "#/$defs/identifier"}, "name": {"type": "string", "minLength": 1, "maxLength": 160}, + "kind": {"enum": ["geometry", "generator", "text", "empty"]}, + "geometry": {"$ref": "#/$defs/geometry"}, + "generator": {"const": "rotary_kiln"}, + "parameters": {"$ref": "#/$defs/rotaryKilnParameters"}, + "text": {"type": "string", "minLength": 1, "maxLength": 1000}, + "text_size": {"$ref": "#/$defs/positive"}, + "text_extrude": {"type": "number", "minimum": 0, "maximum": 10}, + "transform": {"$ref": "#/$defs/transform"}, + "material": {"$ref": "#/$defs/identifier"}, + "parent": {"$ref": "#/$defs/identifier"}, + "collection": {"$ref": "#/$defs/identifier"}, + "modifiers": {"type": "array", "maxItems": 12, "items": {"$ref": "#/$defs/modifier"}}, + "visible": {"type": "boolean"} + }, + "allOf": [ + {"if": {"properties": {"kind": {"const": "geometry"}}}, "then": {"required": ["geometry"]}}, + {"if": {"properties": {"kind": {"const": "generator"}}}, "then": {"required": ["generator", "parameters"]}}, + {"if": {"properties": {"kind": {"const": "text"}}}, "then": {"required": ["text"]}} + ] + }, + "rotaryKilnParameters": { + "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": 1, "maximum": 200}, + "outer_diameter_m": {"type": "number", "minimum": 0.2, "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}, + "drive_system": {"type": "boolean"}, "burner": {"type": "boolean"}, + "hoods": {"type": "boolean"}, "inspection_platform": {"type": "boolean"}, + "material_bed": {"type": "boolean"} + } + }, + "light": { + "type": "object", "required": ["id", "type", "position", "energy"], "additionalProperties": false, + "properties": { + "id": {"$ref": "#/$defs/identifier"}, "type": {"enum": ["sun", "area", "point", "spot"]}, + "position": {"$ref": "#/$defs/vector3"}, "target": {"$ref": "#/$defs/vector3"}, + "energy": {"type": "number", "exclusiveMinimum": 0, "maximum": 1000000}, + "color": {"$ref": "#/$defs/color"}, "size": {"$ref": "#/$defs/positive"}, + "spot_size_deg": {"type": "number", "exclusiveMinimum": 0, "maximum": 180} + } + }, + "view": { + "type": "object", + "required": ["name", "output_key", "position", "target", "projection"], + "additionalProperties": false, + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 80}, + "output_key": {"enum": ["preview_01", "preview_02", "preview_03", "preview_04", "preview_05", "preview_06", "preview_07", "preview_08"]}, + "position": {"$ref": "#/$defs/vector3"}, "target": {"$ref": "#/$defs/vector3"}, + "projection": {"enum": ["perspective", "orthographic"]}, + "lens_mm": {"type": "number", "minimum": 1, "maximum": 300}, + "ortho_scale": {"$ref": "#/$defs/positive"}, + "clip_end": {"type": "number", "minimum": 1, "maximum": 100000} + } + }, + "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"}, + "background_color": {"$ref": "#/$defs/color"}, + "views": { + "type": "array", "minItems": 1, "maxItems": 8, + "contains": {"type": "object", "properties": {"output_key": {"const": "preview_01"}}, "required": ["output_key"]}, + "items": {"$ref": "#/$defs/view"} + } + } + }, + "recipeScene": { + "type": "object", + "required": ["type", "recipe_version", "title", "units", "objects", "render"], + "additionalProperties": false, + "properties": { + "type": {"const": "recipe"}, "recipe_version": {"const": 1}, + "title": {"type": "string", "minLength": 1, "maxLength": 160}, + "units": {"enum": ["meters", "millimeters"]}, + "materials": {"type": "array", "maxItems": 64, "items": {"$ref": "#/$defs/material"}}, + "objects": {"type": "array", "minItems": 1, "maxItems": 200, "items": {"$ref": "#/$defs/object"}}, + "lights": {"type": "array", "maxItems": 16, "items": {"$ref": "#/$defs/light"}}, + "render": {"$ref": "#/$defs/render"} + } + }, + "pngOutput": { + "type": "object", "required": ["key", "type", "format"], "additionalProperties": false, + "properties": {"key": {"type": "string"}, "type": {"const": "figure"}, "format": {"const": "png"}} + }, + "preview02Output": {"allOf": [{"$ref": "#/$defs/pngOutput"}, {"properties": {"key": {"const": "preview_02"}}}]}, + "preview03Output": {"allOf": [{"$ref": "#/$defs/pngOutput"}, {"properties": {"key": {"const": "preview_03"}}}]}, + "preview04Output": {"allOf": [{"$ref": "#/$defs/pngOutput"}, {"properties": {"key": {"const": "preview_04"}}}]}, + "preview05Output": {"allOf": [{"$ref": "#/$defs/pngOutput"}, {"properties": {"key": {"const": "preview_05"}}}]}, + "preview06Output": {"allOf": [{"$ref": "#/$defs/pngOutput"}, {"properties": {"key": {"const": "preview_06"}}}]}, + "preview07Output": {"allOf": [{"$ref": "#/$defs/pngOutput"}, {"properties": {"key": {"const": "preview_07"}}}]}, + "preview08Output": {"allOf": [{"$ref": "#/$defs/pngOutput"}, {"properties": {"key": {"const": "preview_08"}}}]}, + "glbOutput": { + "type": "object", "required": ["key", "type", "format"], "additionalProperties": false, + "properties": {"key": {"const": "scene_glb"}, "type": {"const": "model"}, "format": {"const": "glb"}} + } + } + } +} diff --git a/tests/fixtures/blender-rotary-kiln/request/request.json b/tests/fixtures/blender-rotary-kiln/request/request.json deleted file mode 100644 index 51dde96..0000000 --- a/tests/fixtures/blender-rotary-kiln/request/request.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "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-scene-recipe/request/request.json b/tests/fixtures/blender-scene-recipe/request/request.json new file mode 100644 index 0000000..9ae69df --- /dev/null +++ b/tests/fixtures/blender-scene-recipe/request/request.json @@ -0,0 +1,155 @@ +{ + "job_id": "11111111-1111-4111-8111-111111111111", + "lease_id": "22222222-2222-4222-8222-222222222222", + "request_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "request": { + "schema_version": 3, + "inputs": [], + "operation": { + "scene": { + "type": "recipe", + "recipe_version": 1, + "title": "材料实验室设备场景", + "units": "meters", + "materials": [ + {"id": "concrete", "base_color": "#B8BCC2", "roughness": 0.8}, + {"id": "steel", "base_color": "#55606A", "metallic": 0.75, "roughness": 0.28} + ], + "objects": [ + { + "id": "floor", + "kind": "geometry", + "geometry": {"type": "box", "size": [12, 8, 0.2]}, + "transform": {"position": [0, 0, -0.1]}, + "material": "concrete", + "modifiers": [{"type": "bevel", "width": 0.03, "segments": 2}] + }, + { + "id": "tank", + "kind": "geometry", + "geometry": {"type": "cylinder", "radius": 1.2, "depth": 3, "vertices": 64}, + "transform": {"position": [-3, 0, 1.5]}, + "material": "steel" + }, + { + "id": "process_pipe", + "kind": "geometry", + "geometry": { + "type": "sweep", + "path": [[-3, 0, 3], [-3, 0, 4], [0, 0, 4], [0, 0, 2]], + "radius": 0.1, + "resolution": 3 + }, + "material": "steel" + }, + { + "id": "kiln", + "kind": "generator", + "generator": "rotary_kiln", + "parameters": { + "length_m": 6, + "outer_diameter_m": 0.8, + "shell_thickness_m": 0.02, + "lining_thickness_m": 0.08, + "slope_percent": 3.5, + "support_stations": 3, + "drive_system": true, + "burner": true, + "hoods": true, + "inspection_platform": true, + "material_bed": true + }, + "transform": {"position": [1.5, 1.5, 1.2]} + }, + { + "id": "qa_cone", + "kind": "geometry", + "geometry": {"type": "cone", "radius1": 0.5, "radius2": 0.1, "depth": 1}, + "transform": {"position": [100, 0, 0]}, + "material": "steel", + "modifiers": [{"type": "array", "count": 2, "offset": [1, 0, 0]}], + "visible": false + }, + { + "id": "qa_sphere_cutter", + "kind": "geometry", + "geometry": {"type": "sphere", "radius": 0.4}, + "transform": {"position": [100, 0, 0]}, + "visible": false + }, + { + "id": "qa_plane", + "kind": "geometry", + "geometry": {"type": "plane", "size": [1, 1]}, + "transform": {"position": [100, 0, 0]}, + "modifiers": [{"type": "solidify", "thickness": 0.02}], + "visible": false + }, + { + "id": "qa_extrude", + "kind": "geometry", + "geometry": {"type": "extrude", "profile": [[0, 0], [1, 0], [0, 1]], "depth": 0.5}, + "transform": {"position": [100, 0, 0]}, + "modifiers": [{"type": "mirror", "axes": ["x"]}], + "visible": false + }, + { + "id": "qa_revolve", + "kind": "geometry", + "geometry": {"type": "revolve", "profile": [[0.3, 0], [0.5, 0.5], [0.2, 1]], "segments": 32}, + "transform": {"position": [100, 0, 0]}, + "modifiers": [{"type": "boolean", "operation": "difference", "target": "qa_sphere_cutter"}], + "visible": false + }, + { + "id": "qa_pipe", + "kind": "geometry", + "geometry": {"type": "pipe", "start": [0, 0, 0], "end": [1, 1, 1], "outer_radius": 0.2, "wall_thickness": 0.03}, + "transform": {"position": [100, 0, 0]}, + "visible": false + }, + { + "id": "label", + "kind": "text", + "text": "Materials Lab", + "text_size": 0.6, + "text_extrude": 0.02, + "transform": {"position": [-2, 2.5, 0.2], "rotation_deg": [90, 0, 0]}, + "material": "steel" + } + ], + "lights": [ + {"id": "key", "type": "area", "position": [4, -6, 8], "target": [0, 0, 1], "energy": 1500, "size": 6}, + {"id": "sun", "type": "sun", "position": [-8, -10, 12], "target": [0, 0, 0], "energy": 2.5} + ], + "render": { + "width": 960, + "height": 540, + "background_color": "#20252B", + "views": [ + { + "name": "overview", + "output_key": "preview_01", + "position": [12, -14, 10], + "target": [0, 0, 1.5], + "projection": "perspective", + "lens_mm": 52 + }, + { + "name": "front", + "output_key": "preview_02", + "position": [0, -16, 3], + "target": [0, 0, 1.5], + "projection": "orthographic", + "ortho_scale": 14 + } + ] + } + } + }, + "outputs": [ + {"key": "preview_02", "type": "figure", "format": "png"}, + {"key": "scene_glb", "type": "model", "format": "glb"} + ] + } +} diff --git a/tests/fixtures/blender-rotary-kiln/workspace.json b/tests/fixtures/blender-scene-recipe/workspace.json similarity index 100% rename from tests/fixtures/blender-rotary-kiln/workspace.json rename to tests/fixtures/blender-scene-recipe/workspace.json diff --git a/tests/test_blender_adapter.py b/tests/test_blender_adapter.py index 5e0c89a..4d6acf1 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@v2" +ADAPTER = ROOT / "windows-node" / "adapters" / "blender.scene.author@v3" LAUNCHER_PATH = ADAPTER / "worker.py" spec = importlib.util.spec_from_file_location("blender_adapter_launcher", LAUNCHER_PATH) @@ -25,83 +25,150 @@ spec.loader.exec_module(launcher) def request() -> dict[str, object]: return { - "schema_version": 2, + "schema_version": 3, "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", - }, + "type": "recipe", + "recipe_version": 1, + "title": "水泥实验室设备布置", + "units": "meters", + "materials": [ + { + "id": "concrete", + "base_color": "#B8BCC2", + "metallic": 0, + "roughness": 0.8, + }, + { + "id": "steel", + "base_color": "#55606A", + "metallic": 0.75, + "roughness": 0.28, + }, + ], + "objects": [ + { + "id": "floor", + "kind": "geometry", + "geometry": {"type": "box", "size": [12, 8, 0.2]}, + "transform": {"position": [0, 0, -0.1]}, + "material": "concrete", + "modifiers": [{"type": "bevel", "width": 0.03, "segments": 2}], + }, + { + "id": "process_pipe", + "kind": "geometry", + "geometry": { + "type": "pipe", + "start": [-2, 0, 1], + "end": [2, 0, 1], + "outer_radius": 0.12, + "wall_thickness": 0.01, + }, + "material": "steel", + }, + { + "id": "kiln", + "kind": "generator", + "generator": "rotary_kiln", + "parameters": { + "length_m": 6, + "outer_diameter_m": 0.8, + "shell_thickness_m": 0.02, + "lining_thickness_m": 0.08, + "slope_percent": 3.5, + "support_stations": 3, + "drive_system": True, + }, + "transform": {"position": [0, 0, 1.2]}, + }, + ], + "lights": [ + { + "id": "key", + "type": "area", + "position": [3, -5, 7], + "target": [0, 0, 0], + "energy": 1200, + "size": 5, + } + ], "render": { "width": 1280, "height": 720, - "transparent_background": False, - "views": ["isometric", "longitudinal_cutaway"], + "background_color": "#20252B", + "views": [ + { + "name": "总览", + "output_key": "preview_01", + "position": [10, -12, 8], + "target": [0, 0, 1], + "projection": "perspective", + "lens_mm": 50, + }, + { + "name": "正视", + "output_key": "preview_02", + "position": [0, -15, 3], + "target": [0, 0, 1], + "projection": "orthographic", + "ortho_scale": 12, + }, + ], }, } }, "outputs": [ - {"key": "preview_cutaway", "type": "figure", "format": "png"}, + {"key": "preview_02", "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@v2") + def test_scene_recipe_contract_is_workspace_backed(self) -> None: + contract = get_contract("blender.scene.author@v3") 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_isometric", - "preview_side", - "preview_kiln_head", - "preview_kiln_tail", - "preview_cutaway", - "preview_cross_section", - ), + tuple(f"preview_{index:02d}" for index in range(1, 9)), ) - self.assertEqual(contract.required_adapter_version(normalized), "0.2.0") + self.assertEqual(contract.required_adapter_version(normalized), "1.0.0") self.assertEqual( set(contract.expected_outputs(normalized)), { "project", - "preview_isometric", - "preview_cutaway", + "preview_01", + "preview_02", "scene_manifest", "scene_glb", "provenance", }, ) + def test_contract_supports_all_managed_geometry_kinds(self) -> None: + value = request() + objects = value["operation"]["scene"]["objects"] # type: ignore[index] + objects[:] = [ + {"id": "box", "kind": "geometry", "geometry": {"type": "box", "size": [1, 1, 1]}}, + {"id": "cylinder", "kind": "geometry", "geometry": {"type": "cylinder", "radius": 1, "depth": 2}}, + {"id": "cone", "kind": "geometry", "geometry": {"type": "cone", "radius1": 1, "radius2": 0, "depth": 2}}, + {"id": "sphere", "kind": "geometry", "geometry": {"type": "sphere", "radius": 1}}, + {"id": "torus", "kind": "geometry", "geometry": {"type": "torus", "major_radius": 1, "minor_radius": 0.2}}, + {"id": "plane", "kind": "geometry", "geometry": {"type": "plane", "size": [2, 2]}}, + {"id": "extrude", "kind": "geometry", "geometry": {"type": "extrude", "profile": [[0, 0], [1, 0], [0, 1]], "depth": 1}}, + {"id": "revolve", "kind": "geometry", "geometry": {"type": "revolve", "profile": [[0.5, 0], [1, 1]]}}, + {"id": "sweep", "kind": "geometry", "geometry": {"type": "sweep", "path": [[0, 0, 0], [1, 0, 1]], "radius": 0.1}}, + {"id": "pipe", "kind": "geometry", "geometry": {"type": "pipe", "start": [0, 0, 0], "end": [1, 0, 0], "outer_radius": 0.2, "wall_thickness": 0.02}}, + ] + get_contract("blender.scene.author@v3").normalize_request(value) + def test_contract_rejects_arbitrary_execution_and_unsafe_scale(self) -> None: - contract = get_contract("blender.scene.author@v2") + contract = get_contract("blender.scene.author@v3") malicious = request() malicious["operation"]["scene"]["python"] = "import os" # type: ignore[index] with self.assertRaises(SoftwareContractError): @@ -112,10 +179,10 @@ class BlenderContractTests(unittest.TestCase): with self.assertRaises(SoftwareContractError): contract.normalize_request(oversized) - invalid_geometry = request() - invalid_geometry["operation"]["scene"]["kiln"]["support_stations"] = 1 # type: ignore[index] + invalid_kind = request() + invalid_kind["operation"]["scene"]["objects"][0]["kind"] = "script" # type: ignore[index] with self.assertRaises(SoftwareContractError): - contract.normalize_request(invalid_geometry) + contract.normalize_request(invalid_kind) class BlenderLauncherTests(unittest.TestCase): @@ -131,6 +198,7 @@ class BlenderLauncherTests(unittest.TestCase): payload = json.loads(output.getvalue()) self.assertEqual(payload["health"], "ready") self.assertEqual(payload["software_version"], "5.2.0") + self.assertEqual(payload["adapter_version"], "1.0.0") def test_launcher_uses_fixed_background_and_autoexec_flags(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -167,24 +235,30 @@ class BlenderLauncherTests(unittest.TestCase): 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") + def test_bpy_worker_is_a_bounded_scene_recipe_compiler(self) -> None: + sources = { + name: (ADAPTER / name).read_text(encoding="utf-8") + for name in ("blender_worker.py", "scene_recipe.py", "rotary_kiln.py") + } + combined = "\n".join(sources.values()) for marker in ( - '"KilnShell"', - '"GirthGear"', - '"SupportRoller_', '"scene.blend"', - '"preview-isometric.png"', - '"CutawayShell"', - '"CutawayLining"', - '"longitudinal_cutaway"', + 'f"preview-{index:02d}.png"', '"scene.glb"', + "read_factory_settings", + "MAX_EXPANDED_OBJECTS", + "MAX_MESH_POLYGONS", + "primitive_cube_add", + "primitive_cylinder_add", + "primitive_cone_add", + "primitive_uv_sphere_add", + "primitive_torus_add", + "build_rotary_kiln", "use_renderable=True", - "bpy.ops.wm.open_mainfile", ): - self.assertIn(marker, source) - self.assertNotIn("exec(", source) - self.assertNotIn("eval(", source) + self.assertIn(marker, combined) + for forbidden in ("exec(", "eval(", "os.system", "bpy.ops.script"): + self.assertNotIn(forbidden, combined) if __name__ == "__main__": diff --git a/tests/test_software_job_tools.py b/tests/test_software_job_tools.py index 162b9d6..603bb80 100644 --- a/tests/test_software_job_tools.py +++ b/tests/test_software_job_tools.py @@ -111,21 +111,34 @@ class SoftwareJobToolTests(unittest.TestCase): 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", + "blender.scene.author@v3", inputs=[], operation={ "scene": { - "type": "rotary_kiln", + "type": "recipe", + "recipe_version": 1, "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"]}, + "units": "meters", + "objects": [{ + "id": "kiln", + "kind": "generator", + "generator": "rotary_kiln", + "parameters": { + "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": [{ + "name": "overview", + "output_key": "preview_01", + "position": [50, -70, 35], + "target": [0, 0, 0], + "projection": "perspective", + }]}, } }, outputs=[], diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index f981cef..259f473 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@v2", + "blender.scene.author@v3", }, ) 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 f5b9c40..6ea7813 100644 --- a/tests/test_software_output_publish.py +++ b/tests/test_software_output_publish.py @@ -57,8 +57,8 @@ class SoftwareOutputPublishTests(unittest.TestCase): 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"), + "preview_01": ("preview-01.png", b"overview"), + "preview_02": ("preview-02.png", b"front"), } manifest = [] for output_id, (filename, content) in files.items(): @@ -71,12 +71,12 @@ class SoftwareOutputPublishTests(unittest.TestCase): "sha256": hashlib.sha256(content).hexdigest(), }) context = { - "capability": "blender.scene.author@v2", + "capability": "blender.scene.author@v3", "user_id": uuid4(), "working_dir": str(root / "research"), "request": { "outputs": [ - {"key": "preview_cutaway", "type": "figure", "format": "png"} + {"key": "preview_02", "type": "figure", "format": "png"} ] }, } @@ -85,7 +85,7 @@ class SoftwareOutputPublishTests(unittest.TestCase): previews = _publish_software_job_previews(job_id, context, manifest) self.assertEqual( {item["preview_id"] for item in previews}, - {"preview_isometric", "preview_cutaway"}, + {"preview_01", "preview_02"}, ) def test_explicit_export_registers_an_existing_preview_file(self) -> None: diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 37ff611..03fb0bb 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -322,11 +322,11 @@ class WindowsNodeSourceTests(unittest.TestCase): project, ) self.assertIn( - "..\\..\\software-contracts\\blender.scene.author.v2.json", + "..\\..\\software-contracts\\blender.scene.author.v3.json", project, ) self.assertIn( - "adapters\\blender.scene.author@v2\\blender.scene.author.v2.json", + "adapters\\blender.scene.author@v3\\blender.scene.author.v3.json", project, ) self.assertNotIn("software-contracts\\%(Filename)%(Extension)", project) @@ -542,10 +542,12 @@ 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@v2", script) + self.assertIn("adapters\\blender.scene.author@v3", script) self.assertIn("\\worker.py", script) self.assertIn("\\blender_worker.py", script) - self.assertIn("..\\software-contracts\\blender.scene.author.v2.json", script) + self.assertIn("\\scene_recipe.py", script) + self.assertIn("\\rotary_kiln.py", script) + self.assertIn("..\\software-contracts\\blender.scene.author.v3.json", script) self.assertIn("tar.exe -a -c -f", script) self.assertNotIn("dotnet", script.lower()) diff --git a/windows-node/README.md b/windows-node/README.md index 12f3635..6c1b49e 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@v2` 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@v3` 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.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 软件卡中单独安装即可。 +Blender adapter 1.0.0 面向 Blender 5.2 LTS,将受管 Scene Recipe 编译为静态三维场景。Recipe 可组合基础体、拉伸/旋转/扫掠、带壁厚直管、文字、父子层级、集合、受控修改器、材质和灯光,`rotary_kiln` 作为普通 generator 与其他对象共同构图;一次任务可按声明相机输出最多八张 PNG,并生成 `scene.blend`、场景清单、溯源和可选 GLB。启动命令固定包含 background、factory startup、禁止自动脚本和 Python 异常退出码;请求不接受 Python、命令行、路径、URL、插件或任意节点脚本。只更新该 adapter 时运行 `package-blender-adapter.bat`,退出托盘 Node 后移除旧 v1/v2 目录、放入 `adapters\blender.scene.author@v3\` 并重启;旧 Workspace 文件保留但不能由 v3 续作。若节点没有 `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 9a6b5f2..5b2721b 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@v2\requirements.txt"), + @"adapters\blender.scene.author@v3\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 43345cc..58c1ab2 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@v2\blender.scene.author.v2.json + + adapters\blender.scene.author@v3\blender.scene.author.v3.json PreserveNewest PreserveNewest diff --git a/windows-node/adapters/blender.scene.author@v2/adapter.json b/windows-node/adapters/blender.scene.author@v2/adapter.json deleted file mode 100644 index 7e77e05..0000000 --- a/windows-node/adapters/blender.scene.author@v2/adapter.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "capability": "blender.scene.author@v2", - "adapter_version": "0.2.0", - "runtime": "python", - "runtime_id": "blender", - "entrypoint": "worker.py", - "contract": "blender.scene.author.v2.json", - "worker_timeout_minutes": 120, - "running_detail": "Blender 正在生成三维场景" -} diff --git a/windows-node/adapters/blender.scene.author@v2/blender_worker.py b/windows-node/adapters/blender.scene.author@v2/blender_worker.py deleted file mode 100644 index 5090775..0000000 --- a/windows-node/adapters/blender.scene.author@v2/blender_worker.py +++ /dev/null @@ -1,678 +0,0 @@ -"""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.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: - 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 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, - 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 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() - - -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_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 - - 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): - material_bed( - "KilnMaterial", - center, - direction, - inner_radius * 0.92, - length * 0.985, - hot_mat, - ) - - 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), - "direction": list(direction), - } - - -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", "BLENDER_EEVEE_NEXT"): - 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"]) - 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 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}") - 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) - views = validate_render_request(request, spec) - - output = job_dir / "output" - output.mkdir(exist_ok=True) - project = output / "scene.blend" - manifest = output / "scene-manifest.json" - provenance = output / "provenance.json" - bpy.ops.wm.save_as_mainfile(filepath=str(project), check_existing=False) - - 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( - manifest, - { - "title": spec["title"], - "scene_type": "rotary_kiln", - "dimensions": dimensions, - "rendered_views": views, - "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@v2", - }, - ) - - artifacts = [ - artifact(project, "project", "application/x-blender"), - *[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, - use_renderable=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@v2/requirements.txt b/windows-node/adapters/blender.scene.author@v2/requirements.txt deleted file mode 100644 index bfc9441..0000000 --- a/windows-node/adapters/blender.scene.author@v2/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -# The launcher uses only Python's standard library. bpy runs inside Blender. diff --git a/windows-node/adapters/blender.scene.author@v3/adapter.json b/windows-node/adapters/blender.scene.author@v3/adapter.json new file mode 100644 index 0000000..a11752d --- /dev/null +++ b/windows-node/adapters/blender.scene.author@v3/adapter.json @@ -0,0 +1,10 @@ +{ + "capability": "blender.scene.author@v3", + "adapter_version": "1.0.0", + "runtime": "python", + "runtime_id": "blender", + "entrypoint": "worker.py", + "contract": "blender.scene.author.v3.json", + "worker_timeout_minutes": 120, + "running_detail": "Blender 正在编译受管三维场景" +} diff --git a/windows-node/adapters/blender.scene.author@v3/blender_worker.py b/windows-node/adapters/blender.scene.author@v3/blender_worker.py new file mode 100644 index 0000000..6943a51 --- /dev/null +++ b/windows-node/adapters/blender.scene.author@v3/blender_worker.py @@ -0,0 +1,176 @@ +"""Blender-hosted executor for managed Scene Recipe jobs.""" + +from __future__ import annotations + +import hashlib +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import bpy + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from scene_recipe import SceneBuilder + +ADAPTER_VERSION = "1.0.0" +PREVIEW_FILENAMES = { + f"preview_{index:02d}": f"preview-{index:02d}.png" for index in range(1, 9) +} + + +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 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 validate_semantics(request: dict[str, Any], spec: dict[str, Any]) -> list[dict[str, Any]]: + if request.get("inputs") or spec.get("type") != "recipe" or spec.get("recipe_version") != 1: + raise ValueError("BLENDER_REQUEST_SEMANTICS_INVALID") + views = list(spec["render"]["views"]) + output_keys = [item["output_key"] for item in views] + if len(output_keys) != len(set(output_keys)) or "preview_01" not in output_keys: + 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 = set(output_keys) - {"preview_01"} + if requested - {"scene_glb"} != expected: + raise ValueError("BLENDER_RENDER_OUTPUTS_MISMATCH") + return views + + +def execute(job_dir: Path, record: dict[str, Any]) -> list[dict[str, Any]]: + request = record["request"] + spec = request["operation"]["scene"] + views = validate_semantics(request, spec) + builder = SceneBuilder(spec) + builder.compile() + + output = job_dir / "output" + output.mkdir(exist_ok=True) + project = output / "scene.blend" + manifest = output / "scene-manifest.json" + provenance = output / "provenance.json" + bpy.ops.wm.save_as_mainfile(filepath=str(project), check_existing=False) + + rendered: list[tuple[Path, str]] = [] + for view in views: + builder.configure_camera(view) + output_id = view["output_key"] + preview = output / PREVIEW_FILENAMES[output_id] + bpy.context.scene.render.filepath = str(preview) + bpy.ops.render.render(write_still=True) + rendered.append((preview, output_id)) + + atomic_json(manifest, builder.manifest(views)) + workspace = json.loads((job_dir / "workspace.json").read_text(encoding="utf-8")) + atomic_json( + provenance, + { + "adapter_version": ADAPTER_VERSION, + "blender_version": bpy.app.version_string, + "request_digest": record["request_digest"], + "workspace_mode": workspace.get("mode"), + "generator": "scene_recipe@1", + }, + ) + + artifacts = [ + artifact(project, "project", "application/x-blender"), + *[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, + use_renderable=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 scene recipe completed") + return 0 + except Exception as exc: + code = str(exc).split(":", 1)[0] if str(exc) else type(exc).__name__ + atomic_json( + job_dir / "terminal.json", + terminal(record, "failed", [], {"code": code[:80], "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@v3/requirements.txt b/windows-node/adapters/blender.scene.author@v3/requirements.txt new file mode 100644 index 0000000..c0f0bb8 --- /dev/null +++ b/windows-node/adapters/blender.scene.author@v3/requirements.txt @@ -0,0 +1 @@ +# The launcher uses only the standard library; bpy comes from Blender. diff --git a/windows-node/adapters/blender.scene.author@v3/rotary_kiln.py b/windows-node/adapters/blender.scene.author@v3/rotary_kiln.py new file mode 100644 index 0000000..8ec1eba --- /dev/null +++ b/windows-node/adapters/blender.scene.author@v3/rotary_kiln.py @@ -0,0 +1,139 @@ +"""Built-in rotary-kiln generator for the generic Scene Recipe compiler.""" + +from __future__ import annotations + +import math +from typing import Any + + +def build_rotary_kiln( + builder: Any, + root: Any, + prefix: str, + parameters: dict[str, Any], +) -> None: + length = float(parameters["length_m"]) + diameter = float(parameters["outer_diameter_m"]) + radius = diameter / 2 + slope_angle = math.degrees(math.atan(float(parameters["slope_percent"]) / 100)) + stations = int(parameters["support_stations"]) + shell = builder.generated_material(prefix + "_shell", "#707780", 0.65, 0.3) + metal = builder.generated_material(prefix + "_metal", "#303840", 0.8, 0.22) + lining = builder.generated_material(prefix + "_lining", "#B56A3B", 0.0, 0.75) + concrete = builder.generated_material(prefix + "_concrete", "#8D9398", 0.0, 0.9) + material = builder.generated_material(prefix + "_bed", "#D35B26", 0.0, 0.8) + + builder.add_generated_geometry( + prefix + "_shell_body", + {"type": "cylinder", "radius": radius, "depth": length, "vertices": 96}, + parent=root, + material=shell, + rotation_deg=(0, 90 + slope_angle, 0), + ) + builder.add_generated_geometry( + prefix + "_lining_body", + { + "type": "cylinder", + "radius": max(0.05, radius - float(parameters["shell_thickness_m"])), + "depth": length * 0.995, + "vertices": 96, + }, + parent=root, + material=lining, + rotation_deg=(0, 90 + slope_angle, 0), + ) + if parameters.get("material_bed", True): + builder.add_generated_geometry( + prefix + "_material_bed", + { + "type": "cylinder", + "radius": max( + 0.03, + radius + - float(parameters["shell_thickness_m"]) + - float(parameters["lining_thickness_m"]), + ), + "depth": length * 0.97, + "vertices": 64, + }, + parent=root, + material=material, + position=(0, 0, -radius * 0.25), + rotation_deg=(0, 90 + slope_angle, 0), + ) + + usable = length * 0.7 + for index in range(stations): + x = -usable / 2 + usable * index / (stations - 1) + z = -x * math.tan(math.radians(slope_angle)) + builder.add_generated_geometry( + f"{prefix}_tyre_{index + 1:02d}", + { + "type": "torus", + "major_radius": radius + diameter * 0.035, + "minor_radius": max(0.04, diameter * 0.055), + "major_segments": 64, + "minor_segments": 16, + }, + parent=root, + material=metal, + position=(x, 0, z), + rotation_deg=(0, 90 + slope_angle, 0), + ) + builder.add_generated_geometry( + f"{prefix}_foundation_{index + 1:02d}", + {"type": "box", "size": [diameter * 0.7, diameter * 1.8, radius * 0.8]}, + parent=root, + material=concrete, + position=(x, 0, z - radius * 1.25), + ) + + if parameters.get("drive_system", True): + builder.add_generated_geometry( + prefix + "_girth_gear", + { + "type": "torus", + "major_radius": radius + diameter * 0.08, + "minor_radius": max(0.05, diameter * 0.07), + "major_segments": 72, + "minor_segments": 16, + }, + parent=root, + material=metal, + position=(-length * 0.18, 0, length * 0.18 * math.tan(math.radians(slope_angle))), + rotation_deg=(0, 90 + slope_angle, 0), + ) + builder.add_generated_geometry( + prefix + "_drive_motor", + {"type": "box", "size": [diameter * 0.55, diameter * 0.4, diameter * 0.4]}, + parent=root, + material=metal, + position=(-length * 0.18, -radius * 1.45, -radius * 0.7), + ) + + if parameters.get("hoods", True): + for name, x in (("tail_hood", -length * 0.54), ("head_hood", length * 0.54)): + builder.add_generated_geometry( + prefix + "_" + name, + {"type": "box", "size": [diameter * 0.65, diameter * 1.4, diameter * 1.55]}, + parent=root, + material=metal, + position=(x, 0, -x * math.tan(math.radians(slope_angle))), + ) + if parameters.get("burner", True): + builder.add_generated_geometry( + prefix + "_burner", + {"type": "cylinder", "radius": max(0.03, diameter * 0.055), "depth": diameter * 2.2, "vertices": 32}, + parent=root, + material=metal, + position=(length * 0.57, 0, -length * 0.57 * math.tan(math.radians(slope_angle))), + rotation_deg=(0, 90 + slope_angle, 0), + ) + if parameters.get("inspection_platform", True): + builder.add_generated_geometry( + prefix + "_inspection_platform", + {"type": "box", "size": [diameter * 1.6, diameter * 0.9, 0.12]}, + parent=root, + material=metal, + position=(length * 0.35, -radius * 1.2, -radius * 0.2), + ) diff --git a/windows-node/adapters/blender.scene.author@v3/scene_recipe.py b/windows-node/adapters/blender.scene.author@v3/scene_recipe.py new file mode 100644 index 0000000..f7adc1a --- /dev/null +++ b/windows-node/adapters/blender.scene.author@v3/scene_recipe.py @@ -0,0 +1,535 @@ +"""Compile a bounded declarative Scene Recipe into Blender data blocks.""" + +from __future__ import annotations + +import math +from typing import Any + +import bpy +from mathutils import Vector + +MAX_EXPANDED_OBJECTS = 1000 +MAX_MESH_POLYGONS = 1_000_000 + + +def rgba(value: str, alpha: float = 1.0) -> tuple[float, float, float, float]: + return tuple(int(value[index:index + 2], 16) / 255 for index in (1, 3, 5)) + (alpha,) + + +def look_at(obj: Any, target: Vector) -> None: + direction = target - obj.location + if direction.length == 0: + raise ValueError("BLENDER_LOOK_AT_ZERO_LENGTH") + obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler() + + +class SceneBuilder: + def __init__(self, spec: dict[str, Any]) -> None: + self.spec = spec + self.unit_scale = 1.0 if spec["units"] == "meters" else 0.001 + self.objects: dict[str, Any] = {} + self.materials: dict[str, Any] = {} + self.collections: dict[str, Any] = {} + self._object_specs: dict[str, dict[str, Any]] = {} + + def length(self, value: float) -> float: + return float(value) * self.unit_scale + + def vector(self, values: list[float]) -> Vector: + return Vector(tuple(self.length(item) for item in values)) + + def reset(self) -> None: + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.unit_settings.system = "METRIC" + scene.unit_settings.length_unit = "METERS" + scene.unit_settings.scale_length = 1.0 + + def create_material(self, spec: dict[str, Any], identifier: str | None = None) -> Any: + material_id = identifier or spec["id"] + if material_id in self.materials: + return self.materials[material_id] + value = bpy.data.materials.new(material_id) + value.use_nodes = True + opacity = float(spec.get("opacity", 1.0)) + value.diffuse_color = rgba(spec.get("base_color", "#808080"), opacity) + principled = value.node_tree.nodes.get("Principled BSDF") + if principled is not None: + inputs = principled.inputs + inputs["Base Color"].default_value = rgba(spec.get("base_color", "#808080")) + inputs["Metallic"].default_value = float(spec.get("metallic", 0.0)) + inputs["Roughness"].default_value = float(spec.get("roughness", 0.5)) + inputs["Alpha"].default_value = opacity + emission = inputs.get("Emission Color") or inputs.get("Emission") + if emission is not None and spec.get("emission_color"): + emission.default_value = rgba(spec["emission_color"]) + strength = inputs.get("Emission Strength") + if strength is not None: + strength.default_value = float(spec.get("emission_strength", 0.0)) + if opacity < 1.0: + try: + value.surface_render_method = "DITHERED" + except (AttributeError, TypeError): + value.blend_method = "BLEND" + self.materials[material_id] = value + return value + + def generated_material( + self, + identifier: str, + color: str, + metallic: float = 0.0, + roughness: float = 0.5, + ) -> Any: + return self.create_material( + { + "id": identifier, + "base_color": color, + "metallic": metallic, + "roughness": roughness, + }, + identifier, + ) + + def _link_collection(self, obj: Any, collection_id: str | None) -> None: + if not collection_id: + return + collection = self.collections.get(collection_id) + if collection is None: + collection = bpy.data.collections.new(collection_id) + bpy.context.scene.collection.children.link(collection) + self.collections[collection_id] = collection + for current in list(obj.users_collection): + current.objects.unlink(obj) + collection.objects.link(obj) + + def _register(self, identifier: str, obj: Any, spec: dict[str, Any]) -> Any: + if identifier in self.objects: + raise ValueError(f"BLENDER_DUPLICATE_OBJECT_ID:{identifier}") + obj.name = str(spec.get("name") or identifier) + obj["zcbot_id"] = identifier + obj.hide_render = not bool(spec.get("visible", True)) + self._link_collection(obj, spec.get("collection")) + self.objects[identifier] = obj + self._object_specs[identifier] = spec + if len(bpy.context.scene.objects) > MAX_EXPANDED_OBJECTS: + raise ValueError("BLENDER_EXPANDED_OBJECT_LIMIT") + return obj + + def _assign_material(self, obj: Any, material_id: str | None) -> None: + if material_id is None: + return + material = self.materials.get(material_id) + if material is None: + raise ValueError(f"BLENDER_UNKNOWN_MATERIAL:{material_id}") + if getattr(obj.data, "materials", None) is not None: + obj.data.materials.append(material) + + def _apply_transform(self, obj: Any, transform: dict[str, Any] | None) -> None: + transform = transform or {} + obj.location = self.vector(transform.get("position", [0, 0, 0])) + obj.rotation_euler = tuple( + math.radians(float(item)) for item in transform.get("rotation_deg", [0, 0, 0]) + ) + obj.scale = tuple(float(item) for item in transform.get("scale", [1, 1, 1])) + + def _mesh_object( + self, + identifier: str, + spec: dict[str, Any], + vertices: list[tuple[float, float, float]], + faces: list[tuple[int, ...]], + ) -> Any: + mesh = bpy.data.meshes.new(identifier + "Mesh") + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new(identifier, mesh) + bpy.context.scene.collection.objects.link(obj) + return self._register(identifier, obj, spec) + + def _extrude(self, identifier: str, spec: dict[str, Any], geometry: dict[str, Any]) -> Any: + profile = [(self.length(x), self.length(y)) for x, y in geometry["profile"]] + depth = self.length(geometry["depth"]) + vertices = [(x, y, -depth / 2) for x, y in profile] + vertices += [(x, y, depth / 2) for x, y in profile] + count = len(profile) + faces: list[tuple[int, ...]] = [tuple(range(count - 1, -1, -1)), tuple(range(count, count * 2))] + faces.extend((index, (index + 1) % count, (index + 1) % count + count, index + count) for index in range(count)) + return self._mesh_object(identifier, spec, vertices, faces) + + def _revolve(self, identifier: str, spec: dict[str, Any], geometry: dict[str, Any]) -> Any: + profile = [(self.length(radius), self.length(z)) for radius, z in geometry["profile"]] + segments = int(geometry.get("segments", 64)) + angle = math.radians(float(geometry.get("angle_deg", 360))) + closed = math.isclose(angle, math.tau) + ring_count = segments if closed else segments + 1 + vertices = [ + (radius * math.cos(angle * ring / segments), radius * math.sin(angle * ring / segments), z) + for ring in range(ring_count) + for radius, z in profile + ] + width = len(profile) + faces: list[tuple[int, ...]] = [] + span_count = segments if closed else ring_count - 1 + for ring in range(span_count): + next_ring = (ring + 1) % ring_count + for index in range(width - 1): + a = ring * width + index + b = ring * width + index + 1 + c = next_ring * width + index + 1 + d = next_ring * width + index + faces.append((a, b, c, d)) + return self._mesh_object(identifier, spec, vertices, faces) + + def _sweep(self, identifier: str, spec: dict[str, Any], geometry: dict[str, Any]) -> Any: + curve = bpy.data.curves.new(identifier + "Curve", "CURVE") + curve.dimensions = "3D" + curve.resolution_u = int(geometry.get("resolution", 2)) + curve.bevel_depth = self.length(geometry["radius"]) + curve.bevel_resolution = min(6, int(geometry.get("resolution", 2))) + spline = curve.splines.new("POLY") + path = geometry["path"] + spline.points.add(len(path) - 1) + for point, coordinate in zip(spline.points, path): + value = self.vector(coordinate) + point.co = (*value, 1.0) + obj = bpy.data.objects.new(identifier, curve) + bpy.context.scene.collection.objects.link(obj) + return self._register(identifier, obj, spec) + + def _pipe(self, identifier: str, spec: dict[str, Any], geometry: dict[str, Any]) -> Any: + start = self.vector(geometry["start"]) + end = self.vector(geometry["end"]) + direction = end - start + if direction.length == 0: + raise ValueError("BLENDER_PIPE_ZERO_LENGTH") + outer = self.length(geometry["outer_radius"]) + inner = outer - self.length(geometry["wall_thickness"]) + if inner <= 0: + raise ValueError("BLENDER_PIPE_WALL_INVALID") + segments = int(geometry.get("vertices", 48)) + vertices: list[tuple[float, float, float]] = [] + for z in (-direction.length / 2, direction.length / 2): + for radius in (outer, inner): + vertices.extend( + (radius * math.cos(math.tau * i / segments), radius * math.sin(math.tau * i / segments), z) + for i in range(segments) + ) + faces: list[tuple[int, ...]] = [] + outer_bottom, inner_bottom, outer_top, inner_top = (0, segments, segments * 2, segments * 3) + for i in range(segments): + j = (i + 1) % segments + faces.extend( + [ + (outer_bottom + i, outer_bottom + j, outer_top + j, outer_top + i), + (inner_bottom + j, inner_bottom + i, inner_top + i, inner_top + j), + (outer_top + i, outer_top + j, inner_top + j, inner_top + i), + (outer_bottom + j, outer_bottom + i, inner_bottom + i, inner_bottom + j), + ] + ) + rotation = direction.to_track_quat("Z", "Y") + center = (start + end) / 2 + vertices = [tuple(rotation @ Vector(vertex) + center) for vertex in vertices] + obj = self._mesh_object(identifier, spec, vertices, faces) + return obj + + def _geometry(self, identifier: str, spec: dict[str, Any]) -> Any: + geometry = spec["geometry"] + kind = geometry["type"] + if kind == "box": + bpy.ops.mesh.primitive_cube_add(size=1) + obj = bpy.context.object + obj.dimensions = tuple(self.length(item) for item in geometry["size"]) + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + elif kind == "cylinder": + bpy.ops.mesh.primitive_cylinder_add( + vertices=int(geometry.get("vertices", 48)), + radius=self.length(geometry["radius"]), + depth=self.length(geometry["depth"]), + ) + obj = bpy.context.object + elif kind == "cone": + if geometry["radius1"] == 0 and geometry["radius2"] == 0: + raise ValueError("BLENDER_CONE_RADII_INVALID") + bpy.ops.mesh.primitive_cone_add( + vertices=int(geometry.get("vertices", 48)), + radius1=self.length(geometry["radius1"]), + radius2=self.length(geometry["radius2"]), + depth=self.length(geometry["depth"]), + ) + obj = bpy.context.object + elif kind == "sphere": + bpy.ops.mesh.primitive_uv_sphere_add( + segments=int(geometry.get("segments", 48)), + ring_count=int(geometry.get("rings", 24)), + radius=self.length(geometry["radius"]), + ) + obj = bpy.context.object + elif kind == "torus": + bpy.ops.mesh.primitive_torus_add( + major_segments=int(geometry.get("major_segments", 64)), + minor_segments=int(geometry.get("minor_segments", 16)), + location=(0, 0, 0), + major_radius=self.length(geometry["major_radius"]), + minor_radius=self.length(geometry["minor_radius"]), + ) + obj = bpy.context.object + elif kind == "plane": + bpy.ops.mesh.primitive_plane_add(size=1) + obj = bpy.context.object + obj.dimensions = (self.length(geometry["size"][0]), self.length(geometry["size"][1]), 0) + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + elif kind == "extrude": + return self._extrude(identifier, spec, geometry) + elif kind == "revolve": + return self._revolve(identifier, spec, geometry) + elif kind == "sweep": + return self._sweep(identifier, spec, geometry) + elif kind == "pipe": + return self._pipe(identifier, spec, geometry) + else: + raise ValueError(f"BLENDER_GEOMETRY_UNSUPPORTED:{kind}") + return self._register(identifier, obj, spec) + + def _text(self, identifier: str, spec: dict[str, Any]) -> Any: + bpy.ops.object.text_add() + obj = bpy.context.object + obj.data.body = spec["text"] + obj.data.size = self.length(spec.get("text_size", 1.0)) + obj.data.extrude = self.length(spec.get("text_extrude", 0.02)) + return self._register(identifier, obj, spec) + + def _empty(self, identifier: str, spec: dict[str, Any]) -> Any: + obj = bpy.data.objects.new(identifier, None) + bpy.context.scene.collection.objects.link(obj) + return self._register(identifier, obj, spec) + + def add_generated_geometry( + self, + identifier: str, + geometry: dict[str, Any], + *, + parent: Any, + material: Any, + position: tuple[float, float, float] = (0, 0, 0), + rotation_deg: tuple[float, float, float] = (0, 0, 0), + ) -> Any: + spec = { + "id": identifier, + "kind": "geometry", + "geometry": geometry, + "transform": {"position": list(position), "rotation_deg": list(rotation_deg)}, + } + obj = self._geometry(identifier, spec) + self._apply_transform(obj, spec["transform"]) + if getattr(obj.data, "materials", None) is not None: + obj.data.materials.append(material) + obj.parent = parent + return obj + + def _generator(self, identifier: str, spec: dict[str, Any]) -> Any: + root = self._empty(identifier, spec) + if spec["generator"] != "rotary_kiln": + raise ValueError("BLENDER_GENERATOR_UNSUPPORTED") + from rotary_kiln import build_rotary_kiln + + build_rotary_kiln(self, root, identifier, spec["parameters"]) + return root + + def _build_object(self, spec: dict[str, Any]) -> Any: + identifier = spec["id"] + kind = spec["kind"] + if kind == "geometry": + obj = self._geometry(identifier, spec) + elif kind == "generator": + obj = self._generator(identifier, spec) + elif kind == "text": + obj = self._text(identifier, spec) + elif kind == "empty": + obj = self._empty(identifier, spec) + else: + raise ValueError(f"BLENDER_OBJECT_KIND_UNSUPPORTED:{kind}") + self._apply_transform(obj, spec.get("transform")) + self._assign_material(obj, spec.get("material")) + return obj + + def _parent_objects(self) -> None: + parents = { + identifier: spec.get("parent") + for identifier, spec in self._object_specs.items() + if spec.get("parent") + } + for identifier in parents: + seen: set[str] = set() + current: str | None = identifier + while current is not None: + if current in seen: + raise ValueError("BLENDER_PARENT_CYCLE") + seen.add(current) + current = parents.get(current) + for identifier, spec in self._object_specs.items(): + parent_id = spec.get("parent") + if not parent_id: + continue + parent = self.objects.get(parent_id) + if parent is None: + raise ValueError(f"BLENDER_UNKNOWN_PARENT:{parent_id}") + child = self.objects[identifier] + child.parent = parent + + def _add_modifiers(self) -> None: + boolean_targets: set[str] = set() + for identifier, spec in self._object_specs.items(): + modifiers = spec.get("modifiers") or [] + obj = self.objects[identifier] + if modifiers and obj.type != "MESH": + raise ValueError(f"BLENDER_MODIFIER_TARGET_INVALID:{identifier}") + for index, modifier_spec in enumerate(modifiers, start=1): + kind = modifier_spec["type"] + modifier = obj.modifiers.new(f"zcbot_{kind}_{index:02d}", kind.upper()) + if kind == "bevel": + modifier.width = self.length(modifier_spec["width"]) + modifier.segments = int(modifier_spec.get("segments", 3)) + elif kind == "mirror": + axes = modifier_spec["axes"] + modifier.use_axis[0] = "x" in axes + modifier.use_axis[1] = "y" in axes + modifier.use_axis[2] = "z" in axes + elif kind == "array": + modifier.count = int(modifier_spec["count"]) + modifier.use_relative_offset = False + modifier.use_constant_offset = True + modifier.constant_offset_displace = self.vector(modifier_spec["offset"]) + elif kind == "solidify": + modifier.thickness = self.length(modifier_spec["thickness"]) + elif kind == "boolean": + target_id = modifier_spec["target"] + target = self.objects.get(target_id) + if target is None or target.type != "MESH" or target == obj: + raise ValueError(f"BLENDER_BOOLEAN_TARGET_INVALID:{target_id}") + modifier.operation = { + "union": "UNION", "difference": "DIFFERENCE", "intersect": "INTERSECT" + }[modifier_spec["operation"]] + modifier.object = target + boolean_targets.add(target_id) + else: + raise ValueError(f"BLENDER_MODIFIER_UNSUPPORTED:{kind}") + for target_id in boolean_targets: + self.objects[target_id].hide_render = True + + def _configure_world(self) -> None: + scene = bpy.context.scene + render = self.spec["render"] + 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 + if scene.world is None: + scene.world = bpy.data.worlds.new("SceneWorld") + background = render.get("background_color", "#20252B") + 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.35 + + def _create_light(self, spec: dict[str, Any]) -> None: + light_type = spec["type"].upper() + data = bpy.data.lights.new(spec["id"] + "Data", light_type) + data.energy = float(spec["energy"]) + data.color = rgba(spec.get("color", "#FFFFFF"))[:3] + if light_type == "AREA": + data.shape = "DISK" + data.size = self.length(spec.get("size", 5.0)) + if light_type == "SPOT": + data.spot_size = math.radians(float(spec.get("spot_size_deg", 45))) + obj = bpy.data.objects.new(spec["id"], data) + bpy.context.scene.collection.objects.link(obj) + obj.location = self.vector(spec["position"]) + if spec.get("target") is not None and light_type != "POINT": + look_at(obj, self.vector(spec["target"])) + + def _configure_lights(self) -> None: + lights = self.spec.get("lights") or [] + if lights: + for spec in lights: + self._create_light(spec) + return + self._create_light( + {"id": "DefaultSun", "type": "sun", "position": [-8, -10, 12], "target": [0, 0, 0], "energy": 3.0} + ) + self._create_light( + {"id": "DefaultArea", "type": "area", "position": [4, -6, 8], "target": [0, 0, 0], "energy": 1200, "size": 6} + ) + + def compile(self) -> None: + self.reset() + material_specs = self.spec.get("materials") or [] + material_ids = [item["id"] for item in material_specs] + if len(material_ids) != len(set(material_ids)): + raise ValueError("BLENDER_DUPLICATE_MATERIAL_ID") + for material_spec in material_specs: + self.create_material(material_spec) + object_ids = [item["id"] for item in self.spec["objects"]] + if len(object_ids) != len(set(object_ids)): + raise ValueError("BLENDER_DUPLICATE_OBJECT_ID") + for object_spec in self.spec["objects"]: + self._build_object(object_spec) + self._parent_objects() + self._add_modifiers() + self._configure_world() + self._configure_lights() + polygon_count = sum( + len(obj.data.polygons) for obj in bpy.context.scene.objects if obj.type == "MESH" + ) + if polygon_count > MAX_MESH_POLYGONS: + raise ValueError("BLENDER_MESH_COMPLEXITY_LIMIT") + + def configure_camera(self, view: 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) + data = bpy.data.cameras.new("SceneCameraData") + data.type = "PERSP" if view["projection"] == "perspective" else "ORTHO" + data.lens = float(view.get("lens_mm", 50)) + data.ortho_scale = self.length(view.get("ortho_scale", 10)) + data.clip_end = self.length(view.get("clip_end", 10000)) + camera = bpy.data.objects.new("SceneCamera", data) + scene.collection.objects.link(camera) + camera.location = self.vector(view["position"]) + look_at(camera, self.vector(view["target"])) + scene.camera = camera + + def manifest(self, views: list[dict[str, Any]]) -> dict[str, Any]: + meshes = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"] + return { + "recipe_version": self.spec["recipe_version"], + "title": self.spec["title"], + "units": self.spec["units"], + "rendered_views": [ + {"name": item["name"], "output_key": item["output_key"]} for item in views + ], + "objects": [ + { + "id": str(obj.get("zcbot_id", "")), + "name": obj.name, + "type": obj.type, + "vertices": len(obj.data.vertices) if obj.type == "MESH" else 0, + "polygons": len(obj.data.polygons) if obj.type == "MESH" else 0, + } + for obj in bpy.context.scene.objects + if obj.type not in {"CAMERA", "LIGHT"} + ], + "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), + } diff --git a/windows-node/adapters/blender.scene.author@v2/worker.py b/windows-node/adapters/blender.scene.author@v3/worker.py similarity index 98% rename from windows-node/adapters/blender.scene.author@v2/worker.py rename to windows-node/adapters/blender.scene.author@v3/worker.py index 6b9171b..00325b6 100644 --- a/windows-node/adapters/blender.scene.author@v2/worker.py +++ b/windows-node/adapters/blender.scene.author@v3/worker.py @@ -1,4 +1,4 @@ -"""Fixed launcher for the Blender scene-authoring adapter.""" +"""Fixed launcher for the managed Blender scene-recipe adapter.""" from __future__ import annotations @@ -12,7 +12,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any -ADAPTER_VERSION = "0.2.0" +ADAPTER_VERSION = "1.0.0" BLENDER_ENV = "ZCBOT_BLENDER_EXE" diff --git a/windows-node/package-blender-adapter.bat b/windows-node/package-blender-adapter.bat index 2d94727..528ce99 100644 --- a/windows-node/package-blender-adapter.bat +++ b/windows-node/package-blender-adapter.bat @@ -4,14 +4,16 @@ cd /d "%~dp0" set "OUTPUT_ROOT=%~f1" if "%~1"=="" set "OUTPUT_ROOT=%~dp0dist" -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" +set "SOURCE_DIR=%~dp0adapters\blender.scene.author@v3" +set "CONTRACT=%~dp0..\software-contracts\blender.scene.author.v3.json" +set "PACKAGE_DIR=!OUTPUT_ROOT!\blender.scene.author@v3" +set "ARCHIVE_PATH=!OUTPUT_ROOT!\blender.scene.author@v3-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%\scene_recipe.py" goto :failed +if not exist "%SOURCE_DIR%\rotary_kiln.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 @@ -24,11 +26,13 @@ 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%\scene_recipe.py" "!PACKAGE_DIR!\scene_recipe.py" >nul +copy /y "%SOURCE_DIR%\rotary_kiln.py" "!PACKAGE_DIR!\rotary_kiln.py" >nul copy /y "%SOURCE_DIR%\requirements.txt" "!PACKAGE_DIR!\requirements.txt" >nul -copy /y "%CONTRACT%" "!PACKAGE_DIR!\blender.scene.author.v2.json" >nul +copy /y "%CONTRACT%" "!PACKAGE_DIR!\blender.scene.author.v3.json" >nul if errorlevel 1 goto :failed -tar.exe -a -c -f "!ARCHIVE_PATH!" -C "!OUTPUT_ROOT!" "blender.scene.author@v2" +tar.exe -a -c -f "!ARCHIVE_PATH!" -C "!OUTPUT_ROOT!" "blender.scene.author@v3" if errorlevel 1 goto :failed echo [OK] Blender adapter package: !ARCHIVE_PATH! exit /b 0