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>
327 lines
13 KiB
Python
327 lines
13 KiB
Python
"""Material assignment helpers for Blender headless renders."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re as _re
|
|
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]
|
|
|
|
result: dict = {}
|
|
if not names:
|
|
return result
|
|
|
|
try:
|
|
with bpy.data.libraries.load(mat_lib_path, link=False) as (data_from, data_to):
|
|
# data_from.materials lists all material names in the .blend
|
|
available = set(data_from.materials)
|
|
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.
|
|
# If a USD stub occupied the name, the real material gets a .NNN suffix.
|
|
for mat_name in to_load:
|
|
mat = _find_material_with_nodes(mat_name)
|
|
if mat:
|
|
result[mat_name] = mat
|
|
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:
|
|
print(f"[blender_render] WARNING: materials not in library: {sorted(not_found)[:10]}")
|
|
except Exception as exc:
|
|
print(f"[blender_render] WARNING: batch append failed ({exc}), falling back to individual append")
|
|
# Fallback: individual append for each material
|
|
for mat_name in names:
|
|
if mat_name in result:
|
|
continue
|
|
try:
|
|
bpy.ops.wm.append(
|
|
filepath=f"{mat_lib_path}/Material/{mat_name}",
|
|
directory=f"{mat_lib_path}/Material/",
|
|
filename=mat_name,
|
|
link=False,
|
|
)
|
|
mat = _find_material_with_nodes(mat_name)
|
|
if mat:
|
|
result[mat_name] = mat
|
|
except Exception:
|
|
pass
|
|
|
|
return result
|
|
|
|
|
|
def assign_failed_material(part_obj) -> None:
|
|
"""Assign the standard fallback material (magenta) when no library material matches.
|
|
|
|
Reuses SCHAEFFLER_059999_FailedMaterial if already loaded; otherwise
|
|
creates a simple magenta Principled BSDF node tree.
|
|
"""
|
|
import bpy # type: ignore[import]
|
|
|
|
mat = bpy.data.materials.get(FAILED_MATERIAL_NAME)
|
|
if mat is None:
|
|
mat = bpy.data.materials.new(name=FAILED_MATERIAL_NAME)
|
|
mat.use_nodes = True
|
|
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
if bsdf:
|
|
bsdf.inputs["Base Color"].default_value = (1.0, 0.0, 1.0, 1.0) # magenta
|
|
bsdf.inputs["Roughness"].default_value = 0.6
|
|
part_obj.data.materials.clear()
|
|
part_obj.data.materials.append(mat)
|
|
|
|
|
|
def build_mat_map_lower(material_map: dict) -> dict:
|
|
"""Return a lowercased version of material_map with _AF\\d+ suffix variants added.
|
|
|
|
Both the original key and the AF-stripped key are inserted so that GLB
|
|
object names (which may lack _AF suffixes that OCC adds to mat_map keys)
|
|
can match in either direction.
|
|
"""
|
|
mat_map_lower: dict = {}
|
|
for k, v in material_map.items():
|
|
kl = k.lower().strip()
|
|
mat_map_lower[kl] = v
|
|
# USD path: part_key slugs replace ALL non-alphanumeric chars with '_'
|
|
# (same regex as generate_part_key in export_step_to_usd.py).
|
|
# E.g. "F-802007_TR4-D1" → "f_802007_tr4_d1". Add slug variant so
|
|
# hyphenated OCC names match USD-imported Blender objects.
|
|
slug_key = _re.sub(r'[^a-z0-9]+', '_', kl).strip('_')
|
|
if slug_key and slug_key != kl:
|
|
mat_map_lower.setdefault(slug_key, v)
|
|
# Strip OCC assembly-frame suffixes: _AF0, _AF0_1, _AF0_1_AF0, etc.
|
|
# Pattern matches one or more groups of _AF<n> optionally followed by
|
|
# an instance number _<n>, anchored at end of string.
|
|
stripped = _re.sub(r'(_af\d+(_\d+)?)+$', '', kl)
|
|
if stripped != kl:
|
|
mat_map_lower.setdefault(stripped, v)
|
|
# Also slug the AF-stripped key for USD path where part_key is
|
|
# both AF-stripped AND slugified (e.g. "ge360-hf_..." → "ge360_hf_...")
|
|
slug_stripped = _re.sub(r'[^a-z0-9]+', '_', stripped).strip('_')
|
|
if slug_stripped and slug_stripped != stripped:
|
|
mat_map_lower.setdefault(slug_stripped, v)
|
|
return mat_map_lower
|
|
|
|
|
|
def apply_material_library_direct(
|
|
parts: list,
|
|
mat_lib_path: str,
|
|
material_lookup: dict[str, str],
|
|
) -> None:
|
|
"""Assign materials from library using a direct object_name → material_name mapping.
|
|
|
|
This bypasses all name-matching heuristics — the mapping comes from USD
|
|
customData (schaeffler:canonicalMaterialName) read via pxr after Blender import.
|
|
Parts not present in material_lookup receive FAILED_MATERIAL_NAME.
|
|
|
|
material_lookup: {blender_object_name: canonical_material_name}
|
|
"""
|
|
if not mat_lib_path or not os.path.isfile(mat_lib_path):
|
|
print(f"[blender_render] material library not found: {mat_lib_path}")
|
|
return
|
|
|
|
import bpy # type: ignore[import]
|
|
|
|
_t0 = _time.monotonic()
|
|
|
|
# Collect unique material names needed
|
|
needed = set(material_lookup.values())
|
|
if not needed:
|
|
return
|
|
|
|
# Batch-append materials from library (single file open)
|
|
appended: dict = {}
|
|
_t_append = _time.monotonic()
|
|
# 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:
|
|
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
|
|
if still_needed:
|
|
appended.update(_batch_append_materials(mat_lib_path, still_needed))
|
|
_append_dur = _time.monotonic() - _t_append
|
|
print(f"[blender_render] TIMING material_append_direct={_append_dur:.2f}s ({len(appended)}/{len(needed)} materials)", flush=True)
|
|
|
|
if not appended:
|
|
return
|
|
|
|
assigned_count = 0
|
|
unmatched_names = []
|
|
for part in parts:
|
|
mat_name = material_lookup.get(part.name)
|
|
if mat_name and mat_name in appended:
|
|
if part.data.users > 1:
|
|
part.data = part.data.copy()
|
|
part.data.materials.clear()
|
|
part.data.materials.append(appended[mat_name])
|
|
assigned_count += 1
|
|
else:
|
|
unmatched_names.append(part.name)
|
|
|
|
_assign_dur = _time.monotonic() - _t_append - _append_dur + (_time.monotonic() - _t0 - _append_dur)
|
|
_total = _time.monotonic() - _t0
|
|
print(f"[blender_render] TIMING material_assign_direct={_total:.2f}s "
|
|
f"(append={_append_dur:.2f}s, assign={_total - _append_dur:.2f}s, "
|
|
f"{assigned_count}/{len(parts)} matched)", flush=True)
|
|
if unmatched_names:
|
|
print(f"[blender_render] unmatched (no primvar): {unmatched_names[:10]}", flush=True)
|
|
for part in parts:
|
|
if part.name in set(unmatched_names):
|
|
if part.data.users > 1:
|
|
part.data = part.data.copy()
|
|
assign_failed_material(part)
|
|
|
|
|
|
def apply_material_library(
|
|
parts: list,
|
|
mat_lib_path: str,
|
|
mat_map: dict,
|
|
part_names_ordered: list | None = None,
|
|
) -> None:
|
|
"""Append materials from library .blend and assign to parts via material_map.
|
|
|
|
GLB-imported objects are named after STEP parts, so matching is by name
|
|
(stripping Blender .NNN suffix for duplicates). Falls back to
|
|
part_names_ordered index-based matching.
|
|
|
|
mat_map: {part_name_lower: material_name}
|
|
Parts without a match receive the FAILED_MATERIAL_NAME sentinel.
|
|
"""
|
|
if not mat_lib_path or not os.path.isfile(mat_lib_path):
|
|
print(f"[blender_render] material library not found: {mat_lib_path}")
|
|
return
|
|
|
|
import bpy # type: ignore[import]
|
|
|
|
_t0 = _time.monotonic()
|
|
|
|
if part_names_ordered is None:
|
|
part_names_ordered = []
|
|
|
|
# Collect unique material names needed
|
|
needed = set(mat_map.values())
|
|
if not needed:
|
|
return
|
|
|
|
# Batch-append materials from library (single file open)
|
|
appended: dict = {}
|
|
_t_append = _time.monotonic()
|
|
appended.update(_batch_append_materials(mat_lib_path, needed))
|
|
_append_dur = _time.monotonic() - _t_append
|
|
print(f"[blender_render] TIMING material_append={_append_dur:.2f}s ({len(appended)}/{len(needed)} materials)", flush=True)
|
|
|
|
if not appended:
|
|
return
|
|
|
|
# Assign materials to parts — primary: name-based (GLB object names),
|
|
# secondary: index-based via part_names_ordered
|
|
assigned_count = 0
|
|
unmatched_names = []
|
|
for i, part in enumerate(parts):
|
|
# Try name-based matching first (strip Blender .NNN suffix)
|
|
base_name = _re.sub(r'\.\d{3}$', '', part.name)
|
|
# Strip OCC assembly-instance suffix (_AF0, _AF1, …) — GLB object
|
|
# names may or may not have them while mat_map keys might.
|
|
_prev = None
|
|
while _prev != base_name:
|
|
_prev = base_name
|
|
base_name = _re.sub(r'_AF\d+$', '', base_name, flags=_re.IGNORECASE)
|
|
part_key = base_name.lower().strip()
|
|
mat_name = mat_map.get(part_key)
|
|
|
|
# Prefix fallback: if a mat_map key starts with our base name or
|
|
# vice-versa, use the longest matching key (most-specific wins).
|
|
if not mat_name:
|
|
for key, val in sorted(mat_map.items(), key=lambda x: len(x[0]), reverse=True):
|
|
if len(key) >= 5 and len(part_key) >= 5 and (
|
|
part_key.startswith(key) or key.startswith(part_key)
|
|
):
|
|
mat_name = val
|
|
break
|
|
|
|
# Fall back to index-based matching via part_names_ordered
|
|
if not mat_name and part_names_ordered and i < len(part_names_ordered):
|
|
step_name = part_names_ordered[i]
|
|
step_key = step_name.lower().strip()
|
|
mat_name = mat_map.get(step_key)
|
|
# Also try stripping AF from part_names_ordered entry
|
|
if not mat_name:
|
|
_p2 = None
|
|
while _p2 != step_key:
|
|
_p2 = step_key
|
|
step_key = _re.sub(r'_af\d+$', '', step_key)
|
|
mat_name = mat_map.get(step_key)
|
|
|
|
if mat_name and mat_name in appended:
|
|
part.data.materials.clear()
|
|
part.data.materials.append(appended[mat_name])
|
|
assigned_count += 1
|
|
else:
|
|
unmatched_names.append(part.name)
|
|
|
|
_total = _time.monotonic() - _t0
|
|
print(f"[blender_render] TIMING material_assign={_total:.2f}s "
|
|
f"(append={_append_dur:.2f}s, match={_total - _append_dur:.2f}s, "
|
|
f"{assigned_count}/{len(parts)} matched)", flush=True)
|
|
if unmatched_names:
|
|
print(f"[blender_render] unmatched parts → assigning {FAILED_MATERIAL_NAME}: {unmatched_names[:10]}", flush=True)
|
|
unmatched_set = set(unmatched_names)
|
|
for part in parts:
|
|
if part.name in unmatched_set:
|
|
if part.data.users > 1:
|
|
part.data = part.data.copy()
|
|
assign_failed_material(part)
|