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