feat: per-position camera settings, material alias dialog, product delete, media browser links
- 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>
This commit is contained in:
@@ -63,6 +63,16 @@ def parse_args() -> SimpleNamespace:
|
||||
_usd_idx = sys.argv.index("--usd-path")
|
||||
usd_path = sys.argv[_usd_idx + 1] if _usd_idx + 1 < len(sys.argv) else ""
|
||||
|
||||
focal_length_mm = None
|
||||
if "--focal-length" in sys.argv:
|
||||
_fl_idx = sys.argv.index("--focal-length")
|
||||
focal_length_mm = float(sys.argv[_fl_idx + 1]) if _fl_idx + 1 < len(sys.argv) else None
|
||||
|
||||
sensor_width_mm_override = None
|
||||
if "--sensor-width" in sys.argv:
|
||||
_sw_idx = sys.argv.index("--sensor-width")
|
||||
sensor_width_mm_override = float(sys.argv[_sw_idx + 1]) if _sw_idx + 1 < len(sys.argv) else None
|
||||
|
||||
if template_path and not os.path.isfile(template_path):
|
||||
print(f"[blender_render] ERROR: template not found: {template_path}")
|
||||
sys.exit(1)
|
||||
@@ -96,4 +106,6 @@ def parse_args() -> SimpleNamespace:
|
||||
mesh_attributes=mesh_attributes,
|
||||
usd_path=usd_path,
|
||||
use_template=bool(template_path),
|
||||
focal_length_mm=focal_length_mm,
|
||||
sensor_width_mm=sensor_width_mm_override,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,9 @@ SENSOR_WIDTH_MM = 36.0
|
||||
FILL_FACTOR = 0.85
|
||||
|
||||
|
||||
def setup_auto_camera(parts: list, width: int, height: int):
|
||||
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
|
||||
@@ -19,6 +21,9 @@ def setup_auto_camera(parts: list, width: int, height: int):
|
||||
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)
|
||||
@@ -50,18 +55,21 @@ def setup_auto_camera(parts: list, width: int, height: int):
|
||||
math.sin(elevation_rad),
|
||||
)).normalized()
|
||||
|
||||
fov_h = math.atan(SENSOR_WIDTH_MM / (2.0 * LENS_MM))
|
||||
fov_v = math.atan(SENSOR_WIDTH_MM * (height / width) / (2.0 * LENS_MM))
|
||||
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
|
||||
dist = max(dist, bsphere_radius * 1.5)
|
||||
print(f"[blender_render] camera dist={dist:.4f}, fov={math.degrees(fov_used):.2f}°")
|
||||
# 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_MM
|
||||
cam_obj.data.lens = _lens
|
||||
bpy.context.scene.camera = cam_obj
|
||||
|
||||
look_dir = (bbox_center - cam_location).normalized()
|
||||
|
||||
@@ -8,12 +8,45 @@ import time as _time
|
||||
FAILED_MATERIAL_NAME = "SCHAEFFLER_059999_FailedMaterial"
|
||||
|
||||
|
||||
def _find_material_with_nodes(base_name: str):
|
||||
"""Find a material by name that actually has shader nodes.
|
||||
|
||||
Blender's USD importer creates empty stub materials (use_nodes=True,
|
||||
node_tree has 0 nodes) from USD material bindings. When we later
|
||||
append the real material from a .blend library, Blender renames it
|
||||
with a .001/.002 suffix to avoid the name collision.
|
||||
|
||||
This helper searches bpy.data.materials for the version that has
|
||||
actual shader nodes, preferring exact name match, then .NNN suffixes.
|
||||
"""
|
||||
import bpy # type: ignore[import]
|
||||
|
||||
# Exact name first
|
||||
exact = bpy.data.materials.get(base_name)
|
||||
if exact and exact.node_tree and len(exact.node_tree.nodes) > 0:
|
||||
return exact
|
||||
|
||||
# Search for .NNN suffixed versions
|
||||
for mat in bpy.data.materials:
|
||||
if not mat.name.startswith(base_name):
|
||||
continue
|
||||
suffix = mat.name[len(base_name):]
|
||||
if suffix == "" or _re.match(r'^\.\d{3}$', suffix):
|
||||
if mat.node_tree and len(mat.node_tree.nodes) > 0:
|
||||
return mat
|
||||
return None
|
||||
|
||||
|
||||
def _batch_append_materials(mat_lib_path: str, names: set[str]) -> dict:
|
||||
"""Append multiple materials from a .blend file in a single open.
|
||||
|
||||
Uses bpy.data.libraries.load() to open the .blend once instead of
|
||||
N separate bpy.ops.wm.append() calls (each reopens the file).
|
||||
Falls back to individual append for any materials that fail to load.
|
||||
|
||||
Handles empty material stubs left by Blender's USD importer: when a
|
||||
stub exists with the target name, the library material gets renamed
|
||||
with a .NNN suffix. We find it via _find_material_with_nodes().
|
||||
"""
|
||||
import bpy # type: ignore[import]
|
||||
|
||||
@@ -28,12 +61,16 @@ def _batch_append_materials(mat_lib_path: str, names: set[str]) -> dict:
|
||||
to_load = [n for n in names if n in available]
|
||||
not_found = names - available
|
||||
data_to.materials = to_load
|
||||
# After the context manager closes, materials are loaded into bpy.data
|
||||
# After the context manager closes, materials are loaded into bpy.data.
|
||||
# If a USD stub occupied the name, the real material gets a .NNN suffix.
|
||||
for mat_name in to_load:
|
||||
mat = bpy.data.materials.get(mat_name)
|
||||
mat = _find_material_with_nodes(mat_name)
|
||||
if mat:
|
||||
result[mat_name] = mat
|
||||
print(f"[blender_render] batch-appended material: {mat_name}")
|
||||
if mat.name != mat_name:
|
||||
print(f"[blender_render] batch-appended material: {mat_name} (as '{mat.name}', stub collision)")
|
||||
else:
|
||||
print(f"[blender_render] batch-appended material: {mat_name}")
|
||||
else:
|
||||
print(f"[blender_render] WARNING: material '{mat_name}' not found after batch append")
|
||||
if not_found:
|
||||
@@ -51,7 +88,7 @@ def _batch_append_materials(mat_lib_path: str, names: set[str]) -> dict:
|
||||
filename=mat_name,
|
||||
link=False,
|
||||
)
|
||||
mat = bpy.data.materials.get(mat_name)
|
||||
mat = _find_material_with_nodes(mat_name)
|
||||
if mat:
|
||||
result[mat_name] = mat
|
||||
except Exception:
|
||||
@@ -141,11 +178,15 @@ def apply_material_library_direct(
|
||||
# Batch-append materials from library (single file open)
|
||||
appended: dict = {}
|
||||
_t_append = _time.monotonic()
|
||||
# Check already-loaded materials first
|
||||
# Check already-loaded materials first — but skip empty stubs created by
|
||||
# Blender's USD importer (use_nodes=True but node_tree has 0 nodes).
|
||||
# Those stubs must be loaded from the library via _batch_append_materials
|
||||
# which uses _find_material_with_nodes() to resolve stub collisions.
|
||||
still_needed = set()
|
||||
for mat_name in needed:
|
||||
if mat_name in bpy.data.materials:
|
||||
appended[mat_name] = bpy.data.materials[mat_name]
|
||||
existing = _find_material_with_nodes(mat_name)
|
||||
if existing:
|
||||
appended[mat_name] = existing
|
||||
else:
|
||||
still_needed.add(mat_name)
|
||||
# Load remaining from .blend in one pass
|
||||
|
||||
@@ -111,7 +111,9 @@ def _setup_mode_b(args, lap_fn: Callable[[str], None]) -> None:
|
||||
print(f"[blender_render] template mode: {len(parts)} parts imported into collection '{args.target_collection}'")
|
||||
|
||||
if needs_auto_camera:
|
||||
setup_auto_camera(parts, args.width, args.height)
|
||||
setup_auto_camera(parts, args.width, args.height,
|
||||
lens_mm=args.focal_length_mm,
|
||||
sensor_width_mm=args.sensor_width_mm)
|
||||
|
||||
|
||||
def _setup_mode_a(args) -> None:
|
||||
@@ -156,7 +158,9 @@ def _setup_mode_a(args) -> None:
|
||||
build_mat_map_lower(args.material_map), args.part_names_ordered,
|
||||
)
|
||||
|
||||
bbox_center, bsphere_radius = setup_auto_camera(parts, args.width, args.height)
|
||||
bbox_center, bsphere_radius = setup_auto_camera(parts, args.width, args.height,
|
||||
lens_mm=args.focal_length_mm,
|
||||
sensor_width_mm=args.sensor_width_mm)
|
||||
setup_auto_lights(bbox_center, bsphere_radius)
|
||||
world = bpy.data.worlds.new("World")
|
||||
bpy.context.scene.world = world
|
||||
|
||||
@@ -497,56 +497,6 @@ def _collect_part_key_map(shape_tool, free_labels) -> dict:
|
||||
return part_key_map
|
||||
|
||||
|
||||
def _apply_glb_mm_to_m_scale(glb_path: Path) -> None:
|
||||
"""Wrap all GLB scene root nodes under a new root node with scale 0.001.
|
||||
|
||||
RWGltf_CafWriter exports geometry in mm (original STEP units).
|
||||
BRepBuilderAPI_Transform destroys Poly_Triangulation, so we cannot scale
|
||||
the B-Rep before export. Instead we add a root transform node to the GLB
|
||||
that scales mm → m. glTF spec uses metres; Three.js and Blender honour
|
||||
node scale transforms.
|
||||
|
||||
The GLB binary is re-serialized in-place.
|
||||
"""
|
||||
import struct as _struct
|
||||
|
||||
data = glb_path.read_bytes()
|
||||
json_len = _struct.unpack_from("<I", data, 12)[0]
|
||||
json_type = _struct.unpack_from("<I", data, 16)[0]
|
||||
if json_type != 0x4E4F534A: # "JSON"
|
||||
return
|
||||
|
||||
j = json.loads(data[20: 20 + json_len])
|
||||
|
||||
if "scenes" not in j or not j["scenes"]:
|
||||
return
|
||||
|
||||
scene = j["scenes"][0]
|
||||
old_roots = scene.get("nodes", [])
|
||||
if not old_roots:
|
||||
return
|
||||
|
||||
# Create a new root node with mm→m scale
|
||||
nodes = j.setdefault("nodes", [])
|
||||
new_root_idx = len(nodes)
|
||||
nodes.append({
|
||||
"name": "__mm_to_m_root__",
|
||||
"scale": [0.001, 0.001, 0.001],
|
||||
"children": old_roots,
|
||||
})
|
||||
scene["nodes"] = [new_root_idx]
|
||||
|
||||
new_json = json.dumps(j, separators=(",", ":"))
|
||||
pad = (4 - len(new_json) % 4) % 4
|
||||
new_json_bytes = new_json.encode() + b" " * pad
|
||||
|
||||
rest = data[20 + json_len:] # BIN chunk and anything after
|
||||
new_chunk = _struct.pack("<II", len(new_json_bytes), 0x4E4F534A) + new_json_bytes
|
||||
new_total = 12 + len(new_chunk) + len(rest)
|
||||
new_header = _struct.pack("<III", 0x46546C67, 2, new_total)
|
||||
glb_path.write_bytes(new_header + new_chunk + rest)
|
||||
|
||||
|
||||
def _inject_glb_extras(glb_path: Path, extras: dict, part_key_map: dict | None = None) -> None:
|
||||
"""Patch a GLB binary to add/update scenes[0].extras JSON field.
|
||||
|
||||
@@ -817,8 +767,10 @@ def main() -> None:
|
||||
except Exception as _exc:
|
||||
print(f"WARNING: GLB extras injection failed (non-fatal): {_exc}", file=sys.stderr)
|
||||
|
||||
# NOTE: RWGltf_CafWriter already converts mm → m and Z-up → Y-up internally.
|
||||
# No additional scaling or coordinate transform is needed.
|
||||
# NOTE: RWGltf_CafWriter reads unit metadata from the XDE document (set by
|
||||
# STEPCAFControl_Reader from the STEP file's SI_UNIT declarations) and converts
|
||||
# mm → m automatically. It also handles Z-up → Y-up coordinate transform.
|
||||
# No additional scaling or BRepBuilderAPI_Transform is needed.
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -373,6 +373,18 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Named argument: --focal-length <mm>
|
||||
_focal_length = None
|
||||
if "--focal-length" in argv:
|
||||
_idx = argv.index("--focal-length")
|
||||
_focal_length = float(argv[_idx + 1]) if _idx + 1 < len(argv) else None
|
||||
|
||||
# Named argument: --sensor-width <mm>
|
||||
_sensor_width = None
|
||||
if "--sensor-width" in argv:
|
||||
_idx = argv.index("--sensor-width")
|
||||
_sensor_width = float(argv[_idx + 1]) if _idx + 1 < len(argv) else None
|
||||
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
try:
|
||||
@@ -616,8 +628,8 @@ def main():
|
||||
# ── Camera (isometric-style, matches blender_render.py) ──────────────
|
||||
ELEVATION_DEG = 28.0
|
||||
AZIMUTH_DEG = 40.0
|
||||
LENS_MM = 50.0
|
||||
SENSOR_WIDTH_MM = 36.0
|
||||
LENS_MM = _focal_length if _focal_length is not None else 50.0
|
||||
SENSOR_WIDTH_MM = _sensor_width if _sensor_width is not None else 36.0
|
||||
FILL_FACTOR = 0.85
|
||||
|
||||
elevation_rad = math.radians(ELEVATION_DEG)
|
||||
@@ -634,7 +646,7 @@ def main():
|
||||
fov_used = min(fov_h, fov_v)
|
||||
|
||||
dist = (bsphere_radius / math.tan(fov_used)) / FILL_FACTOR
|
||||
dist = max(dist, bsphere_radius * 1.5)
|
||||
dist = max(dist, bsphere_radius * 1.05)
|
||||
|
||||
cam_location = bbox_center + cam_dir * dist
|
||||
bpy.ops.object.camera_add(location=cam_location)
|
||||
|
||||
@@ -342,18 +342,45 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Named argument: --camera-orbit — rotate camera around model instead of rotating model
|
||||
camera_orbit = "--camera-orbit" in argv
|
||||
|
||||
# Named argument: --usd-path <path> — when set, import USD instead of GLB
|
||||
usd_path = ""
|
||||
if "--usd-path" in argv:
|
||||
_usd_idx = argv.index("--usd-path")
|
||||
usd_path = argv[_usd_idx + 1] if _usd_idx + 1 < len(argv) else ""
|
||||
|
||||
# Pre-load USD import helper once (used in both MODE A and MODE B)
|
||||
# Named argument: --focal-length <mm>
|
||||
_focal_length = None
|
||||
if "--focal-length" in argv:
|
||||
_idx = argv.index("--focal-length")
|
||||
_focal_length = float(argv[_idx + 1]) if _idx + 1 < len(argv) else None
|
||||
|
||||
# Named argument: --sensor-width <mm>
|
||||
_sensor_width = None
|
||||
if "--sensor-width" in argv:
|
||||
_idx = argv.index("--sensor-width")
|
||||
_sensor_width = float(argv[_idx + 1]) if _idx + 1 < len(argv) else None
|
||||
|
||||
# Ensure scripts dir is on path for shared module imports
|
||||
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if _scripts_dir not in sys.path:
|
||||
sys.path.insert(0, _scripts_dir)
|
||||
|
||||
# Pre-load USD import helper (used in both MODE A and MODE B)
|
||||
_import_usd_file = None
|
||||
if usd_path:
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from import_usd import import_usd_file as _import_usd_file # type: ignore[assignment]
|
||||
|
||||
# Shared material helpers (handle USD stub collisions correctly)
|
||||
from _blender_materials import (
|
||||
apply_material_library_direct as _apply_material_library_direct,
|
||||
apply_material_library as _apply_material_library_shared,
|
||||
build_mat_map_lower as _build_mat_map_lower,
|
||||
assign_failed_material as _assign_failed_material,
|
||||
)
|
||||
|
||||
os.makedirs(frames_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
@@ -390,6 +417,7 @@ def main():
|
||||
print(f"[turntable_render] material_library={material_library_path}, material_map keys={list(material_map.keys())}")
|
||||
|
||||
# ── SCENE SETUP ──────────────────────────────────────────────────────────
|
||||
_usd_mat_lookup: dict = {} # populated by import_usd_file when USD path is used
|
||||
|
||||
if use_template:
|
||||
# ── MODE B: Template-based render ────────────────────────────────────
|
||||
@@ -401,7 +429,7 @@ def main():
|
||||
|
||||
# Import geometry: USD path when available, otherwise GLB
|
||||
if usd_path and _import_usd_file:
|
||||
parts = _import_usd_file(usd_path)
|
||||
parts, _usd_mat_lookup = _import_usd_file(usd_path)
|
||||
else:
|
||||
parts = _import_glb(glb_path)
|
||||
# Apply render position rotation before material/camera setup
|
||||
@@ -419,20 +447,30 @@ def main():
|
||||
for part in parts:
|
||||
_apply_smooth(part, SMOOTH_ANGLE)
|
||||
|
||||
# Material assignment: library materials if available, otherwise palette
|
||||
if material_library_path and material_map:
|
||||
mat_map_lower = {k.lower(): v for k, v in material_map.items()}
|
||||
_apply_material_library(parts, material_library_path, mat_map_lower, part_names_ordered)
|
||||
# Parts not matched by library get palette fallback
|
||||
for i, part in enumerate(parts):
|
||||
if not part.data.materials or len(part.data.materials) == 0:
|
||||
_assign_palette_material(part, i)
|
||||
else:
|
||||
for i, part in enumerate(parts):
|
||||
step_name = _resolve_part_name(i, part, part_names_ordered)
|
||||
color_hex = part_colors.get(step_name)
|
||||
if not color_hex:
|
||||
_assign_palette_material(part, i)
|
||||
# Material assignment: USD primvar path first, then name-matching fallback
|
||||
if material_library_path and _usd_mat_lookup:
|
||||
_apply_material_library_direct(parts, material_library_path, _usd_mat_lookup)
|
||||
# Fall back to name-matching for parts without USD primvars
|
||||
if material_map:
|
||||
_unassigned = [p for p in parts if not p.data.materials or
|
||||
(len(p.data.materials) == 1 and p.data.materials[0] and
|
||||
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")]
|
||||
if _unassigned:
|
||||
print(f"[turntable_render] {len(_unassigned)} parts without USD primvar — "
|
||||
f"falling back to name-matching", flush=True)
|
||||
_apply_material_library_shared(
|
||||
_unassigned, material_library_path,
|
||||
_build_mat_map_lower(material_map), part_names_ordered,
|
||||
)
|
||||
elif material_library_path and material_map:
|
||||
_apply_material_library_shared(
|
||||
parts, material_library_path,
|
||||
_build_mat_map_lower(material_map), part_names_ordered,
|
||||
)
|
||||
# Palette fallback for any parts still without materials
|
||||
for i, part in enumerate(parts):
|
||||
if not part.data.materials or len(part.data.materials) == 0:
|
||||
_assign_palette_material(part, i)
|
||||
|
||||
# ── Shadow catcher (Cycles only, template mode only) ─────────────────
|
||||
if shadow_catcher:
|
||||
@@ -482,7 +520,7 @@ def main():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
if usd_path and _import_usd_file:
|
||||
parts = _import_usd_file(usd_path)
|
||||
parts, _usd_mat_lookup = _import_usd_file(usd_path)
|
||||
else:
|
||||
parts = _import_glb(glb_path)
|
||||
# Apply render position rotation before material/camera setup
|
||||
@@ -493,14 +531,23 @@ def main():
|
||||
for i, part in enumerate(parts):
|
||||
_apply_smooth(part, SMOOTH_ANGLE)
|
||||
|
||||
# Material assignment: library materials if available, else part_colors/palette
|
||||
if material_library_path and material_map:
|
||||
mat_map_lower = {k.lower(): v for k, v in material_map.items()}
|
||||
_apply_material_library(parts, material_library_path, mat_map_lower, part_names_ordered)
|
||||
# Palette fallback for unmatched parts
|
||||
for i, part in enumerate(parts):
|
||||
if not part.data.materials or len(part.data.materials) == 0:
|
||||
_assign_palette_material(part, i)
|
||||
# Material assignment: USD primvar path first, then name-matching fallback
|
||||
if material_library_path and _usd_mat_lookup:
|
||||
_apply_material_library_direct(parts, material_library_path, _usd_mat_lookup)
|
||||
if material_map:
|
||||
_unassigned = [p for p in parts if not p.data.materials or
|
||||
(len(p.data.materials) == 1 and p.data.materials[0] and
|
||||
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")]
|
||||
if _unassigned:
|
||||
_apply_material_library_shared(
|
||||
_unassigned, material_library_path,
|
||||
_build_mat_map_lower(material_map), part_names_ordered,
|
||||
)
|
||||
elif material_library_path and material_map:
|
||||
_apply_material_library_shared(
|
||||
parts, material_library_path,
|
||||
_build_mat_map_lower(material_map), part_names_ordered,
|
||||
)
|
||||
else:
|
||||
# part_colors or palette — use index-based lookup via part_names_ordered
|
||||
for i, part in enumerate(parts):
|
||||
@@ -523,6 +570,10 @@ def main():
|
||||
part.data.materials.append(mat)
|
||||
else:
|
||||
_assign_palette_material(part, i)
|
||||
# Palette fallback for any parts still without materials
|
||||
for i, part in enumerate(parts):
|
||||
if not part.data.materials or len(part.data.materials) == 0:
|
||||
_assign_palette_material(part, i)
|
||||
|
||||
if needs_auto_camera:
|
||||
# ── Combined bounding box / bounding sphere ──────────────────────────
|
||||
@@ -573,7 +624,19 @@ def main():
|
||||
fill.data.size = max(4.0, bsphere_radius * 4.0)
|
||||
|
||||
# ── Camera ───────────────────────────────────────────────────────────
|
||||
cam_dist = bsphere_radius * 2.5
|
||||
_lens = _focal_length if _focal_length is not None else 50.0
|
||||
_sw = _sensor_width if _sensor_width is not None else 36.0
|
||||
if _focal_length is not None:
|
||||
# FOV-based distance when focal length is explicitly set
|
||||
_fov_h = math.atan(_sw / (2.0 * _lens))
|
||||
_fov_v = math.atan(_sw * (height / width) / (2.0 * _lens))
|
||||
_fov_used = min(_fov_h, _fov_v)
|
||||
_FILL_FACTOR = 0.85
|
||||
cam_dist = (bsphere_radius / math.tan(_fov_used)) / _FILL_FACTOR
|
||||
cam_dist = max(cam_dist, bsphere_radius * 1.05)
|
||||
print(f"[turntable_render] FOV-based cam_dist={cam_dist:.4f}, lens={_lens}mm")
|
||||
else:
|
||||
cam_dist = bsphere_radius * 2.5
|
||||
cam_location = Vector((
|
||||
bbox_center.x + cam_dist,
|
||||
bbox_center.y,
|
||||
@@ -582,6 +645,7 @@ def main():
|
||||
bpy.ops.object.camera_add(location=cam_location)
|
||||
camera = bpy.context.active_object
|
||||
bpy.context.scene.camera = camera
|
||||
camera.data.lens = _lens
|
||||
camera.data.clip_start = max(cam_dist * 0.001, 0.0001)
|
||||
camera.data.clip_end = cam_dist * 10.0
|
||||
|
||||
@@ -606,30 +670,57 @@ def main():
|
||||
bg.inputs["Color"].default_value = (0.96, 0.96, 0.97, 1.0)
|
||||
bg.inputs["Strength"].default_value = 0.15
|
||||
|
||||
# ── Turntable pivot ──────────────────────────────────────────────────
|
||||
pivot = bpy.data.objects.new("pivot", None)
|
||||
bpy.context.collection.objects.link(pivot)
|
||||
pivot.location = bbox_center
|
||||
|
||||
# Parent camera to pivot
|
||||
camera.parent = pivot
|
||||
camera.location = (cam_dist, 0, bsphere_radius * 0.5)
|
||||
|
||||
# Keyframe pivot rotation
|
||||
# ── Turntable animation ──────────────────────────────────────────────
|
||||
scene = bpy.context.scene
|
||||
scene.frame_start = 1
|
||||
scene.frame_end = frame_count
|
||||
|
||||
pivot.rotation_euler = (0, 0, 0)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
|
||||
if camera_orbit:
|
||||
# Camera orbit: parent camera to pivot, rotate pivot
|
||||
pivot = bpy.data.objects.new("pivot", None)
|
||||
bpy.context.collection.objects.link(pivot)
|
||||
pivot.location = bbox_center
|
||||
|
||||
camera.parent = pivot
|
||||
camera.location = (cam_dist, 0, bsphere_radius * 0.5)
|
||||
|
||||
pivot.rotation_euler = (0, 0, 0)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
|
||||
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
print(f"[turntable] camera_orbit=True — rotating camera around model")
|
||||
else:
|
||||
# Object rotation: camera stays fixed, model parts rotate around bbox center
|
||||
pivot = bpy.data.objects.new("turntable_pivot", None)
|
||||
bpy.context.collection.objects.link(pivot)
|
||||
pivot.location = bbox_center
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
# Reparent parts to pivot while preserving world positions.
|
||||
# Parts may have existing USD parents (Xform nodes), so simple
|
||||
# matrix_parent_inverse = pivot.inverted() is NOT enough — it
|
||||
# loses the old parent's contribution. Instead, capture world
|
||||
# matrix, reparent, then restore world position via local matrix.
|
||||
for part in parts:
|
||||
mw = part.matrix_world.copy()
|
||||
part.parent = pivot
|
||||
part.matrix_parent_inverse.identity()
|
||||
bpy.context.view_layer.update()
|
||||
part.matrix_local = pivot.matrix_world.inverted() @ mw
|
||||
|
||||
pivot.rotation_euler = (0, 0, 0)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
|
||||
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
print(f"[turntable] camera_orbit=False — rotating model in front of camera")
|
||||
|
||||
# Linear interpolation — frame N+1 is never rendered, giving N uniform steps
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
|
||||
else:
|
||||
# Template has camera — set up turntable on the model parts instead
|
||||
# Template has its own camera (not auto-camera)
|
||||
scene = bpy.context.scene
|
||||
scene.frame_start = 1
|
||||
scene.frame_end = frame_count
|
||||
@@ -645,22 +736,44 @@ def main():
|
||||
(min(v.z for v in all_corners) + max(v.z for v in all_corners)) * 0.5,
|
||||
))
|
||||
|
||||
# Create a pivot empty and parent all parts to it
|
||||
pivot = bpy.data.objects.new("turntable_pivot", None)
|
||||
bpy.context.collection.objects.link(pivot)
|
||||
pivot.location = bbox_center
|
||||
if camera_orbit:
|
||||
# Camera orbit mode: rotate the template's camera around the model
|
||||
template_cam = scene.camera
|
||||
pivot = bpy.data.objects.new("turntable_pivot", None)
|
||||
bpy.context.collection.objects.link(pivot)
|
||||
pivot.location = bbox_center
|
||||
|
||||
for part in parts:
|
||||
part.parent = pivot
|
||||
template_cam.parent = pivot
|
||||
|
||||
# Keyframe pivot rotation
|
||||
pivot.rotation_euler = (0, 0, 0)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
|
||||
pivot.rotation_euler = (0, 0, 0)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
|
||||
|
||||
# Linear interpolation — frame N+1 is never rendered, giving N uniform steps
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
print(f"[turntable] camera_orbit=True — rotating template camera around model")
|
||||
else:
|
||||
# Object rotation mode: rotate the model in front of template camera
|
||||
pivot = bpy.data.objects.new("turntable_pivot", None)
|
||||
bpy.context.collection.objects.link(pivot)
|
||||
pivot.location = bbox_center
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
# Reparent preserving world positions (parts may have USD parents)
|
||||
for part in parts:
|
||||
mw = part.matrix_world.copy()
|
||||
part.parent = pivot
|
||||
part.matrix_parent_inverse.identity()
|
||||
bpy.context.view_layer.update()
|
||||
part.matrix_local = pivot.matrix_world.inverted() @ mw
|
||||
|
||||
pivot.rotation_euler = (0, 0, 0)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
|
||||
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
|
||||
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
|
||||
|
||||
_set_fcurves_linear(pivot.animation_data.action)
|
||||
print(f"[turntable] camera_orbit=False — rotating model in front of template camera")
|
||||
|
||||
# ── Colour management ────────────────────────────────────────────────────
|
||||
# In template mode the .blend file owns its colour management settings.
|
||||
|
||||
Reference in New Issue
Block a user