feat: performance optimizations + part-materials validation

- @timed_step decorator with wall-clock + RSS tracking (pipeline_logger)
- Blender timing laps for sharp edges and material assignment
- MeshRegistry pattern: eliminate 13 scene.traverse() calls across viewers
- Lazy material cloning (clone-on-first-write in both viewers)
- _pipeline_session context manager: 7 create_engine() → 2 in render_thumbnail
- KD-tree spatial pre-filter for sharp edge marking (bbox-based pruning)
- Batch material library append: N bpy.ops.wm.append → single bpy.data.libraries.load
- GMSH single-session batching: compound all solids into one tessellation call
- Validate part-materials save endpoints against parsed_objects (prevents bogus keys)
- ROADMAP updated with completion status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 11:53:14 +01:00
parent ec667dd56a
commit 6c5873d51f
11 changed files with 612 additions and 541 deletions
+82 -37
View File
@@ -3,10 +3,63 @@ from __future__ import annotations
import os
import re as _re
import time as _time
FAILED_MATERIAL_NAME = "SCHAEFFLER_059999_FailedMaterial"
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.
"""
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
for mat_name in to_load:
mat = bpy.data.materials.get(mat_name)
if mat:
result[mat_name] = mat
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 = bpy.data.materials.get(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.
@@ -78,32 +131,28 @@ def apply_material_library_direct(
import bpy # type: ignore[import]
_t0 = _time.monotonic()
# Collect unique material names needed
needed = set(material_lookup.values())
if not needed:
return
# Append materials from library
# Batch-append materials from library (single file open)
appended: dict = {}
_t_append = _time.monotonic()
# Check already-loaded materials first
still_needed = set()
for mat_name in needed:
if mat_name in bpy.data.materials:
appended[mat_name] = bpy.data.materials[mat_name]
continue
inner_path = f"{mat_lib_path}/Material/{mat_name}"
try:
bpy.ops.wm.append(
filepath=inner_path,
directory=f"{mat_lib_path}/Material/",
filename=mat_name,
link=False,
)
if mat_name in bpy.data.materials:
appended[mat_name] = bpy.data.materials[mat_name]
print(f"[blender_render] appended material: {mat_name}")
else:
print(f"[blender_render] WARNING: material '{mat_name}' not found after append")
except Exception as exc:
print(f"[blender_render] WARNING: failed to append material '{mat_name}': {exc}")
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
@@ -121,8 +170,11 @@ def apply_material_library_direct(
else:
unmatched_names.append(part.name)
print(f"[blender_render] direct material assignment (USD primvars): "
f"{assigned_count}/{len(parts)} parts matched", flush=True)
_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:
@@ -153,6 +205,8 @@ def apply_material_library(
import bpy # type: ignore[import]
_t0 = _time.monotonic()
if part_names_ordered is None:
part_names_ordered = []
@@ -161,24 +215,12 @@ def apply_material_library(
if not needed:
return
# Append materials from library
# Batch-append materials from library (single file open)
appended: dict = {}
for mat_name in needed:
inner_path = f"{mat_lib_path}/Material/{mat_name}"
try:
bpy.ops.wm.append(
filepath=inner_path,
directory=f"{mat_lib_path}/Material/",
filename=mat_name,
link=False,
)
if mat_name in bpy.data.materials:
appended[mat_name] = bpy.data.materials[mat_name]
print(f"[blender_render] appended material: {mat_name}")
else:
print(f"[blender_render] WARNING: material '{mat_name}' not found after append")
except Exception as exc:
print(f"[blender_render] WARNING: failed to append material '{mat_name}': {exc}")
_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
@@ -229,7 +271,10 @@ def apply_material_library(
else:
unmatched_names.append(part.name)
print(f"[blender_render] material assignment: {assigned_count}/{len(parts)} parts matched", flush=True)
_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)