679 lines
24 KiB
Python
679 lines
24 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.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())
|