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:
parent
59c3fbd849
commit
bd04c76cfe
|
|
@ -476,7 +476,9 @@ Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/software-nodes/c
|
|||
|
||||
Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠标命令。`origin.plot@v2` 以新增 `plot.type=recipe + recipe_version=1` 复用已验证的 `layout/panels/series/axis/legend` 组合结构;旧 `multi_panel` 与 Recipe 进入同一个受控执行器,单图和特殊统计图继续保留兼容入口。模型可以组合契约允许的原子图形能力,但不能提供 Python、LabTalk、解释器、模板名或文件路径。其他软件可采用同一通用 Job 外壳和各自的声明式 Recipe,不建设跨软件万能 DSL;API/SDK executor 优先,未来的 UI Automation 或 Computer Use 仅作为 adapter 内部执行后端,不改变云端 Recipe。
|
||||
|
||||
Blender 入口为 `blender.scene.author@v2`,以设备目标状态而非 Python、逐步按键或任意节点脚本表达三维创作;不再保留 v1 调度能力,目标执行版本为 Blender 5.2 LTS。adapter 0.2.0 的 `rotary_kiln` feature 接受长度、直径、壳体/衬里厚度、斜度、支撑档数及驱动、燃烧器、窑头尾罩、平台、物料等受控参数,由固定 `bpy` Worker 一次建模,再按声明的视图连续输出等轴测、侧视、窑头、窑尾、纵向剖切和横截面 PNG。剖切使用仅在渲染态启用的半壳体与衬里几何,保存的 `.blend` 始终恢复完整模型;固定等轴测预览之外的视图和 GLB 由请求显式选择。受管 Python 只负责发现并以 `--background --factory-startup --disable-autoexec --python-exit-code 1` 拉起已安装的 Blender,实际 `bpy` 在 Blender 自带 Python 内执行;请求不能提供脚本、命令行、本机路径、URL 或插件。`.blend` 作为 Workspace state,续作先验证并打开上一版工程,再以完整目标状态生成新版本。
|
||||
Blender 入口为 `blender.scene.author@v3`,唯一场景类型是 `recipe`;它是 Blender 专属的声明式静态场景目标状态,不是跨软件万能 DSL,也不是 Python、逐步按键或任意节点脚本。Recipe v1 组合带稳定 ID、父子层级和集合归属的 geometry/generator/text/empty 对象:geometry 覆盖 box/cylinder/cone/sphere/torus/plane、二维轮廓拉伸、轴向旋转、路径扫掠和有壁厚直管,受控 modifier 覆盖 boolean/bevel/mirror/array/solidify,并统一声明材质、变换、灯光、世界背景和 1–8 个相机视图;`rotary_kiln` 只是 generator 组件,可与其他对象处于同一场景,不再占据顶层 capability。adapter 1.0.0 把合同校验后的 Recipe 编译为固定 `bpy` 调用,限制请求 256 KiB、200 个声明对象、展开后 1000 个 Blender 对象、100 万 mesh polygon、材质/灯光/视图和修改器数量;Worker 还机械校验 ID 唯一性、引用、父子关系、布尔目标、管壁和请求预览的一致性。输出固定包含 `.blend`、首张 PNG、场景清单和溯源,可再选择最多七张 PNG 与 GLB;预览用 `preview_01`–`preview_08` 稳定槽位,视图业务名称记录在 manifest。
|
||||
|
||||
Scene Recipe 每次按完整目标状态从 factory-empty 场景确定性重建,不把上一版 `.blend` 中的隐式手工状态当作事实源;Workspace 继续保存当前与 rollback 两份工程供结果留存和失败恢复,完整 Recipe 保存在 Job 请求中作为续作事实源。为避免旧的回转窑专用状态与新语义混用,本次按用户明确授权直接移除 v2 合同与调度,旧 v2 Workspace 文件不删除但不能作为 v3 source。目标执行版本仍为 Blender 5.2 LTS;受管 Python 只负责发现并以 `--background --factory-startup --disable-autoexec --python-exit-code 1` 拉起已安装的 Blender,实际 `bpy` 在 Blender 自带 Python 内执行,请求不能提供脚本、命令行、本机路径、URL、插件、driver 或任意 Geometry Nodes。
|
||||
|
||||
专业软件的连续加工以独立 `software_workspaces` 为稳定边界,Job 只记录一次操作。首个 Job 创建独立 `workspace_id`,两者不共用 ID;服务端保存 user 归属、capability、`home_node_id`、`head_job_id`、容量与留存元数据,Node 物理目录只使用 `<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
6
RUN.md
|
|
@ -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/empty:geometry 支持 box、cylinder、cone、sphere、torus、plane、extrude、revolve、sweep 和 pipe,generator 当前提供可与其他对象组合的 `rotary_kiln`;对象可声明父级、集合、材质、变换及 boolean/bevel/mirror/array/solidify 修改器。`render.views` 提供 1–8 个透视或正交相机,每个视图绑定唯一的 `preview_01`–`preview_08`,必须包含 `preview_01`;首张预览固定生成,其余视图必须在 `outputs` 请求同名 PNG,GLB 仍按需请求。任务同时生成 `scene.blend`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb`。
|
||||
|
||||
Recipe 是完整目标状态,每次从空场景重建;修改已有 v3 结果时,从 `software_job_status` 的 `editable_request` 形成完整下一版 `operation/outputs` 后调用 `software_job_revise`。v2 回转窑合同已破坏性移除,旧 Workspace 不能作为 v3 source,节点磁盘上的旧 `.blend` 不会自动删除;确需保留时应在部署前从 `<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 R2(revision 242)。adapter 0.3.0 使用 PyMechanical 0.11.3:前者导入几何并输出面清单、单位、包围盒、Named Selection 和预览,后者可按已有 Named Selection、全局轴极值面或平面法向/偏置/容差建立支撑与载荷选区,再执行真实静力求解。v2 直接替换旧 v1,旧请求不会在新版节点调度。Node 会从 `adapters/` 自动发现并在每次心跳上报全部能力,现有节点不需要清身份或重新注册,但必须整体替换发布目录并重启。默认 probe 仍返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`。
|
||||
|
||||
|
|
@ -1152,7 +1154,7 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动
|
|||
|
||||
只更新 Origin adapter 时,不需要编译、重装或替换 Node EXE:运行 `windows-node\package-origin-adapter.bat` 可直接把 manifest、Worker、依赖清单和契约打成 `dist\origin.plot@v2-adapter.zip`。在 Node 机器上先从托盘退出 zcbot Windows Node,备份并整体替换 EXE 同级的 `adapters\origin.plot@v2\`,再启动 Node。不要在任务执行中覆盖目录。若 `requirements.txt` 发生变化,在对应软件卡中“安装/更新运行环境”;仅 `adapter.json`、契约或 `worker.py` 变化时无需更新 runtime。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。
|
||||
|
||||
Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v2-adapter.zip`,替换规则与 Origin 相同。升级时退出托盘 Node,删除旧 `adapters\blender.scene.author@v1\` 并整体放入 v2 目录后重启;Blender 的 `requirements.txt` 当前不安装第三方包,`bpy` 不安装到轻量启动 runtime。
|
||||
Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v3-adapter.zip`,替换规则与 Origin 相同。升级时先从托盘退出 Node,删除程序目录中的 `adapters\blender.scene.author@v1\`、`adapters\blender.scene.author@v2\`,整体放入 v3 目录后重启;数据根目录中的旧 Workspace 不在清理范围。Blender 的 `requirements.txt` 当前不安装第三方包,`bpy` 不安装到轻量启动 runtime。
|
||||
|
||||
注册配置写入 `<data_root>\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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=[],
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
内网 MVP 的 Windows 执行节点,目标运行环境为 Windows 11 Enterprise + .NET 10 SDK 10.0.303。仓库根目录 `global.json` 固定 SDK patch;客户端只使用 .NET Windows Desktop Framework,不依赖第三方 NuGet 包。
|
||||
|
||||
当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力;每个 adapter 目录必须自带 manifest 指向的合同,Node 不读取共享合同目录。Host 只按该合同的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXE,manifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 Origin、ANSYS 和 `blender.scene.author@v2` Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;Origin OPJU 与 Blender `.blend` 工程保存在各自的 `workspaces/<workspace-id>/current`,只保留一份 `rollback`,完成时自动上传轻量预览,用户明确请求导出时才上传并发布所选正式产物。
|
||||
当前实现托盘状态角标、配置与本机任务窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。Node Host 以语言无关的 job 目录协议负责持久化、下载、恢复、取消和上传,并从 EXE 同级 `adapters/*/adapter.json` 发现能力;每个 adapter 目录必须自带 manifest 指向的合同,Node 不读取共享合同目录。Host 只按该合同的 JSON Schema 通用校验请求,软件探测、语义校验和执行都由 Worker 完成。Worker 可为受管 Python 脚本或独立 EXE,manifest 独立声明每种软件的任务超时及可选 Workspace 状态。当前安装包带 Origin、ANSYS 和 `blender.scene.author@v3` Worker,但 Python 不是 Node 通用协议的一部分。本机任务列表只读取已派发到该 Node 的持久化目录;Origin OPJU 与 Blender `.blend` 工程保存在各自的 `workspaces/<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` 目标状态生成参数化回转窑,支持长度、直径、壳体/耐火衬里厚度、斜度、2–8 档支撑,以及驱动、燃烧器、窑头尾罩、检修平台和窑内物料开关;一次任务可输出等轴测、侧视、窑头、窑尾、纵向剖切和横截面 PNG,并生成可续作 `scene.blend`、场景清单、溯源和可选 GLB。剖切只影响渲染态,工程保存完整模型。启动命令固定包含 background、factory startup、禁止自动脚本和 Python 异常退出码,渲染优先使用 5.2 的 Eevee 标识;请求不接受 Python、命令行、路径、URL 或插件。只更新该 adapter 时运行 `package-blender-adapter.bat`,退出托盘 Node 后移除旧 v1 目录、放入 `adapters\blender.scene.author@v2\` 并重启;若节点没有 `runtimes\blender`,在 Blender 软件卡中单独安装即可。
|
||||
Blender adapter 1.0.0 面向 Blender 5.2 LTS,将受管 Scene Recipe 编译为静态三维场景。Recipe 可组合基础体、拉伸/旋转/扫掠、带壁厚直管、文字、父子层级、集合、受控修改器、材质和灯光,`rotary_kiln` 作为普通 generator 与其他对象共同构图;一次任务可按声明相机输出最多八张 PNG,并生成 `scene.blend`、场景清单、溯源和可选 GLB。启动命令固定包含 background、factory startup、禁止自动脚本和 Python 异常退出码;请求不接受 Python、命令行、路径、URL、插件或任意节点脚本。只更新该 adapter 时运行 `package-blender-adapter.bat`,退出托盘 Node 后移除旧 v1/v2 目录、放入 `adapters\blender.scene.author@v3\` 并重启;旧 Workspace 文件保留但不能由 v3 续作。若节点没有 `runtimes\blender`,在 Blender 软件卡中单独安装即可。
|
||||
|
||||
ANSYS adapter 0.2.0 已实现 Mechanical 2024 R2 / PyMechanical 0.11.3 的真实线性静力流程,可从 STEP/Parasolid/IGES 导入 Named Selection,应用固定材料/网格/支撑/力或压力,并导出 MECHDAT、汇总、结果表、结果图、solver log 和溯源。契约不接收 Python、APDL、Journal、工程文件或本机路径。Node 主窗口提供“复制诊断信息”和“ANSYS Mechanical 真机验收”卡片;验收使用内置 NIST FTC-11 STEP 并自动建立两个端面 Named Selection,用户只需选择报告目录。该入口不受执行门影响,但只有固定的取消与连续 20 次求解全部通过后,界面才允许写入机器级执行门。完成专用节点的许可证、取消、残留进程和许可证释放验收前,能力仍保持不可调度,具体步骤见根目录 `RUN.md`。
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 正在生成三维场景"
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
@ -1 +0,0 @@
|
|||
# The launcher uses only Python's standard library. bpy runs inside Blender.
|
||||
|
|
@ -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 正在编译受管三维场景"
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
@ -0,0 +1 @@
|
|||
# The launcher uses only the standard library; bpy comes from Blender.
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue