feat(blender)!: add managed scene recipe v3

Replace the rotary-kiln-only contract with a bounded declarative scene compiler.

BREAKING CHANGE: blender.scene.author@v2 is replaced by blender.scene.author@v3; existing v2 workspaces cannot be revised through v3.
This commit is contained in:
caoqianming 2026-08-21 12:29:58 +08:00
parent 59c3fbd849
commit bd04c76cfe
25 changed files with 1617 additions and 1102 deletions

View File

@ -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不建设跨软件万能 DSLAPI/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并统一声明材质、变换、灯光、世界背景和 18 个相机视图;`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 物理目录只使用 `<data_root>/workspaces/<workspace_id>/`,不再按 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、串行回到同一节点节点离线时等待而不复制工程或静默改派。

6
RUN.md
View File

@ -1107,7 +1107,9 @@ Workspace 目录固定为 `<data_root>\workspaces\<workspace-id>\`,不使用 u
主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2 和 Blender 分别管理应用位置与隔离 runtime。每张软件卡都可单独执行“安装/更新运行环境”,只会读取安装包内固定 requirements并先在临时目录完成后再替换该软件的 `runtimes/<runtime_id>`;有对应活动任务时拒绝替换。安装要求本机存在 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/emptygeometry 支持 box、cylinder、cone、sphere、torus、plane、extrude、revolve、sweep 和 pipegenerator 当前提供可与其他对象组合的 `rotary_kiln`;对象可声明父级、集合、材质、变换及 boolean/bevel/mirror/array/solidify 修改器。`render.views` 提供 18 个透视或正交相机,每个视图绑定唯一的 `preview_01``preview_08`,必须包含 `preview_01`;首张预览固定生成,其余视图必须在 `outputs` 请求同名 PNGGLB 仍按需请求。任务同时生成 `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` 不会自动删除;确需保留时应在部署前从 `<data_root>\workspaces\<workspace-id>\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 R2revision 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。
注册配置写入 `<data_root>\node.json`Token 使用 DPAPI `LocalMachine` 加密ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。

View File

@ -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"
]
}
}
}
}
}
}
}
}
}

View File

@ -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"}}
}
}
}
}

View File

@ -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"}
]
}
}

View File

@ -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"}
]
}
}

View File

@ -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__":

View File

@ -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=[],

View File

@ -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))

View File

@ -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:

View File

@ -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())

View File

@ -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 脚本或独立 EXEmanifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 Origin、ANSYS 和 `blender.scene.author@v2` Worker但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录Origin OPJU 与 Blender `.blend` 工程保存在各自的 `workspaces/<workspace-id>/current`,只保留一份 `rollback`,完成时自动上传轻量预览,用户明确请求导出时才上传并发布所选正式产物。
当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力;每个 adapter 目录必须自带 manifest 指向的合同Node 不读取共享合同目录。Host 只按该合同的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXEmanifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 Origin、ANSYS 和 `blender.scene.author@v3` Worker但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录Origin OPJU 与 Blender `.blend` 工程保存在各自的 `workspaces/<workspace-id>/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` 目标状态生成参数化回转窑,支持长度、直径、壳体/耐火衬里厚度、斜度、28 档支撑,以及驱动、燃烧器、窑头尾罩、检修平台和窑内物料开关;一次任务可输出等轴测、侧视、窑头、窑尾、纵向剖切和横截面 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`

View File

@ -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) =>

View File

@ -35,8 +35,8 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="..\..\software-contracts\blender.scene.author.v2.json">
<Link>adapters\blender.scene.author@v2\blender.scene.author.v2.json</Link>
<Content Include="..\..\software-contracts\blender.scene.author.v3.json">
<Link>adapters\blender.scene.author@v3\blender.scene.author.v3.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>

View File

@ -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 正在生成三维场景"
}

View File

@ -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())

View File

@ -1 +0,0 @@
# The launcher uses only Python's standard library. bpy runs inside Blender.

View File

@ -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 正在编译受管三维场景"
}

View File

@ -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())

View File

@ -0,0 +1 @@
# The launcher uses only the standard library; bpy comes from Blender.

View File

@ -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),
)

View File

@ -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),
}

View File

@ -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"

View File

@ -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