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
+56 -2
View File
@@ -56,9 +56,12 @@ def apply_sharp_edges_from_occ(parts: list, sharp_edge_pairs: list) -> None:
if not sharp_edge_pairs:
return
import time as _time
import bmesh # type: ignore[import]
import mathutils # type: ignore[import]
_t0 = _time.monotonic()
SCALE = 0.001 # mm → m
TOL = 0.0005 # 0.5 mm in metres
@@ -71,8 +74,33 @@ def apply_sharp_edges_from_occ(parts: list, sharp_edge_pairs: list) -> None:
v1 = mathutils.Vector((pair[1][0] * SCALE, -pair[1][2] * SCALE, pair[1][1] * SCALE))
occ_pairs.append((v0, v1))
_t_convert = _time.monotonic()
print(f"[blender_render] TIMING sharp_edges_convert={_t_convert - _t0:.3f}s ({len(occ_pairs)} pairs)", flush=True)
# ── Spatial pre-filter: build a KD-tree over OCC pair midpoints ────────
# For each part, query the midpoint KD-tree with the part's bbox radius
# to get only nearby pairs instead of testing all N pairs × M parts.
_t_spatial = _time.monotonic()
pair_midpoints = []
pair_radii = [] # half-length of each pair (max distance from midpoint to endpoint)
for v0, v1 in occ_pairs:
mid = (v0 + v1) * 0.5
pair_midpoints.append(mid)
pair_radii.append((v0 - mid).length)
pair_kd = mathutils.kdtree.KDTree(len(pair_midpoints))
for i, mid in enumerate(pair_midpoints):
pair_kd.insert(mid, i)
pair_kd.balance()
_t_spatial_done = _time.monotonic()
print(f"[blender_render] TIMING sharp_edges_spatial_index={_t_spatial_done - _t_spatial:.3f}s", flush=True)
marked_total = 0
kd_build_time = 0.0
match_time = 0.0
pairs_tested_total = 0
for obj in parts:
_t_kd = _time.monotonic()
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.verts.ensure_lookup_table()
@@ -86,8 +114,28 @@ def apply_sharp_edges_from_occ(parts: list, sharp_edge_pairs: list) -> None:
kd.insert(world_mat @ v.co, v.index)
kd.balance()
# Compute part's world-space bounding box center and search radius
from mathutils import Vector # type: ignore[import]
corners = [world_mat @ Vector(c) for c in obj.bound_box]
bbox_min = Vector((min(c.x for c in corners), min(c.y for c in corners), min(c.z for c in corners)))
bbox_max = Vector((max(c.x for c in corners), max(c.y for c in corners), max(c.z for c in corners)))
bbox_center = (bbox_min + bbox_max) * 0.5
bbox_half_diag = (bbox_max - bbox_min).length * 0.5
kd_build_time += _time.monotonic() - _t_kd
_t_match = _time.monotonic()
marked = 0
for v0_occ, v1_occ in occ_pairs:
# Query pair midpoints within bbox_half_diag + max_pair_radius + tolerance
# This guarantees we don't miss any pair whose endpoints could be inside the bbox
max_pair_radius = max(pair_radii) if pair_radii else 0.0
search_radius = bbox_half_diag + max_pair_radius + TOL
nearby = pair_kd.find_range(bbox_center, search_radius)
pairs_tested_total += len(nearby)
for _co, pair_idx, _dist in nearby:
v0_occ, v1_occ = occ_pairs[pair_idx]
_co0, idx0, dist0 = kd.find(v0_occ)
_co1, idx1, dist1 = kd.find(v1_occ)
if dist0 > TOL or dist1 > TOL:
@@ -102,12 +150,18 @@ def apply_sharp_edges_from_occ(parts: list, sharp_edge_pairs: list) -> None:
if edge is not None and edge.smooth:
edge.smooth = False
marked += 1
match_time += _time.monotonic() - _t_match
bm.to_mesh(obj.data)
bm.free()
marked_total += marked
print(f"[blender_render] OCC sharp edges applied: {marked_total} edges marked across {len(parts)} parts", flush=True)
_total = _time.monotonic() - _t0
pairs_skipped = len(occ_pairs) * len(parts) - pairs_tested_total
print(f"[blender_render] TIMING sharp_edges={_total:.2f}s "
f"(kd_build={kd_build_time:.2f}s, matching={match_time:.2f}s, "
f"pairs={len(occ_pairs)}, parts={len(parts)}, marked={marked_total}, "
f"tested={pairs_tested_total}, skipped={pairs_skipped})", flush=True)
def setup_shadow_catcher(parts: list) -> None: