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