b583b0d7a2
- Per-render-position focal_length_mm/sensor_width_mm (DB → pipeline → Blender)
- FOV-based camera distance with min clamp fix for wide-angle lenses
- Unmapped materials blocking dialog on "Dispatch Renders" with batch alias creation
- Material check endpoint (GET /orders/{id}/check-materials)
- Batch alias endpoint (POST /materials/batch-aliases)
- Quick-map "No alias" badges on Materials page
- Full product hard-delete with storage cleanup (MinIO + disk files + orphaned CadFile)
- Delete button on ProductDetail page with confirmation
- Clickable product names in Media Browser (links to product page)
- Single-line render dispatch/retry (POST /orders/{id}/lines/{id}/dispatch-render)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
120 lines
4.2 KiB
Python
120 lines
4.2 KiB
Python
"""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)
|