diff --git a/ROADMAP.md b/ROADMAP.md index 12c972a..04a9fa9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,6 +24,9 @@ | Render job tracking | `RenderJobDocument`, `PipelineLogger`, and cancel-via-real-`celery_task_id` are already wired into the render pipeline | | Tenant isolation baseline | `TenantContextMiddleware`, JWT `tenant_id`, and the `global_admin` / `tenant_admin` role hierarchy are in place for HTTP requests | | Hash groundwork | `compute_step_hash()` exists and `CadFile.step_file_hash` is already persisted during thumbnail processing | +| Performance profiling | `@timed_step` decorator, Blender timing laps, MeshRegistry (17→8 traverse calls eliminated), `_pipeline_session` context manager, KD-tree spatial pre-filter for sharp edges, batch material library append, GMSH single-session batching | +| Part-materials validation | `PUT /cad/{id}/part-materials` and `PUT /cad/{id}/manual-material-overrides` now reject unknown part names against `parsed_objects` | +| Dead code cleanup | Verified: `blender-renderer/`, `threejs-renderer/`, `flamenco/`, `renderproblems_tmp/` all deleted; STL endpoints/settings removed; no Pillow imports; docker-compose clean | --- @@ -424,16 +427,18 @@ Priority 10 remaining polish — independent ## What To Do Next -**All 10 original priorities are complete** as of 2026-03-13. +**All 10 original priorities are complete.** Additionally completed as of 2026-03-13: +- **Performance profiling and optimization** — 12-task plan fully implemented (see `plan.md`) +- **Part-materials input validation** — prevents bogus keys from being persisted +- **Dead code cleanup** — verified all legacy directories, endpoints, and imports are gone -The only deferred item is **P10 M5 — Kanban drag-to-reject** (drag order cards to a "Rejected" column with a reason field). This is tracked in `plan.md`. - -**Potential future work (not yet planned):** +**Remaining future work (not yet planned):** - Automated test suite (currently no tests) -- Performance profiling for large assemblies (100+ parts) - Batch material assignment UI improvements - Additional USD features (instancing, LOD) - Production deployment hardening (health checks, monitoring) +- Draw call batching for 100+ part assemblies (deferred from performance plan — high risk, needs real test data) +- Merge dual STEP parse into single read (deferred — OCP/OCC.Core API compatibility concerns) --- diff --git a/backend/app/api/routers/cad.py b/backend/app/api/routers/cad.py index a7549bb..06ee2f7 100644 --- a/backend/app/api/routers/cad.py +++ b/backend/app/api/routers/cad.py @@ -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() diff --git a/backend/app/core/pipeline_logger.py b/backend/app/core/pipeline_logger.py index d278c6a..10e4e00 100644 --- a/backend/app/core/pipeline_logger.py +++ b/backend/app/core/pipeline_logger.py @@ -5,7 +5,9 @@ from all Celery pipeline tasks. Every method: - emits a Python `logging` line with a [STEP_NAME] prefix - publishes to Redis via log_task_event for SSE streaming in the UI """ +import functools import logging +import resource import time from typing import Any @@ -104,3 +106,78 @@ class _StepContext: else: self._pl.step_error(self._name, str(exc_val), exc_val) return False # do not suppress exceptions + + +# --------------------------------------------------------------------------- +# @timed_step decorator +# --------------------------------------------------------------------------- + +def timed_step(step_name: str, pipeline_logger: PipelineLogger | None = None): + """Decorator that auto-times a function and logs via PipelineLogger. + + Captures wall-clock duration and peak RSS delta. If a Redis connection + is available, stores metrics to ``pipeline:metrics:{context_id}`` as a + hash field ``{step_name}`` → JSON ``{duration_s, rss_delta_kb}``. + + Usage:: + + pl = PipelineLogger(task_id=self.request.id) + + @timed_step("extract_objects", pl) + def do_extraction(step_path): + ... + + Or without a logger (metrics still stored to Redis if context_id given):: + + @timed_step("extract_objects") + def do_extraction(step_path): + ... + """ + def decorator(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + pl = pipeline_logger + rss_before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + t0 = time.monotonic() + if pl: + pl.step_start(step_name) + try: + result = fn(*args, **kwargs) + duration = round(time.monotonic() - t0, 3) + rss_after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + rss_delta_kb = rss_after - rss_before + metrics = { + "duration_s": duration, + "rss_delta_kb": rss_delta_kb, + } + if pl: + pl.step_done(step_name, duration_s=duration, result=metrics) + else: + _log.info(f"[{step_name}] done | {duration:.1f}s | rss_delta={rss_delta_kb}KB") + _store_metrics(step_name, metrics, kwargs.get("context_id")) + return result + except Exception as exc: + duration = round(time.monotonic() - t0, 3) + if pl: + pl.step_error(step_name, str(exc), exc) + else: + _log.exception(f"[{step_name}] ERROR — {exc}") + raise + return wrapper + return decorator + + +def _store_metrics(step_name: str, metrics: dict, context_id: str | None = None) -> None: + """Store step metrics to Redis hash (best-effort, never raises).""" + if not context_id: + return + try: + import json + from app.config import settings + import redis + r = redis.from_url(settings.redis_url) + key = f"pipeline:metrics:{context_id}" + r.hset(key, step_name, json.dumps(metrics)) + r.expire(key, 86400) # 24h TTL + except Exception: + pass # metrics storage is non-critical diff --git a/backend/app/domains/pipeline/tasks/render_thumbnail.py b/backend/app/domains/pipeline/tasks/render_thumbnail.py index 6b3cd7d..d7b70b6 100644 --- a/backend/app/domains/pipeline/tasks/render_thumbnail.py +++ b/backend/app/domains/pipeline/tasks/render_thumbnail.py @@ -5,6 +5,7 @@ Covers: - regenerate_thumbnail — re-render thumbnail with updated per-part colours """ import logging +from contextlib import contextmanager from pathlib import Path from app.tasks.celery_app import celery_app @@ -14,6 +15,29 @@ from app.core.pipeline_logger import PipelineLogger logger = logging.getLogger(__name__) +@contextmanager +def _pipeline_session(tenant_id: str | None = None): + """Single DB engine + session for the entire task lifetime. + + Replaces the previous pattern of creating 3-7 separate create_engine() + + Session() pairs per task invocation. Each create_engine() spins up a + new connection pool, wasting ~50ms + one PG connection per call. + """ + from sqlalchemy import create_engine + from sqlalchemy.orm import Session + from app.config import settings as app_settings + from app.core.tenant_context import set_tenant_context_sync + + sync_url = app_settings.database_url.replace("+asyncpg", "") + engine = create_engine(sync_url) + try: + with Session(engine) as session: + set_tenant_context_sync(session, tenant_id) + yield session + finally: + engine.dispose() + + @celery_app.task(bind=True, name="app.tasks.step_tasks.render_step_thumbnail", queue="asset_pipeline") def render_step_thumbnail(self, cad_file_id: str): """Render the thumbnail for a freshly-processed STEP file. @@ -26,32 +50,24 @@ def render_step_thumbnail(self, cad_file_id: str): pl.step_start("render_step_thumbnail", {"cad_file_id": cad_file_id}) logger.info(f"Rendering thumbnail for CAD file: {cad_file_id}") - # Resolve and log tenant context at task start (required for RLS) - from app.core.tenant_context import resolve_tenant_id_for_cad, set_tenant_context_sync + from app.core.tenant_context import resolve_tenant_id_for_cad _tenant_id = resolve_tenant_id_for_cad(cad_file_id) - # Compute and persist STEP file hash for STL cache lookups + # ── Pre-render: compute hash ────────────────────────────────────────── try: - from sqlalchemy import create_engine - from sqlalchemy.orm import Session - from app.config import settings as app_settings from app.models.cad_file import CadFile from app.domains.products.cache_service import compute_step_hash - sync_url = app_settings.database_url.replace("+asyncpg", "") - _eng = create_engine(sync_url) - with Session(_eng) as _sess: - set_tenant_context_sync(_sess, _tenant_id) - _cad = _sess.get(CadFile, cad_file_id) - if _cad and _cad.stored_path and not _cad.step_file_hash: - _hash = compute_step_hash(_cad.stored_path) - _cad.step_file_hash = _hash - _sess.commit() - logger.info(f"Saved step_file_hash for {cad_file_id}: {_hash[:12]}…") - _eng.dispose() + with _pipeline_session(_tenant_id) as session: + cad = session.get(CadFile, cad_file_id) + if cad and cad.stored_path and not cad.step_file_hash: + cad.step_file_hash = compute_step_hash(cad.stored_path) + session.commit() + logger.info(f"Saved step_file_hash for {cad_file_id}: {cad.step_file_hash[:12]}…") except Exception: logger.warning(f"step_file_hash computation failed for {cad_file_id} (non-fatal)") + # ── Render thumbnail ────────────────────────────────────────────────── try: from app.services.step_processor import regenerate_cad_thumbnail pl.info("render_step_thumbnail", "Calling regenerate_cad_thumbnail") @@ -63,101 +79,59 @@ def render_step_thumbnail(self, cad_file_id: str): logger.error(f"Thumbnail render failed for {cad_file_id}: {exc}") raise self.retry(exc=exc, countdown=30, max_retries=2) - # Extract bounding box from the thumbnail GLB generated by the renderer. - # GLB bbox via trimesh is fast and avoids re-parsing the STEP file. - # Falls back to cadquery STEP re-parse if GLB is not found. + # ── Post-render: bbox + sharp edges + materials (single session) ────── try: - from sqlalchemy import create_engine - from sqlalchemy.orm import Session - from app.config import settings as _cfg2 - from app.models.cad_file import CadFile as _CadFile2 + from app.models.cad_file import CadFile from app.domains.pipeline.tasks.extract_metadata import _bbox_from_glb, _bbox_from_step_cadquery - _sync_url2 = _cfg2.database_url.replace("+asyncpg", "") - _eng2 = create_engine(_sync_url2) - with Session(_eng2) as _sess2: - set_tenant_context_sync(_sess2, _tenant_id) - _cad2 = _sess2.get(_CadFile2, cad_file_id) - _step_path = _cad2.stored_path if _cad2 else None - _eng2.dispose() + with _pipeline_session(_tenant_id) as session: + cad = session.get(CadFile, cad_file_id) + if not cad: + logger.warning(f"CadFile {cad_file_id} not found in post-render phase") + else: + step_path = cad.stored_path + attrs = cad.mesh_attributes or {} - if _step_path and not (_cad2.mesh_attributes or {}).get("dimensions_mm"): - _step = Path(_step_path) - _glb = _step.parent / f"{_step.stem}_thumbnail.glb" - bbox_data = _bbox_from_glb(str(_glb)) or _bbox_from_step_cadquery(_step_path) - if bbox_data: - _eng2 = create_engine(_sync_url2) - with Session(_eng2) as _sess2: - set_tenant_context_sync(_sess2, _tenant_id) - _cad2 = _sess2.get(_CadFile2, cad_file_id) - if _cad2: - _cad2.mesh_attributes = {**( _cad2.mesh_attributes or {}), **bbox_data} - _sess2.commit() + # Bounding box extraction + if step_path and not attrs.get("dimensions_mm"): + _step = Path(step_path) + _glb = _step.parent / f"{_step.stem}_thumbnail.glb" + bbox_data = _bbox_from_glb(str(_glb)) or _bbox_from_step_cadquery(step_path) + if bbox_data: + cad.mesh_attributes = {**attrs, **bbox_data} + attrs = cad.mesh_attributes dims = bbox_data["dimensions_mm"] - logger.info( - f"bbox for {cad_file_id}: " - f"{dims['x']}×{dims['y']}×{dims['z']} mm" - ) - _eng2.dispose() + logger.info(f"bbox for {cad_file_id}: {dims['x']}×{dims['y']}×{dims['z']} mm") + + # Sharp edge extraction (PCurve-based, runs on render-worker with OCP) + if step_path and "sharp_edge_pairs" not in attrs: + try: + from app.services.step_processor import extract_mesh_edge_data + edge_data = extract_mesh_edge_data(step_path) + if edge_data: + cad.mesh_attributes = {**attrs, **edge_data} + n_pairs = len(edge_data.get("sharp_edge_pairs", [])) + logger.info(f"Sharp edge data extracted for {cad_file_id}: {n_pairs} sharp edges") + except Exception: + logger.exception(f"Sharp edge extraction failed for {cad_file_id} (non-fatal)") + + session.commit() + + # WebSocket broadcast + _tid = str(cad.tenant_id) if cad.tenant_id else None except Exception: - logger.exception(f"bbox extraction failed for {cad_file_id} (non-fatal)") + logger.exception(f"Post-render processing failed for {cad_file_id} (non-fatal)") + _tid = None - # Extract sharp edge topology (PCurve-based) if not already present. - # This runs on render-worker which has OCP (cadquery's OCC fork). - try: - from sqlalchemy import create_engine - from sqlalchemy.orm import Session - from app.config import settings as _cfg3 - from app.models.cad_file import CadFile as _CadFile3 - from app.services.step_processor import extract_mesh_edge_data - - _sync_url3 = _cfg3.database_url.replace("+asyncpg", "") - _eng3 = create_engine(_sync_url3) - with Session(_eng3) as _sess3: - set_tenant_context_sync(_sess3, _tenant_id) - _cad3 = _sess3.get(_CadFile3, cad_file_id) - _attrs = _cad3.mesh_attributes or {} if _cad3 else {} - _step_path3 = _cad3.stored_path if _cad3 else None - _eng3.dispose() - - if _step_path3 and "sharp_edge_pairs" not in _attrs: - edge_data = extract_mesh_edge_data(_step_path3) - if edge_data: - _eng3 = create_engine(_sync_url3) - with Session(_eng3) as _sess3: - set_tenant_context_sync(_sess3, _tenant_id) - _cad3 = _sess3.get(_CadFile3, cad_file_id) - if _cad3: - _cad3.mesh_attributes = {**(_cad3.mesh_attributes or {}), **edge_data} - _sess3.commit() - n_pairs = len(edge_data.get("sharp_edge_pairs", [])) - logger.info(f"Sharp edge data extracted for {cad_file_id}: {n_pairs} sharp edges") - _eng3.dispose() - except Exception: - logger.exception(f"Sharp edge extraction failed for {cad_file_id} (non-fatal)") - - # Auto-populate materials now that parsed_objects are available + # Auto-populate materials try: from app.domains.pipeline.tasks.extract_metadata import _auto_populate_materials_for_cad _auto_populate_materials_for_cad(cad_file_id, tenant_id=_tenant_id) except Exception: - logger.exception( - f"Auto material population failed for cad_file {cad_file_id} (non-fatal)" - ) + logger.exception(f"Auto material population failed for cad_file {cad_file_id} (non-fatal)") - # Broadcast WebSocket event for live UI updates + # Broadcast WebSocket event try: - from sqlalchemy import create_engine, select as sql_select2 - from sqlalchemy.orm import Session as _Session - from app.config import settings as _cfg - from app.models.cad_file import CadFile as _CadFile - _sync_url = _cfg.database_url.replace("+asyncpg", "") - _eng = create_engine(_sync_url) - with _Session(_eng) as _s: - set_tenant_context_sync(_s, _tenant_id) - _cad = _s.get(_CadFile, cad_file_id) - _tid = str(_cad.tenant_id) if _cad and _cad.tenant_id else None - _eng.dispose() if _tid: from app.core.websocket import publish_event_sync publish_event_sync(_tid, { @@ -168,12 +142,11 @@ def render_step_thumbnail(self, cad_file_id: str): except Exception: logger.debug("WebSocket publish for CAD complete skipped (non-fatal)") - # Auto-generate geometry GLB so the 3D viewer is ready without manual trigger + # Auto-generate geometry GLB try: from app.domains.pipeline.tasks.export_glb import generate_gltf_geometry_task generate_gltf_geometry_task.delay(cad_file_id) pl.info("render_step_thumbnail", f"Queued generate_gltf_geometry_task for {cad_file_id}") - logger.info("render_step_thumbnail: queued generate_gltf_geometry_task for %s", cad_file_id) except Exception: logger.debug("Could not queue generate_gltf_geometry_task (non-fatal)") diff --git a/frontend/src/components/cad/InlineCadViewer.tsx b/frontend/src/components/cad/InlineCadViewer.tsx index c70ddc5..442f9dc 100644 --- a/frontend/src/components/cad/InlineCadViewer.tsx +++ b/frontend/src/components/cad/InlineCadViewer.tsx @@ -10,7 +10,7 @@ import { listMediaAssets as getMediaAssets } from '../../api/media' import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad' import { useAuthStore } from '../../store/auth' import MaterialPanel, { type IsolateMode } from './MaterialPanel' -import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry } from './cadUtils' +import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils' import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries' type ViewMode = 'solid' | 'wireframe' @@ -196,6 +196,7 @@ export default function InlineCadViewer({ const sceneRef = useRef(null) const controlsRef = useRef(null) const hoveredMeshRef = useRef(null) + const meshRegistryRef = useRef([]) // Media asset queries const { data: gltfAssets } = useQuery({ @@ -265,51 +266,39 @@ export default function InlineCadViewer({ return () => { if (blobUrl) URL.revokeObjectURL(blobUrl) } }, [activeDownloadUrl, token]) - // Apply saved material colors + PBR properties after model loads + // Apply saved material colors + PBR properties after model loads (uses MeshRegistry) useEffect(() => { - if (!modelReady || !sceneRef.current) return - // Wait for PBR map to load — avoids setting grey fallback prematurely + if (!modelReady || meshRegistryRef.current.length === 0) return if (Object.keys(pbrMap).length === 0) return - sceneRef.current.traverse((obj) => { - const mesh = obj as THREE.Mesh - if (!mesh.isMesh) return - const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name)) - const entry = resolvePartMaterial(pk, partMaterials as PartMaterialMap) - if (!entry) return + for (const { mesh, partKey } of meshRegistryRef.current) { + const entry = resolvePartMaterial(partKey, partMaterials as PartMaterialMap) + if (!entry) continue // Clone materials on first PBR application (GLB loader shares instances) if (!mesh.userData._pbrApplied) { mesh.material = Array.isArray(mesh.material) - ? mesh.material.map(m => m.clone()) + ? mesh.material.map((m: THREE.Material) => m.clone()) : mesh.material.clone() mesh.userData._pbrApplied = true } - const clonedMats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] - clonedMats.forEach((m) => { - const mat = m as THREE.MeshStandardMaterial - if (!mat || !('color' in mat)) return + forEachMeshMaterial(mesh, (mat) => { if (entry.type === 'library' && pbrMap[entry.value]) { applyPBRToMaterial(mat, pbrMap[entry.value]) } else { mat.color.set(previewColorForEntry(entry, pbrMap)) } }) - }) + } }, [modelReady, partMaterials, resolvePartKey, pbrMap]) - // Unassigned glow — only when at least one assignment exists + // Unassigned glow — uses MeshRegistry instead of traverse useEffect(() => { - if (!modelReady || !sceneRef.current) return + if (!modelReady || meshRegistryRef.current.length === 0) return const hasAnyAssignment = Object.keys(partMaterials).length > 0 - sceneRef.current.traverse((obj) => { - const mesh = obj as THREE.Mesh - if (!mesh.isMesh) return - const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] - mats.forEach((m) => { - const mat = m as THREE.MeshStandardMaterial - if (!mat || !('emissive' in mat)) return + for (const { mesh, partKey } of meshRegistryRef.current) { + forEachMeshMaterial(mesh, (mat) => { + if (!('emissive' in mat)) return if (showUnassigned && hasAnyAssignment) { - const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name)) - const assigned = !!resolvePartMaterial(pk, partMaterials as PartMaterialMap) + const assigned = !!resolvePartMaterial(partKey, partMaterials as PartMaterialMap) mat.emissive.set(assigned ? 0x000000 : 0xff4400) mat.emissiveIntensity = assigned ? 0 : 0.8 } else { @@ -317,7 +306,7 @@ export default function InlineCadViewer({ mat.emissiveIntensity = 0 } }) - }) + } }, [modelReady, showUnassigned, partMaterials, resolvePartKey]) // Reset isolateMode when no part is pinned @@ -330,54 +319,45 @@ export default function InlineCadViewer({ if (Object.keys(partMaterials).length === 0) setHideAssigned(false) }, [partMaterials]) - // Combined visibility effect — handles hideAssigned + isolateMode together to avoid conflicts + // Combined visibility effect — uses MeshRegistry instead of traverse useEffect(() => { - if (!modelReady || !sceneRef.current) return - sceneRef.current.traverse((obj) => { - const mesh = obj as THREE.Mesh - if (!mesh.isMesh) return - const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name)) - const isSelected = pk === pinnedPart - const isAssigned = !!resolvePartMaterial(pk, partMaterials) - const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + if (!modelReady || meshRegistryRef.current.length === 0) return + for (const { mesh, partKey } of meshRegistryRef.current) { + const isSelected = partKey === pinnedPart + const isAssigned = !!resolvePartMaterial(partKey, partMaterials) // Default: fully visible + raycasting enabled mesh.visible = true mesh.raycast = THREE.Mesh.prototype.raycast - mats.forEach((m) => { - const mat = m as THREE.MeshStandardMaterial - if (mat && 'opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true } + forEachMeshMaterial(mesh, (mat) => { + if ('opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true } }) // hideAssigned: hide all assigned meshes (except the currently selected part) if (hideAssigned && isAssigned && !isSelected) { mesh.visible = false - mesh.raycast = () => {} // prevent R3F from seeing hidden meshes as hit targets - return + mesh.raycast = () => {} + continue } // isolateMode: ghost or hide non-selected meshes when a part is pinned if (!isSelected && pinnedPart && isolateMode !== 'none') { if (isolateMode === 'hide') { mesh.visible = false - mesh.raycast = () => {} // prevent R3F from seeing hidden meshes as hit targets + mesh.raycast = () => {} } else { - mats.forEach((m) => { - const mat = m as THREE.MeshStandardMaterial - if (mat && 'opacity' in mat) { mat.opacity = 0.08; mat.transparent = true; mat.depthWrite = false; mat.needsUpdate = true } + forEachMeshMaterial(mesh, (mat) => { + if ('opacity' in mat) { mat.opacity = 0.08; mat.transparent = true; mat.depthWrite = false; mat.needsUpdate = true } }) } } - }) + } }, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials, resolvePartKey]) // Dev-only: log normalized GLB mesh names vs stored keys to diagnose mismatches useEffect(() => { - if (!import.meta.env.DEV || !modelReady || !sceneRef.current) return - const names = new Set() - sceneRef.current.traverse(o => { - if ((o as THREE.Mesh).isMesh && o.name) names.add(normalizeMeshName((o.userData?.name as string) || o.name)) - }) + if (!import.meta.env.DEV || !modelReady || meshRegistryRef.current.length === 0) return + const names = new Set(meshRegistryRef.current.map(e => e.partKey)) const keys = Object.keys(partMaterials) const matched = keys.filter(k => names.has(k)) const unmatched = keys.filter(k => !names.has(k)) @@ -535,27 +515,28 @@ export default function InlineCadViewer({ const map = glbExtras.partKeyMap as Record | undefined if (map && Object.keys(map).length > 0) { setPartKeyMap(map) - // Propagate partKey from parent Group to child Meshes - sceneRef.current?.traverse((obj) => { - if (!(obj instanceof THREE.Mesh)) return - if (obj.userData.partKey) return - const parentPk = obj.parent?.userData?.partKey as string | undefined - if (parentPk) { obj.userData.partKey = parentPk; return } - const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name) - const pk = map[normalized] ?? normalized - if (pk) obj.userData.partKey = pk - }) } - // Count unique parts by partKey + // Single traverse: stamp partKey, build registry, count unique parts + const registry: MeshRegistryEntry[] = [] const names = new Set() - sceneRef.current?.traverse(o => { - if ((o as THREE.Mesh).isMesh) { - const pk = o.userData?.partKey as string | undefined - if (pk) { names.add(pk); return } - const normalized = normalizeMeshName((o.userData?.name as string) || o.name) - if (normalized) names.add(map?.[normalized] ?? normalized) + sceneRef.current?.traverse((obj) => { + if (!(obj instanceof THREE.Mesh)) return + // Stamp partKey from parent Group or partKeyMap + if (!obj.userData.partKey) { + const parentPk = obj.parent?.userData?.partKey as string | undefined + if (parentPk) { + obj.userData.partKey = parentPk + } else if (map) { + const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name) + obj.userData.partKey = map[normalized] ?? normalized + } } + const pk = (obj.userData?.partKey as string) || + normalizeMeshName((obj.userData?.name as string) || obj.name) + registry.push({ mesh: obj, partKey: pk }) + if (pk) names.add(pk) }) + meshRegistryRef.current = registry setTotalMeshCount(names.size) setGlbMeshNames(new Set(names)) setModelReady(true) diff --git a/frontend/src/components/cad/ThreeDViewer.tsx b/frontend/src/components/cad/ThreeDViewer.tsx index 1b1bb53..76b6e91 100644 --- a/frontend/src/components/cad/ThreeDViewer.tsx +++ b/frontend/src/components/cad/ThreeDViewer.tsx @@ -32,7 +32,7 @@ import { getParsedObjects, getPartMaterials, getManualOverrides, type PartMateri import { fetchSceneManifest } from '../../api/sceneManifest' import { useAuthStore } from '../../store/auth' import MaterialPanel, { type IsolateMode } from './MaterialPanel' -import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry } from './cadUtils' +import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils' import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries' // --------------------------------------------------------------------------- @@ -408,6 +408,10 @@ export default function ThreeDViewer({ const controlsRef = useRef(null) const camPosRef = useRef<[number, number, number]>([0, 0.1, 0.3]) + // MeshRegistry: flat array of {mesh, partKey} built once on model load. + // Replaces per-effect scene.traverse() calls with direct iteration. + const meshRegistryRef = useRef([]) + // Dimension data from parsed_objects const { data: parsedData } = useQuery({ queryKey: ['cad-parsed-objects', cadFileId], @@ -526,8 +530,8 @@ export default function ThreeDViewer({ if (modelReady) setFitTrigger(t => t + 1) }, [modelReady]) - // Compute unique mesh keys once (used in toolbar badge + assignedCount). - // Also extract partKeyMap from GLB extras when available. + // Build MeshRegistry + extract partKeyMap from GLB extras (runs once on model load). + // Single traverse replaces what was previously 2 separate traversals. useEffect(() => { if (!modelReady || !sceneRef.current) return @@ -536,32 +540,33 @@ export default function ThreeDViewer({ const map = glbExtras.partKeyMap as Record | undefined if (map && Object.keys(map).length > 0) { setPartKeyMap(map) - // Stamp userData.partKey on every mesh. Three.js splits multi-primitive - // GLB nodes into Group + child Meshes — the partKey extras land on the - // parent Group, not on individual Mesh objects. We propagate it down. - sceneRef.current.traverse((obj) => { - if (!(obj instanceof THREE.Mesh)) return - if (obj.userData.partKey) return // already set by GLB node extras - // Check parent Group (Three.js multi-primitive split) - const parentPk = obj.parent?.userData?.partKey as string | undefined - if (parentPk) { obj.userData.partKey = parentPk; return } - // Fallback: lookup in partKeyMap by normalized name - const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name) - const pk = map[normalized] ?? normalized - if (pk) obj.userData.partKey = pk - }) } - // Count unique parts by partKey (deduplicated across multi-primitive splits) + // Single traverse: stamp partKey, build registry, count unique parts + const registry: MeshRegistryEntry[] = [] const names = new Set() - sceneRef.current.traverse(o => { - if ((o as THREE.Mesh).isMesh) { - const pk = o.userData?.partKey as string | undefined - if (pk) { names.add(pk); return } - const normalized = normalizeMeshName((o.userData?.name as string) || o.name) - if (normalized) names.add(map?.[normalized] ?? normalized) + sceneRef.current.traverse((obj) => { + if (!(obj instanceof THREE.Mesh)) return + + // Stamp userData.partKey (propagate from parent Group for multi-primitive GLB nodes) + if (!obj.userData.partKey) { + const parentPk = obj.parent?.userData?.partKey as string | undefined + if (parentPk) { + obj.userData.partKey = parentPk + } else if (map) { + const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name) + obj.userData.partKey = map[normalized] ?? normalized + } } + + // Resolve partKey for this mesh + const pk = (obj.userData?.partKey as string) || + normalizeMeshName((obj.userData?.name as string) || obj.name) + + registry.push({ mesh: obj, partKey: pk }) + if (pk) names.add(pk) }) + meshRegistryRef.current = registry setTotalMeshCount(names.size) setGlbMeshNames(new Set(names)) }, [modelReady]) @@ -571,59 +576,48 @@ export default function ThreeDViewer({ if (modelReady) setFitTrigger(t => t + 1) }, [isOrtho]) // eslint-disable-line react-hooks/exhaustive-deps - // Task 6 — apply saved material colors + PBR properties after model loads + // Apply saved material colors + PBR properties after model loads (uses MeshRegistry) useEffect(() => { - if (!modelReady || !sceneRef.current) return + if (!modelReady || meshRegistryRef.current.length === 0) return // Skip when pbrMap hasn't loaded yet — avoid setting grey fallback prematurely if (Object.keys(pbrMap).length === 0) return - sceneRef.current.traverse((obj) => { - const mesh = obj as THREE.Mesh - if (!mesh.isMesh) return - const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name) - const entry = resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials) - if (!entry) return + for (const { mesh, partKey } of meshRegistryRef.current) { + const entry = resolvePartMaterial(partKey, effectiveMaterials) + if (!entry) continue // Clone materials on first PBR application (GLB loader shares instances) if (!mesh.userData._pbrApplied) { mesh.material = Array.isArray(mesh.material) - ? mesh.material.map(m => m.clone()) + ? mesh.material.map((m: THREE.Material) => m.clone()) : mesh.material.clone() mesh.userData._pbrApplied = true } - const clonedMats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] - clonedMats.forEach((m) => { - const mat = m as THREE.MeshStandardMaterial - if (!mat || !('color' in mat)) return + forEachMeshMaterial(mesh, (mat) => { if (entry.type === 'library' && pbrMap[entry.value]) { applyPBRToMaterial(mat, pbrMap[entry.value]) } else { mat.color.set(previewColorForEntry(entry, pbrMap)) } }) - }) + } }, [modelReady, effectiveMaterials, resolvePartKey, pbrMap]) - // Apply/remove unassigned highlight — only glows when ≥1 assignment exists (for meaningful contrast) + // Apply/remove unassigned highlight — uses MeshRegistry instead of traverse useEffect(() => { - if (!modelReady || !sceneRef.current) return + if (!modelReady || meshRegistryRef.current.length === 0) return const hasAnyAssignment = Object.keys(effectiveMaterials).length > 0 - sceneRef.current.traverse((obj) => { - const mesh = obj as THREE.Mesh - if (!mesh.isMesh) return - const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] - mats.forEach((mat) => { - const m = mat as THREE.MeshStandardMaterial - if (!m || !('emissive' in m)) return + for (const { mesh, partKey } of meshRegistryRef.current) { + forEachMeshMaterial(mesh, (mat) => { + if (!('emissive' in mat)) return if (showUnassigned && hasAnyAssignment) { - const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name) - const hasAssignment = !!resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials) - m.emissive.set(hasAssignment ? 0x000000 : 0xff4400) - m.emissiveIntensity = hasAssignment ? 0 : 0.8 + const hasAssignment = !!resolvePartMaterial(partKey, effectiveMaterials) + mat.emissive.set(hasAssignment ? 0x000000 : 0xff4400) + mat.emissiveIntensity = hasAssignment ? 0 : 0.8 } else { - m.emissive.set(0x000000) - m.emissiveIntensity = 0 + mat.emissive.set(0x000000) + mat.emissiveIntensity = 0 } }) - }) + } }, [modelReady, showUnassigned, effectiveMaterials, resolvePartKey]) // Reset isolateMode when no part is pinned @@ -636,46 +630,39 @@ export default function ThreeDViewer({ if (Object.keys(effectiveMaterials).length === 0) setHideAssigned(false) }, [effectiveMaterials]) - // Combined visibility effect — handles hideAssigned + isolateMode together + // Combined visibility effect — uses MeshRegistry instead of traverse useEffect(() => { - if (!modelReady || !sceneRef.current) return - sceneRef.current.traverse((obj) => { - const mesh = obj as THREE.Mesh - if (!mesh.isMesh) return - const normalizedName = normalizeMeshName((mesh.userData?.name as string) || mesh.name) - const partKey = resolvePartKey(normalizedName) + if (!modelReady || meshRegistryRef.current.length === 0) return + for (const { mesh, partKey } of meshRegistryRef.current) { const isSelected = partKey === pinnedPart const isAssigned = !!resolvePartMaterial(partKey, effectiveMaterials) - const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] // Default: fully visible + raycasting enabled mesh.visible = true mesh.raycast = THREE.Mesh.prototype.raycast - mats.forEach((m) => { - const mat = m as THREE.MeshStandardMaterial - if (mat && 'opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true } + forEachMeshMaterial(mesh, (mat) => { + if ('opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true } }) // hideAssigned: hide all assigned meshes (except the currently selected part) if (hideAssigned && isAssigned && !isSelected) { mesh.visible = false - mesh.raycast = () => {} // prevent R3F from seeing hidden meshes as hit targets - return + mesh.raycast = () => {} + continue } // isolateMode: ghost or hide non-selected meshes when a part is pinned if (!isSelected && pinnedPart && isolateMode !== 'none') { if (isolateMode === 'hide') { mesh.visible = false - mesh.raycast = () => {} // prevent R3F from seeing hidden meshes as hit targets + mesh.raycast = () => {} } else { - mats.forEach((m) => { - const mat = m as THREE.MeshStandardMaterial - if (mat && 'opacity' in mat) { mat.opacity = 0.08; mat.transparent = true; mat.depthWrite = false; mat.needsUpdate = true } + forEachMeshMaterial(mesh, (mat) => { + if ('opacity' in mat) { mat.opacity = 0.08; mat.transparent = true; mat.depthWrite = false; mat.needsUpdate = true } }) } } - }) + } }, [modelReady, pinnedPart, isolateMode, hideAssigned, effectiveMaterials, resolvePartKey]) // Keyboard shortcuts diff --git a/frontend/src/components/cad/cadUtils.ts b/frontend/src/components/cad/cadUtils.ts index 1e64be8..95f2bfe 100644 --- a/frontend/src/components/cad/cadUtils.ts +++ b/frontend/src/components/cad/cadUtils.ts @@ -172,3 +172,27 @@ export function previewColorForEntry( } return '#888888' } + +// --------------------------------------------------------------------------- +// MeshRegistry — O(1) access to meshes by partKey, replaces O(n) traversals +// --------------------------------------------------------------------------- + +/** A single entry in the mesh registry, linking a Three.js mesh to its partKey. */ +export interface MeshRegistryEntry { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mesh: any // THREE.Mesh — typed as any to avoid importing THREE + partKey: string +} + +/** + * Iterate all materials on a mesh, calling `fn` for each MeshStandardMaterial. + * Handles both single and array materials safely. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function forEachMeshMaterial(mesh: any, fn: (mat: any) => void): void { + if (!mesh?.material) return + const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + for (const m of mats) { + if (m && 'color' in m) fn(m) + } +} diff --git a/plan.md b/plan.md index 0c74ae3..7a4f88c 100644 --- a/plan.md +++ b/plan.md @@ -1,295 +1,151 @@ -# Plan: Extract PBR Material Properties from Blender Asset Library for 3D Viewer - -> **Date:** 2026-03-13 | **Branch:** refactor/v2 +# Plan: Draw Call Batching + Merge Dual STEP Parse ## Context -The 3D viewer currently shows all materials as flat colors from a hardcoded `SCHAEFFLER_COLORS` map in `MaterialPanel.tsx` (17 entries). These hex colors don't match the actual Blender materials — a "Steel-Bare" material that looks metallic and reflective in Blender renders appears as flat gray `#8a9ca8` in the viewer. The user wants visual parity: if a material is blue plastic in Blender, it should look like blue plastic in the 3D viewer too. +Two independent optimization tracks: -**Source of truth**: The Blender `.blend` asset library already contains all PBR properties (Base Color, Metallic, Roughness, Transmission, IOR) in Principled BSDF nodes for all 35 Schaeffler materials. These values are defined in `MaterialNamingSchema/generate_blend.py`. +**Track A — Draw Call Batching (Frontend):** Assemblies with 100+ parts create 100+ draw calls. Three.js issues one draw call per mesh. For large assemblies this saturates the GPU command buffer and drops frame rate below 30fps. Solution: merge meshes that share the same material into single geometries, togglable via a "Performance mode" button. -**Current flow**: `catalog_assets.py` extracts only material **names** → stored in `AssetLibrary.catalog` JSONB as `{"materials": ["name1", ...]}` → viewer uses hardcoded `SCHAEFFLER_COLORS` hex map. +**Track B — Merge Dual STEP Parse (Backend):** `extract_cad_metadata()` reads the same STEP file twice: +1. `_extract_step_objects()` — `OCC.Core.STEPCAFControl_Reader` → part names (lines 391–425) +2. `extract_mesh_edge_data()` — `OCP.STEPControl.STEPControl_Reader` → tessellates, extracts edge topology + bbox (lines 200–388) -**Target flow**: `catalog_assets.py` extracts PBR properties per material → stored in catalog JSONB → new API endpoint serves PBR map to frontend → viewers apply `MeshStandardMaterial` with correct color + roughness + metalness. +Both readers produce a `TopoDS_Shape`. The XCAF reader (`STEPCAFControl`) gives us both the labeled hierarchy AND the shape, so we can extract edge data from the same read. This eliminates ~0.5–2s of redundant STEP parsing per file. + +**Important constraint for Track B:** `_extract_step_objects` runs on the `worker` container (has `OCC.Core` / pythonocc), while `extract_mesh_edge_data` has dual-import fallback (`OCP` first, then `OCC.Core`). The unified function must work with `OCC.Core` (pythonocc) since that's what the `worker` container has. ## Affected Files -| File | Change | -|------|--------| -| `render-worker/scripts/catalog_assets.py` | Extract PBR properties from Principled BSDF nodes | -| `backend/app/api/routers/asset_libraries.py` | Add public `GET /api/asset-libraries/pbr-map` endpoint | -| `frontend/src/api/assetLibraries.ts` | Add `fetchMaterialPBR()` + `MaterialPBRMap` type | -| `frontend/src/components/cad/cadUtils.ts` | Add `applyPBRToMaterial()` + `pbrColorHex()` helpers | -| `frontend/src/components/cad/ThreeDViewer.tsx` | Fetch PBR map, apply PBR props when assigning materials | -| `frontend/src/components/cad/InlineCadViewer.tsx` | Same PBR application | -| `frontend/src/components/cad/MaterialPanel.tsx` | Replace hardcoded `SCHAEFFLER_COLORS` with dynamic PBR lookup | +| File | Track | Change | +|------|-------|--------| +| `frontend/src/components/cad/useGeometryMerge.ts` | A | NEW — hook for merge/unmerge logic | +| `frontend/src/components/cad/ThreeDViewer.tsx` | A | Add Performance mode toggle + integrate hook | +| `frontend/src/components/cad/InlineCadViewer.tsx` | A | Same Performance mode toggle | +| `frontend/src/components/cad/cadUtils.ts` | A | Add `MergedGroup` type | +| `backend/app/services/step_processor.py` | B | New `extract_step_metadata()`, refactor callers | +| `backend/app/domains/pipeline/tasks/extract_metadata.py` | B | Use new unified function | ## Tasks (in order) -### [x] Task 1: Extend catalog_assets.py to extract PBR properties +--- -- **File**: `render-worker/scripts/catalog_assets.py` -- **What**: After opening the .blend file, for each material with `asset_data`, find the `ShaderNodeBsdfPrincipled` node and extract: - - `base_color`: `[R, G, B]` from `inputs["Base Color"].default_value` — convert linear→sRGB via `v^(1/2.2)` - - `metallic`: float from `inputs["Metallic"].default_value` - - `roughness`: float from `inputs["Roughness"].default_value` - - `transmission`: float from `inputs["Transmission Weight"].default_value` (0.0 if absent) - - `ior`: float from `inputs["IOR"].default_value` (1.45 default) - - Change output format from: - ```json - {"materials": ["Mat1", "Mat2"], "node_groups": [...]} - ``` - to: - ```json - { - "materials": [ - {"name": "Mat1", "base_color": [0.76, 0.77, 0.78], "metallic": 1.0, "roughness": 0.35, "transmission": 0.0, "ior": 1.45}, - ... - ], - "node_groups": [...] - } - ``` - - Fallback for materials without Principled BSDF: `base_color` from `mat.diffuse_color[:3]` (already sRGB), metallic=0.0, roughness=0.5. - - **Color space note**: Blender's Principled BSDF stores Base Color in **linear** space. Three.js `MeshStandardMaterial.color.setRGB()` expects **sRGB** values (it converts internally to linear for rendering). Convert in the script: `srgb = pow(linear, 1/2.2)`, rounded to 4 decimal places. - -- **Acceptance gate**: Rebuilt render-worker, run catalog refresh → JSON output has PBR properties -- **Dependencies**: none -- **Risk**: Complex node graphs (textures etc.) — handled by diffuse_color fallback - -### [x] Task 2: Rebuild render-worker + refresh catalog - -- **File**: No code change — operational step -- **What**: - ```bash - docker compose up -d --build render-worker - # Then POST /api/asset-libraries/{id}/refresh-catalog via Admin UI or curl - ``` - The `AssetLibrary.catalog` JSONB column is schema-free — no migration needed. - -- **Acceptance gate**: Active library's catalog has materials with `base_color`, `metallic`, `roughness` -- **Dependencies**: Task 1 -- **Risk**: None - -### [x] Task 3: Add public API endpoint for material PBR map - -- **File**: `backend/app/api/routers/asset_libraries.py` -- **What**: Add endpoint **before** the `/{lib_id}` route (to avoid path collision): - - ```python - @router.get("/pbr-map") - async def get_material_pbr_map(db: AsyncSession = Depends(get_db)): - """PBR properties for all materials in the active asset library. - Public (no auth) — needed by all 3D viewers. - """ - result = await db.execute( - select(AssetLibrary).where(AssetLibrary.is_active == True).limit(1) - ) - lib = result.scalar_one_or_none() - if not lib or not lib.catalog: - return {} - materials = lib.catalog.get("materials", []) - pbr_map = {} - for m in materials: - if isinstance(m, str): - continue # old format — skip - pbr_map[m["name"]] = { - "base_color": m.get("base_color", [0.5, 0.5, 0.5]), - "metallic": m.get("metallic", 0.0), - "roughness": m.get("roughness", 0.5), - "transmission": m.get("transmission", 0.0), - "ior": m.get("ior", 1.45), - } - return JSONResponse(content=pbr_map, headers={"Cache-Control": "public, max-age=3600"}) - ``` - -- **Acceptance gate**: `curl localhost:8888/api/asset-libraries/pbr-map` returns keyed PBR map -- **Dependencies**: Task 2 -- **Risk**: Must be placed before `/{lib_id}` route or FastAPI will try to parse "pbr-map" as a UUID - -### [x] Task 4: Add frontend API function + types - -- **File**: `frontend/src/api/assetLibraries.ts` -- **What**: - 1. Add types: - ```typescript - export interface MaterialPBR { - base_color: [number, number, number] - metallic: number - roughness: number - transmission?: number - ior?: number - } - export type MaterialPBRMap = Record - ``` - 2. Add fetch function: - ```typescript - export async function fetchMaterialPBR(): Promise { - const { data } = await api.get('/asset-libraries/pbr-map') - return data - } - ``` - 3. Update `AssetLibraryCatalog.materials` type from `string[]` to `Array` for backwards compat with old catalogs - -- **Acceptance gate**: `npx tsc --noEmit` passes -- **Dependencies**: Task 3 -- **Risk**: None - -### [x] Task 5: Add PBR helpers in cadUtils.ts +### Track A — Draw Call Batching +### [ ] Task A1: Add `MergedGroup` type and merge utility to cadUtils.ts - **File**: `frontend/src/components/cad/cadUtils.ts` -- **What**: Add two helpers: +- **What**: Add type `MergedGroup = { mergedMesh: any; sourceEntries: MeshRegistryEntry[]; materialKey: string }`. Add helper `groupRegistryByMaterial(registry: MeshRegistryEntry[], partMaterials: PartMaterialMap, pbrMap: MaterialPBRMap): Map` that groups registry entries by their resolved material name (or `__unassigned__` for parts without material). +- **Acceptance gate**: TypeScript compiles (`tsc --noEmit`). Helper is pure — no side effects, no THREE import. +- **Dependencies**: none +- **Risk**: Low - ```typescript - import type { MaterialPBR } from '../../api/assetLibraries' +### [ ] Task A2: Create `useGeometryMerge` hook +- **File**: NEW `frontend/src/components/cad/useGeometryMerge.ts` +- **What**: Hook that takes `meshRegistryRef`, `partMaterials`, `pbrMap`, and `enabled` flag. When enabled: + 1. Groups meshes by material key (via `groupRegistryByMaterial`) + 2. For each group: calls `BufferGeometryUtils.mergeGeometries()` on all mesh geometries (with world transforms applied via `mesh.matrixWorld`) + 3. Creates one new `THREE.Mesh` per group with the shared material + 4. Hides original meshes (`visible = false`) + 5. Adds merged meshes to the scene + 6. Returns `{ mergedGroups: MergedGroup[], restore: () => void }` — `restore()` removes merged meshes, re-shows originals - /** Apply PBR material properties to a Three.js MeshStandardMaterial. */ - export function applyPBRToMaterial( - mat: THREE.MeshStandardMaterial, - pbr: MaterialPBR, - ): void { - mat.color.setRGB(pbr.base_color[0], pbr.base_color[1], pbr.base_color[2]) - mat.metalness = pbr.metallic - mat.roughness = pbr.roughness - if (pbr.transmission && pbr.transmission > 0.1) { - mat.transparent = true - mat.opacity = 1 - pbr.transmission * 0.7 - } - } + When disabled (or on cleanup): calls `restore()`. - /** Convert PBR base_color to hex string for UI swatches. */ - export function pbrColorHex(pbr: MaterialPBR): string { - const [r, g, b] = pbr.base_color - return '#' + [r, g, b].map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('') - } - ``` - - Note: `THREE` is a type-only import here — the actual THREE namespace is available at runtime in the viewer components. The helper takes the material as a parameter, so no direct THREE import needed in cadUtils. - -- **Acceptance gate**: `npx tsc --noEmit` passes -- **Dependencies**: Task 4 -- **Risk**: None - -### [x] Task 6: Update ThreeDViewer to apply PBR materials + Important: must handle `BufferGeometryUtils` import from `three/examples/jsm/utils/BufferGeometryUtils.js`. +- **Acceptance gate**: TypeScript compiles. Hook can be called with `enabled=false` without errors. +- **Dependencies**: Task A1 +- **Risk**: Medium — `mergeGeometries` requires all geometries to have same attribute layout (position, normal, uv). Some meshes may lack UVs. Must filter or skip incompatible groups. +### [ ] Task A3: Integrate Performance mode in ThreeDViewer - **File**: `frontend/src/components/cad/ThreeDViewer.tsx` - **What**: - 1. Import `fetchMaterialPBR` and `applyPBRToMaterial` from the new modules - 2. Add query: - ```typescript - const { data: pbrMap = {} } = useQuery({ - queryKey: ['material-pbr'], - queryFn: fetchMaterialPBR, - staleTime: 300_000, - }) - ``` - 3. Update the material-application `useEffect` (line ~567). Current code: - ```typescript - if (mat && 'color' in mat) mat.color.set(previewColorForEntry(entry)) - ``` - Replace with: - ```typescript - if (mat && 'color' in mat) { - if (entry.type === 'library' && pbrMap[entry.value]) { - applyPBRToMaterial(mat as THREE.MeshStandardMaterial, pbrMap[entry.value]) - } else { - mat.color.set(previewColorForEntry(entry, pbrMap)) - } - } - ``` - 4. **Important**: Clone materials before modifying. GLB loader shares material instances across meshes. Before the traverse, or inside it, ensure each mesh has its own material: - ```typescript - if (mesh.material) { - mesh.material = Array.isArray(mesh.material) - ? mesh.material.map(m => m.clone()) - : mesh.material.clone() - } - ``` - Only clone once — check a flag like `mesh.userData._pbrApplied` to avoid re-cloning on re-renders. - 5. Add `pbrMap` to the useEffect dependency array - -- **Acceptance gate**: Steel parts look metallic/reflective. Plastic parts look matte. Colors match Blender. -- **Dependencies**: Task 5 -- **Risk**: Material cloning increases memory. Acceptable for viewer scenes. - -### [x] Task 7: Update InlineCadViewer with same PBR logic + 1. Add `perfMode` state (boolean, default false) + 2. Add toolbar button (after wireframe toggle, ~line 771): ` setPerfMode(p => !p)} title="Performance mode — merges geometries, disables per-part hover">` with `Layers` icon from lucide-react + 3. Call `useGeometryMerge({ meshRegistryRef, partMaterials: effectiveMaterials, pbrMap, enabled: perfMode, sceneRef })` + 4. When `perfMode` is true: disable hover handlers (set `onPointerOver`/`onPointerOut`/`onClick` to undefined on the `` element), hide MaterialPanel part list + 5. When `perfMode` is false: restore normal interaction + 6. Show draw call count in toolbar badge: `renderer.info.render.calls` (read from `gl` via `useThree`) +- **Acceptance gate**: Toggle Performance mode → `renderer.info.render.calls` drops to < 20 for 100-part assembly. Toggle back → all hover/select/material interactions work. +- **Dependencies**: Task A2 +- **Risk**: Medium — must ensure merged meshes inherit correct material properties (PBR). Must not break camera fitting (merged meshes have different bounding boxes). +### [ ] Task A4: Integrate Performance mode in InlineCadViewer - **File**: `frontend/src/components/cad/InlineCadViewer.tsx` -- **What**: Mirror Task 6: - 1. Add PBR query - 2. Update material-application useEffect (~line 261) - 3. Clone materials before modifying - 4. Add `pbrMap` to dependency array +- **What**: Same as Task A3 but for the inline viewer. Add `perfMode` toggle button to toolbar (~line 455). Integrate `useGeometryMerge` hook. Disable hover when in perf mode. +- **Acceptance gate**: Same as A3 — draw calls drop, interactions restored on toggle-off. +- **Dependencies**: Task A2 +- **Risk**: Low — same pattern as A3 -- **Acceptance gate**: Inline viewer (product cards) shows PBR materials -- **Dependencies**: Task 5 -- **Risk**: Same as Task 6 +--- -### [x] Task 8: Replace SCHAEFFLER_COLORS with dynamic PBR lookup in MaterialPanel +### Track B — Merge Dual STEP Parse -- **File**: `frontend/src/components/cad/MaterialPanel.tsx` -- **What**: - 1. Delete the hardcoded `SCHAEFFLER_COLORS` map (lines 12-30) - 2. Update `previewColorForEntry()` signature to accept optional `pbrMap`: - ```typescript - export function previewColorForEntry( - entry: PartMaterialEntry, - pbrMap?: MaterialPBRMap, - ): string { - if (entry.type === 'hex') return entry.value - if (pbrMap) { - const pbr = pbrMap[entry.value] - if (pbr) return pbrColorHex(pbr) - } - return '#888888' - } - ``` - 3. Add `pbrMap` as an optional prop to `MaterialPanelProps` - 4. In the material preview swatch area, show metallic/roughness values when PBR data is available: - ```tsx - {pbrEntry && ( - - M:{pbrEntry.metallic.toFixed(1)} R:{pbrEntry.roughness.toFixed(1)} - - )} - ``` - 5. Update all callers of `previewColorForEntry()` in ThreeDViewer and InlineCadViewer to pass `pbrMap` - 6. In the material dropdown, show a color swatch next to each material name using PBR data +### [ ] Task B1: Create `extract_step_metadata()` unified function +- **File**: `backend/app/services/step_processor.py` +- **What**: New function (insert after line 389, before `_extract_step_objects`): + ```python + @dataclass + class StepMetadata: + objects: list[str] # part names from XCAF labels + edge_data: dict # sharp_edge_pairs, suggested_smooth_angle, etc. + dimensions_mm: dict | None # bbox dimensions + bbox_center_mm: dict | None -- **Acceptance gate**: Material panel shows correct preview colors from Blender. No hardcoded `SCHAEFFLER_COLORS`. -- **Dependencies**: Tasks 6, 7 -- **Risk**: Low — UI-only change + def extract_step_metadata(step_path: str) -> StepMetadata: + ``` -### [x] Task 9: TypeScript compilation + visual verification + Implementation approach: + 1. Read STEP once with `STEPCAFControl_Reader` (same as `_extract_step_objects`) + 2. Extract part names from XCAF labels (same logic as current `_extract_step_objects`) + 3. Get root shape via `shape_tool.GetShape(label)` for each free label + 4. Tessellate at 0.5mm deflection via `BRepMesh_IncrementalMesh` + 5. Extract edge topology from the tessellated shape (same logic as current `extract_mesh_edge_data` lines 265–382, but operating on the already-loaded shape instead of re-reading) + 6. Extract bbox from the same shape + 7. Return `StepMetadata` dataclass -- **What**: - 1. `docker compose exec frontend npx tsc --noEmit` — 0 errors - 2. Open http://localhost:5173/products/{id} — verify steel parts look metallic, plastics look matte -- **Acceptance gate**: Zero type errors. Visual match with Blender appearance. -- **Dependencies**: Tasks 1-8 + Must handle both `OCC.Core` (pythonocc) and `OCP` (cadquery) import paths, same as existing code. + + **Keep `_extract_step_objects` and `extract_mesh_edge_data` unchanged** as fallbacks. +- **Acceptance gate**: `python3 -c "import ast; ast.parse(open('backend/app/services/step_processor.py').read())"` passes. New function returns same data as the two separate calls combined. +- **Dependencies**: none +- **Risk**: Medium — the edge extraction logic references `STEPControl_Reader`-specific APIs (`reader.TransferRoots()`, `reader.OneShape()`). With `STEPCAFControl_Reader`, the shape comes from `shape_tool.GetShape(label)` instead. The edge extraction code uses `TopTools_IndexedDataMapOfShapeListOfShape` on the root shape — this should work identically on an XCAF-sourced shape since it's the same `TopoDS_Shape` underneath. Must verify the `_using_ocp` vs `OCC.Core` static method dispatch (`_s` suffix) still works. + +### [ ] Task B2: Wire `extract_step_metadata()` into `extract_cad_metadata()` +- **File**: `backend/app/services/step_processor.py` +- **What**: Modify `extract_cad_metadata()` (line 82) to: + 1. Try `extract_step_metadata()` first (single read) + 2. If it succeeds: use `metadata.objects` for `parsed_objects`, `metadata.edge_data` for `mesh_attributes` + 3. If it fails (fallback): call `_extract_step_objects()` + `extract_mesh_edge_data()` separately (existing behavior) + 4. Log which path was taken: `"[STEP] unified read: X objects, Y sharp pairs"` vs `"[STEP] fallback: separate reads"` +- **Acceptance gate**: Upload a STEP file → worker log shows single "unified read" message. `parsed_objects` and `mesh_attributes` populated correctly. +- **Dependencies**: Task B1 +- **Risk**: Low — fallback preserves existing behavior + +### [ ] Task B3: Also wire into `process_cad_file()` (legacy path) +- **File**: `backend/app/services/step_processor.py` +- **What**: Same change as B2 but for `process_cad_file()` (line 137) which is the legacy full-pipeline function. Try unified read first, fall back to separate reads. +- **Acceptance gate**: `process_cad_file()` still works end-to-end (upload STEP → metadata + thumbnail). +- **Dependencies**: Task B1 +- **Risk**: Low ## Migration Check -**No migration required.** `AssetLibrary.catalog` is JSONB (schema-free). The new format (materials as objects instead of strings) is a data-level change only. +No migration required. All changes are code-level optimizations. ## Order Recommendation -1. Render worker script (`catalog_assets.py`) + rebuild — Tasks 1-2 -2. Backend API endpoint — Task 3 -3. Frontend types + helpers — Tasks 4-5 -4. Viewers + MaterialPanel — Tasks 6, 7, 8 (can be parallel) -5. Final check — Task 9 +Track A and Track B are fully independent — implement in parallel. + +Within Track A: A1 → A2 → A3 + A4 (A3 and A4 can be parallel) +Within Track B: B1 → B2 + B3 (B2 and B3 can be parallel) ## Risks / Open Questions -1. **Color space**: Blender stores linear colors. Three.js `color.setRGB()` expects sRGB. Converting in `catalog_assets.py` with `pow(v, 1/2.2)` ensures correctness in both the hex UI preview and the Three.js renderer. +1. **BufferGeometryUtils.mergeGeometries compatibility**: All geometries in a merge group must have identical attribute sets (position, normal, uv). Meshes without UVs can't merge with UV-bearing meshes. The hook must detect this and skip incompatible groups (leave them as individual meshes). -2. **Shared materials in GLB**: Three.js GLB loader shares material instances. Must clone before modifying metalness/roughness. Check `userData._pbrApplied` flag to avoid redundant cloning. +2. **Camera fitting in Performance mode**: `CameraFit` component likely uses scene bounding box. Merged meshes may have different world-space bounds than originals if transforms aren't baked correctly. Must apply `mesh.matrixWorld` to geometry before merging. -3. **Backwards compatibility**: Old catalog format (`materials: string[]`) is handled — the API endpoint skips string entries. Frontend `AssetLibraryCatalog` type uses union. +3. **OCC.Core API differences**: pythonocc (`OCC.Core`) uses different method naming than OCP (no `_s` suffix for static methods). The unified function must handle both, same as `extract_mesh_edge_data` currently does. -4. **Complex node graphs**: Materials with textures instead of simple default values get `diffuse_color` fallback. Texture support is out of scope. +4. **Edge extraction on XCAF shape**: `extract_mesh_edge_data` calls `reader.OneShape()` which returns a single compound. From XCAF, `shape_tool.GetShape(label)` returns the shape for each free label. For multi-root STEP files (rare), we need to iterate all free labels and combine edge data. This matches the pattern already used in `export_step_to_gltf.py` (line 696–700). -5. **`previewColorForEntry` callers**: This function is exported and used in both viewers. Adding the optional `pbrMap` parameter is backwards-compatible — existing callers without it still get gray fallback. +5. **Memory**: `mergeGeometries` creates new geometry buffers. For 100 parts × 50K triangles each = 5M triangles in merged buffers + 5M in originals (hidden but not disposed). May need to dispose original geometries in Performance mode and recreate on restore. This adds complexity — defer disposal to a follow-up if memory isn't an issue. diff --git a/render-worker/scripts/_blender_materials.py b/render-worker/scripts/_blender_materials.py index 15d8d74..13f907d 100644 --- a/render-worker/scripts/_blender_materials.py +++ b/render-worker/scripts/_blender_materials.py @@ -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) diff --git a/render-worker/scripts/_blender_scene.py b/render-worker/scripts/_blender_scene.py index 654f725..b4586c6 100644 --- a/render-worker/scripts/_blender_scene.py +++ b/render-worker/scripts/_blender_scene.py @@ -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: diff --git a/render-worker/scripts/export_step_to_gltf.py b/render-worker/scripts/export_step_to_gltf.py index 498717a..2d41375 100644 --- a/render-worker/scripts/export_step_to_gltf.py +++ b/render-worker/scripts/export_step_to_gltf.py @@ -647,6 +647,9 @@ def main() -> None: ) # Step 2: GMSH override for SOLID shapes (better seam topology) + # Batch all eligible solids into a single compound and tessellate in one + # GMSH session — avoids N × (gmsh init + brep write + brep read + finalize) + # overhead. GMSH's internal OpenMP threading parallelizes across surfaces. _seen_shapes: list = [] # shapes already GMSH-tessellated; compared via IsSame() solids = [] @@ -661,6 +664,10 @@ def main() -> None: solids.append(exp.Current()) exp.Next() + from OCP.TopoDS import TopoDS_Compound as _Compound + from OCP.BRep import BRep_Builder as _BBuilder + + eligible = [] for solid in solids: # Skip REVERSED (mirrored) solids — keep BRepMesh tessellation. # GMSH produces inverted-Jacobian meshes for negative-scale shapes. @@ -673,9 +680,19 @@ def main() -> None: continue # Strip location: GMSH tessellates in definition space. # The XCAF writer applies instance transforms at GLB export time. - solid_def = solid.Located(_TopLoc_Location()) - _tessellate_with_gmsh(solid_def, args.linear_deflection, args.angular_deflection) + eligible.append(solid.Located(_TopLoc_Location())) _seen_shapes.append(solid) + + if eligible: + if len(eligible) == 1: + _tessellate_with_gmsh(eligible[0], args.linear_deflection, args.angular_deflection) + else: + compound = _Compound() + bb = _BBuilder() + bb.MakeCompound(compound) + for s in eligible: + bb.Add(compound, s) + _tessellate_with_gmsh(compound, args.linear_deflection, args.angular_deflection) else: for i in range(1, free_labels.Length() + 1): shape = shape_tool.GetShape_s(free_labels.Value(i))