zcbot/windows-node/adapters/blender.scene.author@v3/scene_recipe.py

536 lines
24 KiB
Python

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