536 lines
24 KiB
Python
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),
|
|
}
|