zcbot/windows-node/adapters/blender.scene.author@v1/blender_worker.py

422 lines
16 KiB
Python

"""Blender-owned bpy worker for declarative scene authoring."""
from __future__ import annotations
import hashlib
import json
import math
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import bpy
from mathutils import Vector
ADAPTER_VERSION = "0.1.0"
def atomic_json(path: Path, value: Any) -> None:
temporary = path.with_name(path.name + ".tmp-" + os.urandom(8).hex())
try:
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
json.dump(value, handle, ensure_ascii=False, indent=2)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def terminal(
record: dict[str, Any],
status: str,
artifacts: list[dict[str, Any]],
error: dict[str, str],
) -> dict[str, Any]:
return {
"job_id": record.get("job_id", ""),
"lease_id": record.get("lease_id", ""),
"request_digest": record.get("request_digest", ""),
"status": status,
"error": error,
"artifact_manifest": artifacts,
"terminal_at": datetime.now(timezone.utc).isoformat(),
}
def rgba(value: str) -> tuple[float, float, float, float]:
return tuple(int(value[index:index + 2], 16) / 255 for index in (1, 3, 5)) + (1.0,)
def material(name: str, color: str, metallic: float, roughness: float) -> Any:
result = bpy.data.materials.new(name)
result.diffuse_color = rgba(color)
result.use_nodes = True
shader = result.node_tree.nodes.get("Principled BSDF")
if shader is not None:
shader.inputs["Base Color"].default_value = rgba(color)
shader.inputs["Metallic"].default_value = metallic
shader.inputs["Roughness"].default_value = roughness
return result
def assign(obj: Any, value: Any) -> None:
if getattr(obj.data, "materials", None) is not None:
obj.data.materials.append(value)
def cube(name: str, location: Vector, dimensions: tuple[float, float, float], mat: Any) -> Any:
bpy.ops.mesh.primitive_cube_add(location=location)
obj = bpy.context.object
obj.name = name
obj.dimensions = dimensions
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
assign(obj, mat)
return obj
def cylinder(
name: str,
location: Vector,
direction: Vector,
radius: float,
length: float,
mat: Any,
vertices: int = 64,
) -> Any:
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=length, location=location)
obj = bpy.context.object
obj.name = name
obj.rotation_mode = "QUATERNION"
obj.rotation_quaternion = direction.normalized().to_track_quat("Z", "Y")
assign(obj, mat)
return obj
def torus(name: str, location: Vector, direction: Vector, major: float, minor: float, mat: Any) -> Any:
bpy.ops.mesh.primitive_torus_add(
major_radius=major,
minor_radius=minor,
major_segments=64,
minor_segments=12,
location=location,
)
obj = bpy.context.object
obj.name = name
obj.rotation_mode = "QUATERNION"
obj.rotation_quaternion = direction.normalized().to_track_quat("Z", "Y")
assign(obj, mat)
return obj
def look_at(obj: Any, target: Vector) -> None:
obj.rotation_euler = (target - obj.location).to_track_quat("-Z", "Y").to_euler()
def clear_scene(base: Path | None) -> None:
if base is not None:
source = base / "scene.blend"
if not source.is_file():
raise FileNotFoundError("WORKSPACE_SCENE_MISSING")
bpy.ops.wm.open_mainfile(filepath=str(source), load_ui=False)
else:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for datablocks in (bpy.data.materials, bpy.data.cameras, bpy.data.lights):
for datablock in list(datablocks):
if datablock.users == 0:
datablocks.remove(datablock)
def build_rotary_kiln(spec: dict[str, Any]) -> dict[str, Any]:
kiln = spec["kiln"]
components = spec.get("components") or {}
appearance = spec.get("appearance") or {}
length = float(kiln["length_m"])
diameter = float(kiln["outer_diameter_m"])
radius = diameter / 2
slope = math.atan(float(kiln["slope_percent"]) / 100)
stations = int(kiln["support_stations"])
direction = Vector((math.cos(slope), 0, -math.sin(slope)))
low_height = radius + max(1.2, radius * 0.55)
center = Vector((0, 0, low_height + length * math.sin(slope) / 2))
def axis_at(distance: float) -> Vector:
return center + direction * distance
shell_mat = material("ShellMaterial", appearance.get("shell_color", "#707780"), 0.65, 0.3)
metal_mat = material("EquipmentMetal", appearance.get("metal_color", "#303840"), 0.75, 0.25)
lining_mat = material("RefractoryLining", appearance.get("lining_color", "#B56A3B"), 0.05, 0.85)
hot_mat = material("HotMaterial", appearance.get("material_color", "#E35D20"), 0.0, 0.55)
concrete_mat = material("Concrete", "#77736B", 0.0, 0.9)
shell = cylinder("KilnShell", center, direction, radius, length, shell_mat, 96)
bevel = shell.modifiers.new("ShellEdgeBevel", "BEVEL")
bevel.width = min(0.08, diameter * 0.015)
bevel.segments = 3
inner_radius = max(0.05, radius - float(kiln["shell_thickness_m"]) - float(kiln["lining_thickness_m"]))
for side, distance in (("Tail", -length / 2 - 0.015), ("Head", length / 2 + 0.015)):
cylinder(f"{side}LiningFace", axis_at(distance), direction, inner_radius, 0.04, lining_mat, 96)
if components.get("material_bed", True):
cylinder("KilnMaterial", center, direction, inner_radius * 0.72, length * 0.985, hot_mat, 64)
station_positions: list[float] = []
for index in range(stations):
distance = -length * 0.38 + index * (length * 0.76 / (stations - 1))
station_positions.append(distance)
point = axis_at(distance)
torus(f"Tyre_{index + 1:02d}", point, direction, radius + 0.12, max(0.1, diameter * 0.045), metal_mat)
roller_radius = max(0.22, diameter * 0.09)
roller_length = max(0.7, diameter * 0.32)
for side_index, y_sign in enumerate((-1, 1), start=1):
roller_point = point + Vector((0, y_sign * radius * 0.72, -radius * 0.78))
cylinder(
f"SupportRoller_{index + 1:02d}_{side_index}",
roller_point,
Vector((0, 1, 0)),
roller_radius,
roller_length,
metal_mat,
48,
)
foundation_top = max(0.3, point.z - radius * 0.78 - roller_radius + 0.05)
base_point = Vector((point.x, 0, foundation_top / 2))
cube(
f"Foundation_{index + 1:02d}",
base_point,
(diameter * 0.7, diameter * 2.0, foundation_top),
concrete_mat,
)
if components.get("drive_system", True):
drive_distance = station_positions[min(1, len(station_positions) - 1)] + length * 0.06
drive_point = axis_at(drive_distance)
torus("GirthGear", drive_point, direction, radius + 0.22, max(0.12, diameter * 0.06), metal_mat)
motor_center = drive_point + Vector((0, -radius * 1.35, -radius * 0.55))
cube("Gearbox", motor_center, (diameter * 0.45, diameter * 0.5, diameter * 0.42), metal_mat)
cube("DriveMotor", motor_center + Vector((-diameter * 0.48, 0, 0)), (diameter * 0.5, diameter * 0.32, diameter * 0.32), shell_mat)
if components.get("hoods", True):
tail = axis_at(-length / 2 - radius * 0.35)
head = axis_at(length / 2 + radius * 0.35)
cube("KilnTailHood", tail, (diameter * 0.75, diameter * 1.35, diameter * 1.5), metal_mat)
cube("KilnHeadHood", head, (diameter * 0.85, diameter * 1.55, diameter * 1.65), metal_mat)
if components.get("burner", True):
head = axis_at(length / 2)
burner_center = head + direction * radius * 1.0
cylinder("Burner", burner_center, direction, max(0.08, radius * 0.1), radius * 2.4, metal_mat, 32)
if components.get("inspection_platform", True):
head = axis_at(length / 2 - radius)
platform = head + Vector((0, -radius * 1.2, -radius * 0.15))
cube("InspectionPlatform", platform, (diameter * 1.5, diameter * 0.85, 0.12), metal_mat)
for offset_x in (-diameter * 0.65, diameter * 0.65):
cube("PlatformLeg", platform + Vector((offset_x, 0, -radius)), (0.12, 0.12, radius * 2), metal_mat)
cube(
"Ground",
Vector((0, 0, -0.1)),
(length * 1.25, max(length * 0.38, diameter * 5), 0.2),
concrete_mat,
)
return {
"length_m": length,
"outer_diameter_m": diameter,
"slope_percent": float(kiln["slope_percent"]),
"support_stations": stations,
"rotational_speed_rpm": float(kiln.get("rotational_speed_rpm", 3.0)),
"center": list(center),
}
def configure_scene(spec: dict[str, Any], dimensions: dict[str, Any]) -> None:
scene = bpy.context.scene
scene.unit_settings.system = "METRIC"
scene.unit_settings.length_unit = "METERS"
scene.unit_settings.scale_length = 1.0
render = spec.get("render") or {}
scene.render.resolution_x = int(render.get("width", 1280))
scene.render.resolution_y = int(render.get("height", 720))
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.film_transparent = bool(render.get("transparent_background", False))
for engine in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE"):
try:
scene.render.engine = engine
break
except TypeError:
continue
background = (spec.get("appearance") or {}).get("background_color", "#20252B")
if scene.world is None:
scene.world = bpy.data.worlds.new("SceneWorld")
scene.world.use_nodes = True
scene.world.color = rgba(background)[:3]
node = scene.world.node_tree.nodes.get("Background")
if node is not None:
node.inputs["Color"].default_value = rgba(background)
node.inputs["Strength"].default_value = 0.28
length = dimensions["length_m"]
diameter = dimensions["outer_diameter_m"]
target = Vector(dimensions["center"])
view = render.get("view", "isometric")
offsets = {
"isometric": Vector((length * 0.62, -length * 0.68, length * 0.34)),
"side": Vector((0, -length * 0.82, length * 0.12)),
"kiln_head": Vector((length * 0.68, -length * 0.18, length * 0.18)),
"kiln_tail": Vector((-length * 0.68, -length * 0.18, length * 0.18)),
}
bpy.ops.object.camera_add(location=target + offsets[view])
camera = bpy.context.object
camera.name = "SceneCamera"
camera.data.type = "ORTHO"
aspect = scene.render.resolution_x / scene.render.resolution_y
camera.data.ortho_scale = max(diameter * 5.5, length / aspect * 1.45)
camera.data.clip_end = max(1000, length * 10)
look_at(camera, target)
scene.camera = camera
bpy.ops.object.light_add(type="SUN", location=target + Vector((-length, -length, length)))
sun = bpy.context.object
sun.name = "KeySun"
sun.data.energy = 2.2
sun.rotation_euler = (math.radians(28), math.radians(-22), math.radians(-35))
bpy.ops.object.light_add(type="AREA", location=target + Vector((0, -diameter * 5, diameter * 6)))
area = bpy.context.object
area.name = "FillArea"
area.data.energy = max(1200, length * 80)
area.data.shape = "DISK"
area.data.size = max(10, length * 0.45)
look_at(area, target)
def artifact(path: Path, artifact_id: str, media_type: str) -> dict[str, Any]:
if not path.is_file() or path.stat().st_size == 0:
raise RuntimeError(f"OUTPUT_EXPORT_EMPTY:{path.name}")
if path.suffix.lower() == ".png" and path.read_bytes()[:8] != b"\x89PNG\r\n\x1a\n":
raise RuntimeError("PNG_EXPORT_INVALID")
return {
"artifact_id": artifact_id,
"filename": path.name,
"media_type": media_type,
"size_bytes": path.stat().st_size,
"sha256": sha256(path),
}
def execute(job_dir: Path, record: dict[str, Any]) -> list[dict[str, Any]]:
request = record["request"]
spec = request["operation"]["scene"]
if spec["type"] != "rotary_kiln" or request["inputs"]:
raise ValueError("BLENDER_REQUEST_SEMANTICS_INVALID")
workspace_path = job_dir / "workspace.json"
workspace = json.loads(workspace_path.read_text(encoding="utf-8"))
base_value = workspace.get("local_base_path")
base = Path(base_value).resolve(strict=True) if base_value else None
clear_scene(base)
dimensions = build_rotary_kiln(spec)
configure_scene(spec, dimensions)
output = job_dir / "output"
output.mkdir(exist_ok=True)
project = output / "scene.blend"
preview = output / "preview.png"
manifest = output / "scene-manifest.json"
provenance = output / "provenance.json"
bpy.ops.wm.save_as_mainfile(filepath=str(project), check_existing=False)
bpy.context.scene.render.filepath = str(preview)
bpy.ops.render.render(write_still=True)
meshes = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
atomic_json(
manifest,
{
"title": spec["title"],
"scene_type": "rotary_kiln",
"dimensions": dimensions,
"objects": [
{
"name": obj.name,
"type": obj.type,
"vertices": len(obj.data.vertices),
"polygons": len(obj.data.polygons),
}
for obj in meshes
],
"object_count": len(bpy.context.scene.objects),
"mesh_count": len(meshes),
"vertex_count": sum(len(obj.data.vertices) for obj in meshes),
"polygon_count": sum(len(obj.data.polygons) for obj in meshes),
},
)
atomic_json(
provenance,
{
"adapter_version": ADAPTER_VERSION,
"blender_version": bpy.app.version_string,
"request_digest": record["request_digest"],
"workspace_mode": workspace.get("mode"),
"generator": "rotary_kiln@v1",
},
)
artifacts = [
artifact(project, "project", "application/x-blender"),
artifact(preview, "preview", "image/png"),
artifact(manifest, "scene_manifest", "application/json"),
]
if any(item.get("key") == "scene_glb" for item in request["outputs"]):
glb = output / "scene.glb"
bpy.ops.export_scene.gltf(filepath=str(glb), export_format="GLB", export_apply=True)
artifacts.append(artifact(glb, "scene_glb", "model/gltf-binary"))
artifacts.append(artifact(provenance, "provenance", "application/json"))
return artifacts
def job_directory() -> Path:
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(arguments) != 1:
raise ValueError("BLENDER_JOB_DIRECTORY_MISSING")
return Path(arguments[0]).resolve(strict=True)
def main() -> int:
job_dir = job_directory()
record: dict[str, Any] = {}
try:
record = json.loads((job_dir / "request" / "request.json").read_text(encoding="utf-8"))
artifacts = execute(job_dir, record)
atomic_json(job_dir / "artifacts.json", artifacts)
atomic_json(job_dir / "terminal.json", terminal(record, "succeeded", artifacts, {}))
print("[OK] Blender rotary kiln scene completed.")
return 0
except Exception as exc: # terminal.json must capture Blender-side failures
atomic_json(
job_dir / "terminal.json",
terminal(
record,
"failed",
[],
{"code": type(exc).__name__, "detail": str(exc)[:500]},
),
)
print(f"[ERR] {type(exc).__name__}: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())