"""Camera and lighting helpers for Blender headless renders.""" from __future__ import annotations import math ELEVATION_DEG = 28.0 AZIMUTH_DEG = 40.0 LENS_MM = 50.0 SENSOR_WIDTH_MM = 36.0 FILL_FACTOR = 0.85 def setup_auto_camera(parts: list, width: int, height: int, lens_mm: float | None = None, sensor_width_mm: float | None = None): """Compute bounding sphere and place an isometric auto-camera. Returns (bbox_center, bsphere_radius) as a tuple so the caller can pass them to setup_auto_lights(). """ import bpy # type: ignore[import] from mathutils import Vector, Matrix # type: ignore[import] _lens = lens_mm if lens_mm is not None else LENS_MM _sensor = sensor_width_mm if sensor_width_mm is not None else SENSOR_WIDTH_MM all_corners = [] for part in parts: all_corners.extend(part.matrix_world @ Vector(c) for c in part.bound_box) bbox_min = Vector(( min(v.x for v in all_corners), min(v.y for v in all_corners), min(v.z for v in all_corners), )) bbox_max = Vector(( max(v.x for v in all_corners), max(v.y for v in all_corners), max(v.z for v in all_corners), )) bbox_center = (bbox_min + bbox_max) * 0.5 bbox_dims = bbox_max - bbox_min bsphere_radius = max(bbox_dims.length * 0.5, 0.001) print(f"[blender_render] bbox_dims={tuple(round(d,4) for d in bbox_dims)}, " f"bsphere_radius={bsphere_radius:.4f}, center={tuple(round(c,4) for c in bbox_center)}") elevation_rad = math.radians(ELEVATION_DEG) azimuth_rad = math.radians(AZIMUTH_DEG) cam_dir = Vector(( math.cos(elevation_rad) * math.cos(azimuth_rad), math.cos(elevation_rad) * math.sin(azimuth_rad), math.sin(elevation_rad), )).normalized() fov_h = math.atan(_sensor / (2.0 * _lens)) fov_v = math.atan(_sensor * (height / width) / (2.0 * _lens)) fov_used = min(fov_h, fov_v) dist = (bsphere_radius / math.tan(fov_used)) / FILL_FACTOR # Minimum distance: prevent camera from being inside the bounding sphere, # but scale with FOV so wide-angle lenses can still frame correctly. min_dist = bsphere_radius * 1.05 # just outside the sphere surface dist = max(dist, min_dist) print(f"[blender_render] camera dist={dist:.4f}, fov={math.degrees(fov_used):.2f}°, lens={_lens}mm") cam_location = bbox_center + cam_dir * dist bpy.ops.object.camera_add(location=cam_location) cam_obj = bpy.context.active_object cam_obj.data.lens = _lens bpy.context.scene.camera = cam_obj look_dir = (bbox_center - cam_location).normalized() up_world = Vector((0.0, 0.0, 1.0)) right = look_dir.cross(up_world) if right.length < 1e-6: right = Vector((1.0, 0.0, 0.0)) right.normalize() cam_up = right.cross(look_dir).normalized() rot_mat = Matrix(( ( right.x, right.y, right.z), ( cam_up.x, cam_up.y, cam_up.z), (-look_dir.x, -look_dir.y, -look_dir.z), )).transposed() cam_obj.rotation_euler = rot_mat.to_euler('XYZ') cam_obj.data.clip_start = max(dist * 0.001, 0.0001) cam_obj.data.clip_end = dist + bsphere_radius * 3.0 print(f"[blender_render] clip {cam_obj.data.clip_start:.6f} … {cam_obj.data.clip_end:.4f}") return bbox_center, bsphere_radius def setup_auto_lights(bbox_center, bsphere_radius: float) -> None: """Add a sun + area fill light positioned relative to the bounding sphere.""" import bpy # type: ignore[import] light_dist = bsphere_radius * 6.0 bpy.ops.object.light_add(type='SUN', location=( bbox_center.x + light_dist * 0.5, bbox_center.y - light_dist * 0.35, bbox_center.z + light_dist, )) sun = bpy.context.active_object sun.data.energy = 4.0 sun.rotation_euler = (math.radians(45), 0, math.radians(30)) bpy.ops.object.light_add(type='AREA', location=( bbox_center.x - light_dist * 0.4, bbox_center.y + light_dist * 0.4, bbox_center.z + light_dist * 0.7, )) fill = bpy.context.active_object fill.data.energy = max(800.0, bsphere_radius ** 2 * 2000.0) fill.data.size = max(4.0, bsphere_radius * 4.0)