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
+52
View File
@@ -390,6 +390,28 @@ async def get_part_materials(
)
def _normalize_part_name(name: str) -> str:
"""Strip OCC _AF\\d+ suffixes and lowercase for comparison."""
import re
n = name.strip().lower()
prev = ""
while prev != n:
prev = n
n = re.sub(r"_af\d+(_asm)?$", "", n)
return n
def _valid_part_names(cad) -> set[str] | None:
"""Return normalized part names from parsed_objects, or None if unavailable."""
po = cad.parsed_objects
if not po or not isinstance(po, dict):
return None
objects = po.get("objects")
if not objects or not isinstance(objects, list):
return None
return {_normalize_part_name(n) for n in objects if isinstance(n, str)}
@router.put("/{id}/part-materials", response_model=PartMaterialsResponse)
async def save_part_materials(
id: uuid.UUID,
@@ -401,10 +423,26 @@ async def save_part_materials(
Accepts a full dict of part-name -> {type, value} and overwrites the existing
assignment. Pass an empty dict to clear all assignments.
Keys are validated against parsed_objects — unknown part names are rejected.
"""
if not is_privileged(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
cad = await _get_cad_file(id, db)
# Validate keys against known part names from STEP extraction
valid_names = _valid_part_names(cad)
if valid_names is not None and body:
invalid_keys = [
k for k in body
if _normalize_part_name(k) not in valid_names
]
if invalid_keys:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unknown part names (not in parsed_objects): {invalid_keys[:10]}",
)
# Serialise Pydantic models to plain dicts for JSONB storage
cad.part_materials = {name: entry.model_dump() for name, entry in body.items()}
cad.updated_at = datetime.utcnow()
@@ -514,6 +552,20 @@ async def save_manual_material_overrides(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
cad = await _get_cad_file(id, db)
# Validate keys against known part names (slugified form)
valid_names = _valid_part_names(cad)
if valid_names is not None and body.overrides:
invalid_keys = [
k for k in body.overrides
if _normalize_part_name(k) not in valid_names
]
if invalid_keys:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unknown part keys (not in parsed_objects): {invalid_keys[:10]}",
)
cad.manual_material_overrides = body.overrides
cad.updated_at = datetime.utcnow()
await db.commit()