From 95cfe0aa931bde0d6d4ada2e1c2bfcb517d2262c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 7 Mar 2026 16:49:18 +0100 Subject: [PATCH] =?UTF-8?q?refactor:=20replace=20STL=20intermediary=20with?= =?UTF-8?q?=20OCC-native=20STEP=E2=86=92GLB=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export_step_to_gltf.py: STEP→GLB via RWGltf_CafWriter + BRepBuilderAPI_Transform (mm→m pre-scaling, XCAFDoc_ShapeTool.GetComponents_s static method) - Blender scripts (blender_render.py, still_render.py, turntable_render.py, export_gltf.py, export_blend.py): import GLB instead of STL, remove _scale_mm_to_m - step_tasks.py: add generate_gltf_production_task, remove generate_stl_cache, replace _bbox_from_stl with _bbox_from_glb (trimesh), auto-queue geometry GLB after thumbnail render - render_blender.py: replace _stl_from_cache_or_convert with _glb_from_step, remove convert_step_to_stl and export_per_part_stls - domains/rendering/tasks.py: update render_turntable_task, export_gltf/blend tasks to use GLB instead of STL - cad.py: remove STL download/generate endpoints, add generate-gltf-production - admin.py: generate-missing-stls → generate-missing-geometry-glbs - Frontend: replace STL cache UI with GLB generate buttons, remove stl_cached field Co-Authored-By: Claude Sonnet 4.6 --- LEARNINGS.md | 31 ++ backend/app/api/routers/admin.py | 28 +- backend/app/api/routers/cad.py | 241 ++--------- backend/app/api/routers/products.py | 11 - backend/app/domains/products/schemas.py | 1 - backend/app/domains/rendering/tasks.py | 171 +++----- backend/app/services/render_blender.py | 243 +++-------- backend/app/tasks/step_tasks.py | 387 ++++++++++-------- frontend/src/api/cad.ts | 45 +- frontend/src/api/products.ts | 1 - .../src/components/cad/InlineCadViewer.tsx | 4 +- frontend/src/pages/Admin.tsx | 44 -- frontend/src/pages/CadPreview.tsx | 6 +- frontend/src/pages/ProductDetail.tsx | 224 ++++------ render-worker/scripts/blender_render.py | 115 ++---- render-worker/scripts/export_blend.py | 19 +- render-worker/scripts/export_gltf.py | 81 +--- render-worker/scripts/export_step_to_gltf.py | 232 +++++++++++ render-worker/scripts/still_render.py | 114 +----- render-worker/scripts/turntable_render.py | 112 +---- 20 files changed, 809 insertions(+), 1301 deletions(-) create mode 100644 render-worker/scripts/export_step_to_gltf.py diff --git a/LEARNINGS.md b/LEARNINGS.md index 38f07f9..2160b08 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -27,6 +27,37 @@ **Lösung**: `_normalize_key()` Helper: strippt `UPLOAD_DIR`-Prefix. In `download_asset` Legacy-Remapping für alte Pfade als Fallback behalten. Neue Assets immer relativ speichern. **Für künftige Projekte**: `storage_key` immer relativ zu `UPLOAD_DIR` → `candidate = Path(settings.upload_dir) / key`. Absolute Pfade nie in die DB schreiben. +### 2026-03-07 | OCP | `RWMesh_CoordinateSystemConverter` nicht als Python-Binding verfügbar (OCP 7.8.1.1) +**Problem**: `writer.ChangeCoordinateSystemConverter()` wirft `TypeError: Unregistered type : RWMesh_CoordinateSystemConverter` — der C++-Typ ist nicht in OCP-Python-Bindings registriert. +**Lösung**: Shapes vor dem Export mit `BRepBuilderAPI_Transform` um Faktor 0.001 skalieren (mm→m). Dann `RWGltf_CafWriter` direkt ohne Koordinatensystem-Konverter aufrufen. +```python +trsf = gp_Trsf() +trsf.SetScaleFactor(0.001) +for i in range(1, free_labels.Length() + 1): + label = free_labels.Value(i) + scaled = BRepBuilderAPI_Transform(shape_tool.GetShape_s(label), trsf, True).Shape() + shape_tool.SetShape(label, scaled) +``` +**Für künftige Projekte**: Immer OCP-Methoden-Verfügbarkeit mit `hasattr()` oder `dir()` testen bevor man sie aufruft. + +### 2026-03-07 | OCP | `XCAFDoc_ShapeTool.GetComponents` → `GetComponents_s` (static method suffix) +**Problem**: `shape_tool.GetComponents(label, seq)` → `AttributeError: 'XCAFDoc_ShapeTool' has no attribute 'GetComponents'` +**Lösung**: In OCP sind alle XCAF static-Methoden mit `_s`-Suffix: `XCAFDoc_ShapeTool.GetComponents_s(label, seq)`. Gilt für alle `XCAFDoc_*`-Klassen. + +### 2026-03-07 | Pipeline | OCC-native STEP→GLB ersetzt STL-Intermediary komplett +**Problem**: Pipeline nutzte STL als Zwischenformat (STEP→STL via cadquery, STL→Blender). STL verliert Materialnamen, Farben, OCC-Topologie. Zwei parallele Konvertierungen (cadquery STL + Blender Import) = doppelter Aufwand. STL-Cache auf Disk = Dateiflut. +**Lösung**: `export_step_to_gltf.py` nutzt OCC `RWGltf_CafWriter` direkt: STEP→GLB in einem Schritt. Koordinatensystem-Konvertierung (Z-up→Y-up) + mm→m Skalierung übernimmt `conv.SetInputLengthUnit(1e-3)`. Blender importiert GLB nativ via `bpy.ops.import_scene.gltf()` — kein `_scale_mm_to_m` mehr nötig. +**OCC-API-Gotcha**: Projekt nutzt `OCP` (cadquery's pythonocc-Bindings), NICHT `OCC.Core`. Statische Methoden haben `_s`-Suffix: `XCAFApp_Application.GetApplication_s()`, `XCAFDoc_DocumentTool.ShapeTool_s()`. +**Für künftige Projekte**: `RWGltf_CafWriter` + `STEPCAFControl_Reader` sind die kanonische STEP→GLB Pipeline. `BRepMesh_IncrementalMesh` tesselliert vor dem Export. `XCAFDoc_ColorTool.SetColor(label, color, XCAFDoc_ColorSurf)` überträgt Farben in GLB-Materialien. + +### 2026-03-07 | Celery | generate_gltf_geometry_task als Subprocess — kein bpy-Import-Konflikt +**Problem**: OCP und bpy können nicht im selben Python-Prozess koexistieren — OCP lädt native C++-Bibliotheken die mit Blenders internen Versionen kollidieren. Der render-worker-Container hat BEIDE (cadquery + Blender). +**Lösung**: `export_step_to_gltf.py` via `sys.executable` subprocess aus Celery-Task heraus starten. So läuft OCC in isoliertem Prozess, kein Import-Pollution. Pattern: `subprocess.run([sys.executable, script, "--arg", val], timeout=120)`. + +### 2026-03-07 | Bbox | GLB statt STL für Bounding-Box Extraktion +**Problem**: `_bbox_from_stl()` nutzte numpy binary parsing des STL-Headers (schnell, aber STL existiert nicht mehr nach Pipeline-Umbau). +**Lösung**: `_bbox_from_glb()` mit trimesh: `scene = trimesh.load(glb_path, force="scene"); bounds = scene.bounds`. GLB ist in Metern → `* 1000` für mm. Fallback auf `_bbox_from_step_cadquery()` bleibt erhalten. + ### 2026-03-07 | Workflow | Turntable-Workflow brauchte step_path zur Laufzeit **Problem**: `WorkflowDefinition.config` ist statisch (JSON) — enthält keine produktspezifischen Pfade. `_build_turntable()` erwartet `step_path` + `output_dir` in params → `ValueError` bei Workflow-Dispatch. **Lösung**: `dispatch_render_with_workflow()` löst `step_path` + `output_dir` aus dem `OrderLine → Product → CadFile` Graph auf und injiziert sie in params vor `dispatch_workflow()`. diff --git a/backend/app/api/routers/admin.py b/backend/app/api/routers/admin.py index f362dac..636ebc8 100644 --- a/backend/app/api/routers/admin.py +++ b/backend/app/api/routers/admin.py @@ -438,30 +438,36 @@ async def reextract_all_metadata( return {"queued": queued, "message": f"Queued {queued} CAD file(s) for metadata re-extraction"} -@router.post("/settings/generate-missing-stls", status_code=status.HTTP_202_ACCEPTED) -async def generate_missing_stls( +@router.post("/settings/generate-missing-geometry-glbs", status_code=status.HTTP_202_ACCEPTED) +async def generate_missing_geometry_glbs( admin: User = Depends(require_admin), db: AsyncSession = Depends(get_db), ): - """Queue STL generation for every quality missing from each completed CAD file.""" - from pathlib import Path as _Path + """Queue geometry GLB generation for every completed CAD file that has no gltf_geometry MediaAsset.""" + import uuid as _uuid + from app.domains.media.models import MediaAsset, MediaAssetType + result = await db.execute( select(CadFile).where(CadFile.processing_status == ProcessingStatus.completed) ) cad_files = result.scalars().all() - from app.tasks.step_tasks import generate_stl_cache + # Bulk-fetch existing gltf_geometry assets + existing_result = await db.execute( + select(MediaAsset.cad_file_id).where(MediaAsset.asset_type == MediaAssetType.gltf_geometry) + ) + existing_ids = {row[0] for row in existing_result.all()} + + from app.tasks.step_tasks import generate_gltf_geometry_task queued = 0 for cad_file in cad_files: if not cad_file.stored_path: continue - step = _Path(cad_file.stored_path) - for quality in ("low", "high"): - if not (step.parent / f"{step.stem}_{quality}.stl").exists(): - generate_stl_cache.delay(str(cad_file.id), quality) - queued += 1 + if cad_file.id not in existing_ids: + generate_gltf_geometry_task.delay(str(cad_file.id)) + queued += 1 - return {"queued": queued, "message": f"Queued {queued} missing STL generation task(s)"} + return {"queued": queued, "message": f"Queued {queued} missing geometry GLB task(s)"} @router.post("/settings/seed-workflows", status_code=status.HTTP_200_OK) diff --git a/backend/app/api/routers/cad.py b/backend/app/api/routers/cad.py index 0d77694..7493e65 100644 --- a/backend/app/api/routers/cad.py +++ b/backend/app/api/routers/cad.py @@ -262,78 +262,16 @@ async def get_objects( } -@router.get("/{id}/stl/{quality}") -async def download_stl( - id: uuid.UUID, - quality: str, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Download the cached STL for a CAD file with a human-readable filename. - - The STL is cached next to the STEP file on first render. - quality must be 'low' or 'high'. - """ - if quality not in ("low", "high"): - raise HTTPException(400, detail="quality must be 'low' or 'high'") - - cad = await _get_cad_file(id, db) - - if not cad.stored_path: - raise HTTPException(404, detail="STEP file not uploaded for this CAD file") - - step_path = Path(cad.stored_path) - stl_path = step_path.parent / f"{step_path.stem}_{quality}.stl" - - if not stl_path.exists(): - raise HTTPException( - 404, - detail=f"STL cache not found for quality '{quality}'. Trigger a render first to generate it.", - ) - - original_stem = Path(cad.original_name or "model").stem - filename = f"{original_stem}_{quality}.stl" - - return FileResponse( - path=str(stl_path), - media_type="application/octet-stream", - filename=filename, - ) - - -@router.post("/{id}/generate-stl/{quality}", status_code=status.HTTP_202_ACCEPTED) -async def generate_stl( - id: uuid.UUID, - quality: str, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Queue STL generation for the given quality without triggering a full render.""" - if user.role.value not in ("admin", "project_manager"): - raise HTTPException(status_code=403, detail="Insufficient permissions") - if quality not in ("low", "high"): - raise HTTPException(status_code=400, detail="quality must be 'low' or 'high'") - - cad = await _get_cad_file(id, db) - - if not cad.stored_path: - raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file") - - from app.tasks.step_tasks import generate_stl_cache - task = generate_stl_cache.delay(str(id), quality) - return {"status": "queued", "task_id": task.id, "quality": quality} - - @router.post("/{id}/generate-gltf-geometry", status_code=status.HTTP_202_ACCEPTED) async def generate_gltf_geometry( id: uuid.UUID, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Queue GLB geometry export from the existing STL cache (trimesh, no Blender). + """Queue GLB geometry export directly from STEP via OCC (no STL required). Stores the result as a MediaAsset with asset_type='gltf_geometry'. - The STL low-quality cache must already exist (run a thumbnail render first). + Uses export_step_to_gltf.py (OCP/pythonocc) — no Blender needed. """ if user.role.value not in ("admin", "project_manager"): raise HTTPException(status_code=403, detail="Insufficient permissions") @@ -342,20 +280,34 @@ async def generate_gltf_geometry( if not cad.stored_path: raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file") - step_path = Path(cad.stored_path) - stl_path = step_path.parent / f"{step_path.stem}_low.stl" - if not stl_path.exists(): - raise HTTPException( - status_code=404, - detail="STL low-quality cache not found. Trigger a render first to generate it.", - ) - - # Queue as a thumbnail_rendering task (trimesh available in render-worker) from app.tasks.step_tasks import generate_gltf_geometry_task task = generate_gltf_geometry_task.delay(str(id)) return {"status": "queued", "task_id": task.id, "cad_file_id": str(id)} +@router.post("/{id}/generate-gltf-production", status_code=status.HTTP_202_ACCEPTED) +async def generate_gltf_production( + id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Queue production GLB export (Blender + PBR materials) from a geometry GLB. + + Requires a gltf_geometry MediaAsset to already exist (run generate-gltf-geometry first). + Stores result as a MediaAsset with asset_type='gltf_production'. + """ + if user.role.value not in ("admin", "project_manager"): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + cad = await _get_cad_file(id, db) + if not cad.stored_path: + raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file") + + from app.tasks.step_tasks import generate_gltf_production_task + task = generate_gltf_production_task.delay(str(id)) + return {"status": "queued", "task_id": task.id, "cad_file_id": str(id)} + + @router.post( "/{id}/regenerate-thumbnail", status_code=status.HTTP_202_ACCEPTED, @@ -396,146 +348,3 @@ async def regenerate_thumbnail( } -@router.get("/{id}/export-gltf-colored") -async def export_gltf_colored( - id: uuid.UUID, - user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Export a GLB with PBR colors from part_colors (material alias mapping). - - Loads per-part STLs from the low-quality parts cache directory and applies - PBR materials based on the product's cad_part_materials color assignments. - Falls back to the combined STL with a single grey material. - """ - from fastapi.responses import Response - from sqlalchemy import text, select - import trimesh - import io - - if user.role.value not in ("admin", "project_manager"): - raise HTTPException(status_code=403, detail="Insufficient permissions") - - # Bypass RLS for cad_files + products - await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'")) - cad = await _get_cad_file(id, db) - - if not cad.stored_path: - raise HTTPException(404, detail="STEP file not uploaded") - - step_path = Path(cad.stored_path) - stl_path = step_path.parent / f"{step_path.stem}_low.stl" - parts_dir = step_path.parent / f"{step_path.stem}_low_parts" - - if not stl_path.exists(): - raise HTTPException(404, detail="STL cache not found. Trigger a render first.") - - # Load settings - from app.models.system_setting import SystemSetting - settings_result = await db.execute( - select(SystemSetting.key, SystemSetting.value).where( - SystemSetting.key.in_([ - "gltf_scale_factor", "gltf_smooth_normals", - "gltf_pbr_roughness", "gltf_pbr_metallic", - ]) - ) - ) - raw_settings = {k: v for k, v in settings_result.all()} - scale = float(raw_settings.get("gltf_scale_factor", "0.001")) - smooth = raw_settings.get("gltf_smooth_normals", "true") == "true" - roughness = float(raw_settings.get("gltf_pbr_roughness", "0.4")) - metallic = float(raw_settings.get("gltf_pbr_metallic", "0.6")) - - # Load part colors from product - from app.domains.products.models import Product - part_colors: dict[str, str] = {} - if cad.id: - prod_result = await db.execute( - select(Product).where(Product.cad_file_id == cad.id).limit(1) - ) - product = prod_result.scalar_one_or_none() - if product and product.cad_part_materials: - for entry in product.cad_part_materials: - part_name = entry.get("part_name") or entry.get("name", "") - hex_color = entry.get("hex_color") or entry.get("color", "") - if part_name and hex_color: - part_colors[part_name] = hex_color - - def _hex_to_rgba(h: str) -> list: - h = h.lstrip("#") - if len(h) < 6: - return [0.7, 0.7, 0.7, 1.0] - try: - return [int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4)] + [1.0] - except Exception: - return [0.7, 0.7, 0.7, 1.0] - - def _make_material(hex_color: str | None = None): - rgba = _hex_to_rgba(hex_color) if hex_color else [0.7, 0.7, 0.7, 1.0] - return trimesh.visual.material.PBRMaterial( - baseColorFactor=rgba, - roughnessFactor=roughness, - metallicFactor=metallic, - ) - - def _apply_mesh(mesh, color=None): - mesh.apply_scale(scale) - if smooth: - try: - trimesh.smoothing.filter_laplacian(mesh, lamb=0.5, iterations=5) - except Exception: - pass - mesh.visual = trimesh.visual.TextureVisuals(material=_make_material(color)) - return mesh - - # Try per-part STLs first - scene = trimesh.Scene() - used_parts = False - - if parts_dir.exists() and part_colors: - for part_name, hex_color in part_colors.items(): - # Sanitize part name for filesystem - safe_name = part_name.replace("/", "_").replace("\\", "_") - part_stl = parts_dir / f"{safe_name}.stl" - if not part_stl.exists(): - # Try lowercase / partial match - candidates = list(parts_dir.glob(f"{safe_name}*.stl")) - if not candidates: - candidates = list(parts_dir.glob("*.stl")) - candidates = [c for c in candidates if safe_name.lower() in c.stem.lower()] - if candidates: - part_stl = candidates[0] - else: - continue - try: - m = trimesh.load(str(part_stl), force="mesh") - _apply_mesh(m, hex_color) - scene.add_geometry(m, geom_name=part_name) - used_parts = True - except Exception: - pass - - if not used_parts: - # Fallback: combined STL, single color - combined = trimesh.load(str(stl_path)) - if hasattr(combined, 'geometry'): - for name, m in combined.geometry.items(): - _apply_mesh(m, next(iter(part_colors.values()), None)) - scene.add_geometry(m, geom_name=name) - else: - _apply_mesh(combined, next(iter(part_colors.values()), None)) - scene.add_geometry(combined) - - # Export to bytes - buf = io.BytesIO() - scene.export(buf, file_type="glb") - glb_bytes = buf.getvalue() - - original_stem = Path(cad.original_name or "model").stem - filename = f"{original_stem}_colored.glb" - - return Response( - content=glb_bytes, - media_type="model/gltf-binary", - headers={"Content-Disposition": f"attachment; filename={filename}"}, - ) diff --git a/backend/app/api/routers/products.py b/backend/app/api/routers/products.py index 0259458..a11b971 100644 --- a/backend/app/api/routers/products.py +++ b/backend/app/api/routers/products.py @@ -77,20 +77,9 @@ def _product_out(product: Product, priority: list[str] | None = None) -> Product out.cad_parsed_objects = product.cad_parsed_objects out.cad_mesh_attributes = product.cad_file.mesh_attributes if product.cad_file else None out.render_image_url = _best_render_url(product, priority or ["latest_render", "cad_thumbnail"]) - out.stl_cached = _stl_cached_qualities(product) return out -def _stl_cached_qualities(product: Product) -> list[str]: - """Return list of STL qualities that are cached on disk for this product.""" - from pathlib import Path as _Path - cad = product.cad_file - if not cad or not cad.stored_path: - return [] - step = _Path(cad.stored_path) - return [q for q in ("low", "high") if (step.parent / f"{step.stem}_{q}.stl").exists()] - - async def _load_thumbnail_priority(db: AsyncSession) -> list[str]: """Read product_thumbnail_priority from system_settings. diff --git a/backend/app/domains/products/schemas.py b/backend/app/domains/products/schemas.py index bd7bc86..e8f9c3d 100644 --- a/backend/app/domains/products/schemas.py +++ b/backend/app/domains/products/schemas.py @@ -59,7 +59,6 @@ class ProductOut(BaseModel): thumbnail_url: str | None = None render_image_url: str | None = None processing_status: str | None = None - stl_cached: list[str] = [] cad_parsed_objects: list[str] | None = None cad_mesh_attributes: dict | None = None arbeitspaket: str | None = None diff --git a/backend/app/domains/rendering/tasks.py b/backend/app/domains/rendering/tasks.py index 81a6212..05128b1 100644 --- a/backend/app/domains/rendering/tasks.py +++ b/backend/app/domains/rendering/tasks.py @@ -193,9 +193,8 @@ def render_turntable_task( import os import shutil import subprocess - from app.services.render_blender import ( - find_blender, convert_step_to_stl, export_per_part_stls - ) + import sys + from app.services.render_blender import find_blender blender_bin = find_blender() if not blender_bin: @@ -208,27 +207,25 @@ def render_turntable_task( scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) turntable_script = scripts_dir / "turntable_render.py" - # STL conversion — try MinIO cache first, then convert locally - stl_path = step.parent / f"{step.stem}_{stl_quality}.stl" - if not stl_path.exists() or stl_path.stat().st_size == 0: - try: - from app.domains.products.cache_service import compute_step_hash, check_stl_cache - step_hash = compute_step_hash(str(step)) - cached = check_stl_cache(step_hash, stl_quality) - if cached: - stl_path.write_bytes(cached) - logger.info("STL restored from MinIO cache: %s", stl_path.name) - else: - convert_step_to_stl(step, stl_path, stl_quality) - except Exception as exc: - logger.warning("MinIO cache check failed (non-fatal): %s — falling back to conversion", exc) - convert_step_to_stl(step, stl_path, stl_quality) - parts_dir = step.parent / f"{step.stem}_{stl_quality}_parts" - if not (parts_dir / "manifest.json").exists(): - try: - export_per_part_stls(step, parts_dir, stl_quality) - except Exception as exc: - logger.warning("per-part export non-fatal: %s", exc) + # GLB generation via OCC (replaces STL intermediary) + linear_deflection = 0.3 if stl_quality == "low" else 0.05 + angular_deflection = 0.3 if stl_quality == "low" else 0.1 + glb_path = step.parent / f"{step.stem}_{stl_quality}.glb" + if not glb_path.exists() or glb_path.stat().st_size == 0: + occ_script = scripts_dir / "export_step_to_gltf.py" + occ_cmd = [ + sys.executable, str(occ_script), + "--step_path", str(step), + "--output_path", str(glb_path), + "--linear_deflection", str(linear_deflection), + "--angular_deflection", str(angular_deflection), + ] + occ_result = subprocess.run(occ_cmd, capture_output=True, text=True, timeout=120) + if occ_result.returncode != 0: + raise RuntimeError( + f"export_step_to_gltf.py failed:\n{occ_result.stderr[-500:]}" + ) + logger.info("render_turntable_task: GLB generated: %s", glb_path.name) # Build turntable render arguments frames_dir = out_dir / "frames" @@ -238,7 +235,7 @@ def render_turntable_task( blender_bin, "--background", "--python", str(turntable_script), "--", - str(stl_path), + str(glb_path), str(frames_dir), output_name, str(width), str(height), @@ -463,93 +460,40 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict: max_retries=1, ) def export_gltf_for_order_line_task(self, order_line_id: str) -> dict: - """Export a GLB from the STL cache via Blender subprocess (with trimesh fallback). + """Export a geometry GLB directly from STEP via OCC (no STL intermediary). - Publishes a MediaAsset with asset_type='gltf_geometry' (no asset lib) or - 'gltf_production' (when an asset library is applied). - Requires the STL low-quality cache to exist. + Publishes a MediaAsset with asset_type='gltf_geometry'. """ - import json import os import subprocess + import sys step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id) if not step_path_str: raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}") step = Path(step_path_str) - stl_path = step.parent / f"{step.stem}_low.stl" - if not stl_path.exists(): - raise RuntimeError( - f"STL cache not found: {stl_path}. Run thumbnail generation first." - ) - output_path = step.parent / f"{step.stem}_geometry.glb" scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) - export_script = scripts_dir / "export_gltf.py" + occ_script = scripts_dir / "export_step_to_gltf.py" - from app.services.render_blender import find_blender, is_blender_available + if not occ_script.exists(): + raise RuntimeError(f"export_step_to_gltf.py not found at {occ_script}") - asset_type = "gltf_geometry" - - # Load sharp edge hints from mesh_attributes for UV seam marking - sharp_edges_json = "[]" - if cad_file_id: - try: - import asyncio as _asyncio - - async def _load_mesh_attrs() -> list: - from app.database import AsyncSessionLocal - from app.models.cad_file import CadFile as _CF - from sqlalchemy import select as _sel - async with AsyncSessionLocal() as _db: - _res = await _db.execute(_sel(_CF).where(_CF.id == cad_file_id)) - _cad = _res.scalar_one_or_none() - if _cad and _cad.mesh_attributes: - return _cad.mesh_attributes.get("sharp_edge_midpoints") or [] - return [] - - _midpoints = _asyncio.get_event_loop().run_until_complete(_load_mesh_attrs()) - if _midpoints: - sharp_edges_json = json.dumps(_midpoints) - except Exception as _exc: - logger.warning("Could not load sharp_edge_midpoints for %s: %s", cad_file_id, _exc) - - if is_blender_available() and export_script.exists(): - blender_bin = find_blender() - cmd = [ - blender_bin, "--background", - "--python", str(export_script), - "--", - "--stl_path", str(stl_path), - "--output_path", str(output_path), - "--asset_library_blend", "", - "--material_map", json.dumps({}), - "--sharp_edges_json", sharp_edges_json, - ] - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) - if result.returncode != 0: - raise RuntimeError( - f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}" - ) - publish_asset.delay(order_line_id, asset_type, str(output_path)) - logger.info("export_gltf_for_order_line_task completed via Blender: %s", output_path.name) - return {"glb_path": str(output_path), "method": "blender"} - except Exception as exc: - logger.warning( - "Blender GLB export failed for %s, falling back to trimesh: %s", - order_line_id, exc, - ) - - # Trimesh fallback try: - import trimesh - mesh = trimesh.load(str(stl_path)) - mesh.export(str(output_path)) - publish_asset.delay(order_line_id, asset_type, str(output_path)) - logger.info("export_gltf_for_order_line_task completed via trimesh: %s", output_path.name) - return {"glb_path": str(output_path), "method": "trimesh"} + cmd = [ + sys.executable, str(occ_script), + "--step_path", str(step), + "--output_path", str(output_path), + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + if result.returncode != 0: + raise RuntimeError( + f"export_step_to_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}" + ) + publish_asset.delay(order_line_id, "gltf_geometry", str(output_path)) + logger.info("export_gltf_for_order_line_task completed via OCC: %s", output_path.name) + return {"glb_path": str(output_path), "method": "occ"} except Exception as exc: logger.error("export_gltf_for_order_line_task failed for %s: %s", order_line_id, exc) raise self.retry(exc=exc, countdown=15) @@ -576,9 +520,20 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict: raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}") step = Path(step_path_str) - stl_path = step.parent / f"{step.stem}_low.stl" - if not stl_path.exists(): - raise RuntimeError(f"STL cache not found: {stl_path}") + # Use geometry GLB as input (generate if missing) + glb_path = step.parent / f"{step.stem}_geometry.glb" + if not glb_path.exists(): + import subprocess as _sp + import sys as _sys + scripts_dir_tmp = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) + occ_cmd = [ + _sys.executable, str(scripts_dir_tmp / "export_step_to_gltf.py"), + "--step_path", str(step), + "--output_path", str(glb_path), + ] + occ_res = _sp.run(occ_cmd, capture_output=True, text=True, timeout=120) + if occ_res.returncode != 0: + raise RuntimeError(f"GLB generation failed:\n{occ_res.stderr[-500:]}") output_path = step.parent / f"{step.stem}_production.blend" scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) @@ -617,7 +572,7 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict: blender_bin, "--background", "--python", str(export_script), "--", - "--stl_path", str(stl_path), + "--glb_path", str(glb_path), "--output_path", str(output_path), "--asset_library_blend", asset_lib_path, "--material_map", json.dumps(mat_map), @@ -670,7 +625,7 @@ def apply_asset_library_materials_task(self, order_line_id: str, asset_library_i if not product or not product.cad_file_id: return None, None, None cad = s.execute(sql_select(CadFile).where(CadFile.id == product.cad_file_id)).scalar_one_or_none() - stl_path = str(Path(cad.stored_path).parent / f"{Path(cad.stored_path).stem}_low.stl") if cad else None + glb_path = str(Path(cad.stored_path).parent / f"{Path(cad.stored_path).stem}_geometry.glb") if cad else None # Resolve asset library blend path try: @@ -681,24 +636,24 @@ def apply_asset_library_materials_task(self, order_line_id: str, asset_library_i blend_path = None mat_map = {m.get("part_name", ""): m.get("material", "") for m in (product.cad_part_materials or [])} - return stl_path, blend_path, mat_map + return glb_path, blend_path, mat_map result = _inner() if result is None or result[0] is None: logger.warning("apply_asset_library_materials_task: could not resolve paths for %s", order_line_id) return {"status": "skipped"} - stl_path, blend_path, mat_map = result - if not stl_path or not Path(stl_path).exists(): - logger.warning("STL not found for %s", order_line_id) - return {"status": "skipped", "reason": "stl_not_found"} + glb_path, blend_path, mat_map = result + if not glb_path or not Path(glb_path).exists(): + logger.warning("Geometry GLB not found for %s", order_line_id) + return {"status": "skipped", "reason": "glb_not_found"} scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) script = scripts_dir / "asset_library.py" cmd = [ blender_bin, "--background", "--python", str(script), "--", - "--stl_path", stl_path, + "--glb_path", glb_path, "--asset_library_blend", blend_path or "", "--material_map", json.dumps(mat_map), ] diff --git a/backend/app/services/render_blender.py b/backend/app/services/render_blender.py index 2dc58b7..24d20fa 100644 --- a/backend/app/services/render_blender.py +++ b/backend/app/services/render_blender.py @@ -17,24 +17,39 @@ logger = logging.getLogger(__name__) MIN_BLENDER_VERSION = (5, 0, 1) -def _stl_from_cache_or_convert(step_path: Path, stl_path: Path, quality: str) -> None: - """Try MinIO cache first, then fall back to local STEP→STL conversion.""" - # MinIO cache check (non-fatal — cache miss just means we convert normally) - try: - from app.domains.products.cache_service import compute_step_hash, check_stl_cache - step_hash = compute_step_hash(str(step_path)) - cached_bytes = check_stl_cache(step_hash, quality) - if cached_bytes: - stl_path.write_bytes(cached_bytes) - logger.info("STL restored from MinIO cache: %s (%d KB)", stl_path.name, len(cached_bytes) // 1024) - return - except Exception as exc: - logger.warning("MinIO cache check failed (non-fatal): %s", exc) +def _glb_from_step(step_path: Path, glb_path: Path, quality: str = "low") -> None: + """Convert STEP → GLB via OCC (export_step_to_gltf.py, no Blender needed). - # Local conversion - from app.services.step_processor import convert_step_to_stl - logger.info("STL cache miss — converting: %s", step_path.name) - convert_step_to_stl(step_path, stl_path, quality) + quality: "low" → coarser mesh (~0.3 mm deflection, fast) + "high" → finer mesh (~0.05 mm deflection, slower) + """ + import subprocess + import sys as _sys + + linear_deflection = 0.3 if quality == "low" else 0.05 + angular_deflection = 0.3 if quality == "low" else 0.1 + + scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) + script_path = scripts_dir / "export_step_to_gltf.py" + + cmd = [ + _sys.executable, str(script_path), + "--step_path", str(step_path), + "--output_path", str(glb_path), + "--linear_deflection", str(linear_deflection), + "--angular_deflection", str(angular_deflection), + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + for line in result.stdout.splitlines(): + logger.info("[occ-gltf] %s", line) + for line in result.stderr.splitlines(): + logger.warning("[occ-gltf stderr] %s", line) + if result.returncode != 0 or not glb_path.exists() or glb_path.stat().st_size == 0: + raise RuntimeError( + f"export_step_to_gltf.py failed (exit {result.returncode}).\n" + f"STDERR: {result.stderr[-1000:]}" + ) + logger.info("GLB converted: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024) def find_blender() -> str: @@ -51,127 +66,6 @@ def is_blender_available() -> bool: return bool(find_blender()) -def convert_step_to_stl(step_path: Path, stl_path: Path, quality: str = "low") -> None: - """Convert a STEP file to STL using cadquery. - - Raises ImportError if cadquery is not installed (not available in backend - container — only in render-worker container). - """ - import cadquery as cq # only available in render-worker - - if quality == "high": - shape = cq.importers.importStep(str(step_path)) - cq.exporters.export(shape, str(stl_path), tolerance=0.01, angularTolerance=0.02) - else: - shape = cq.importers.importStep(str(step_path)) - cq.exporters.export(shape, str(stl_path), tolerance=0.3, angularTolerance=0.3) - - if not stl_path.exists() or stl_path.stat().st_size == 0: - raise RuntimeError("cadquery produced empty STL") - - -def export_per_part_stls(step_path: Path, parts_dir: Path, quality: str = "low") -> list: - """Export one STL per named STEP leaf shape using OCP XCAF. - - Returns the manifest list (may be empty on failure — non-fatal). - """ - tol = 0.01 if quality == "high" else 0.3 - angular_tol = 0.05 if quality == "high" else 0.3 - - try: - from OCP.STEPCAFControl import STEPCAFControl_Reader - from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool - from OCP.TDataStd import TDataStd_Name - from OCP.TDF import TDF_Label as TDF_Label_cls, TDF_LabelSequence - from OCP.XCAFApp import XCAFApp_Application - from OCP.TDocStd import TDocStd_Document - from OCP.TCollection import TCollection_ExtendedString - from OCP.IFSelect import IFSelect_RetDone - import cadquery as cq - except ImportError as e: - logger.warning("per-part export skipped (import error): %s", e) - return [] - - app = XCAFApp_Application.GetApplication_s() - doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf")) - app.InitDocument(doc) - - reader = STEPCAFControl_Reader() - reader.SetNameMode(True) - status = reader.ReadFile(str(step_path)) - if status != IFSelect_RetDone: - logger.warning("XCAF reader failed with status %s", status) - return [] - - if not reader.Transfer(doc): - logger.warning("XCAF transfer failed") - return [] - - shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()) - name_id = TDataStd_Name.GetID_s() - - leaves = [] - - def _get_label_name(label): - name_attr = TDataStd_Name() - if label.FindAttribute(name_id, name_attr): - return name_attr.Get().ToExtString() - return "" - - def _collect_leaves(label): - if XCAFDoc_ShapeTool.IsAssembly_s(label): - components = TDF_LabelSequence() - XCAFDoc_ShapeTool.GetComponents_s(label, components) - for i in range(1, components.Length() + 1): - comp_label = components.Value(i) - if XCAFDoc_ShapeTool.IsReference_s(comp_label): - ref_label = TDF_Label_cls() - XCAFDoc_ShapeTool.GetReferredShape_s(comp_label, ref_label) - comp_name = _get_label_name(comp_label) - ref_name = _get_label_name(ref_label) - name = ref_name or comp_name - if XCAFDoc_ShapeTool.IsAssembly_s(ref_label): - _collect_leaves(ref_label) - elif XCAFDoc_ShapeTool.IsSimpleShape_s(ref_label): - shape = XCAFDoc_ShapeTool.GetShape_s(comp_label) - leaves.append((name or f"unnamed_{len(leaves)}", shape)) - else: - _collect_leaves(comp_label) - elif XCAFDoc_ShapeTool.IsSimpleShape_s(label): - name = _get_label_name(label) - shape = XCAFDoc_ShapeTool.GetShape_s(label) - leaves.append((name or f"unnamed_{len(leaves)}", shape)) - - top_labels = TDF_LabelSequence() - shape_tool.GetFreeShapes(top_labels) - for i in range(1, top_labels.Length() + 1): - _collect_leaves(top_labels.Value(i)) - - if not leaves: - logger.warning("no leaf shapes found via XCAF") - return [] - - parts_dir.mkdir(parents=True, exist_ok=True) - manifest = [] - - for idx, (name, shape) in enumerate(leaves): - safe_name = name.replace("/", "_").replace("\\", "_").replace(" ", "_") - filename = f"{idx:02d}_{safe_name}.stl" - filepath = str(parts_dir / filename) - try: - cq_shape = cq.Shape(shape) - cq_shape.exportStl(filepath, tolerance=tol, angularTolerance=angular_tol) - manifest.append({"index": idx, "name": name, "file": filename}) - except Exception as e: - logger.warning("failed to export part '%s': %s", name, e) - - manifest_path = parts_dir / "manifest.json" - with open(manifest_path, "w") as f: - json.dump({"parts": manifest}, f, indent=2) - - return manifest - - def render_still( step_path: Path, output_path: Path, @@ -202,7 +96,7 @@ def render_still( denoising_use_gpu: str = "", mesh_attributes: dict | None = None, ) -> dict: - """Convert STEP → STL (cadquery) → PNG (Blender subprocess). + """Convert STEP → GLB (OCC) → PNG (Blender subprocess). Returns a dict with timing, sizes, engine_used, and log_lines. Raises RuntimeError on failure. @@ -215,7 +109,6 @@ def render_still( script_path = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "blender_render.py" if not script_path.exists(): - # Fallback: look next to this file (development mode) alt = Path(__file__).parent.parent.parent.parent / "render-worker" / "scripts" / "blender_render.py" if alt.exists(): script_path = alt @@ -224,24 +117,16 @@ def render_still( t0 = time.monotonic() - # 1. STL conversion (cadquery) - stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl" - parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts" + # 1. GLB conversion (OCC — replaces cadquery STL) + glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb" - t_stl = time.monotonic() - if not stl_path.exists() or stl_path.stat().st_size == 0: - _stl_from_cache_or_convert(step_path, stl_path, stl_quality) + t_glb = time.monotonic() + if not glb_path.exists() or glb_path.stat().st_size == 0: + _glb_from_step(step_path, glb_path, quality=stl_quality) else: - logger.info("STL local hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024) - stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0 - - if not (parts_dir / "manifest.json").exists(): - try: - export_per_part_stls(step_path, parts_dir, stl_quality) - except Exception as exc: - logger.warning("per-part STL export failed (non-fatal): %s", exc) - - stl_duration_s = round(time.monotonic() - t_stl, 2) + logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024) + glb_size_bytes = glb_path.stat().st_size if glb_path.exists() else 0 + glb_duration_s = round(time.monotonic() - t_glb, 2) # 2. Blender render output_path.parent.mkdir(parents=True, exist_ok=True) @@ -263,7 +148,7 @@ def render_still( "--background", "--python", str(script_path), "--", - str(stl_path), + str(glb_path), str(output_path), str(width), str(height), eng, str(samples), str(smooth_angle), @@ -332,22 +217,13 @@ def render_still( render_duration_s = round(time.monotonic() - t_render, 2) - parts_count = 0 - manifest_file = parts_dir / "manifest.json" - if manifest_file.exists(): - try: - data = json.loads(manifest_file.read_text()) - parts_count = len(data.get("parts", [])) - except Exception: - pass - return { "total_duration_s": round(time.monotonic() - t0, 2), - "stl_duration_s": stl_duration_s, + "stl_duration_s": glb_duration_s, # key kept for backward compat with DB render_log "render_duration_s": render_duration_s, - "stl_size_bytes": stl_size_bytes, + "stl_size_bytes": glb_size_bytes, "output_size_bytes": output_path.stat().st_size if output_path.exists() else 0, - "parts_count": parts_count, + "parts_count": 0, "engine_used": engine_used, "log_lines": log_lines, } @@ -407,24 +283,15 @@ def render_turntable_to_file( t0 = time.monotonic() - # 1. STL conversion - stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl" - parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts" + # 1. GLB conversion (OCC — replaces cadquery STL) + glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb" - t_stl = time.monotonic() - if not stl_path.exists() or stl_path.stat().st_size == 0: - _stl_from_cache_or_convert(step_path, stl_path, stl_quality) + t_glb = time.monotonic() + if not glb_path.exists() or glb_path.stat().st_size == 0: + _glb_from_step(step_path, glb_path, quality=stl_quality) else: - logger.info("STL local hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024) - stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0 - - if not (parts_dir / "manifest.json").exists(): - try: - export_per_part_stls(step_path, parts_dir, stl_quality) - except Exception as exc: - logger.warning("per-part STL export failed (non-fatal): %s", exc) - - stl_duration_s = round(time.monotonic() - t_stl, 2) + logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024) + glb_duration_s = round(time.monotonic() - t_glb, 2) # 2. Render frames with Blender frames_dir = output_path.parent / f"_frames_{output_path.stem}" @@ -439,7 +306,7 @@ def render_turntable_to_file( "--background", "--python", str(script_path), "--", - str(stl_path), + str(glb_path), str(frames_dir), str(frame_count), "360", # degrees @@ -554,10 +421,10 @@ def render_turntable_to_file( return { "total_duration_s": round(time.monotonic() - t0, 2), - "stl_duration_s": stl_duration_s, + "stl_duration_s": glb_duration_s, # key kept for backward compat with DB render_log "render_duration_s": render_duration_s, "ffmpeg_duration_s": ffmpeg_duration_s, - "stl_size_bytes": stl_size_bytes, + "stl_size_bytes": 0, "output_size_bytes": output_path.stat().st_size if output_path.exists() else 0, "frame_count": len(frame_files), "engine_used": engine, diff --git a/backend/app/tasks/step_tasks.py b/backend/app/tasks/step_tasks.py index dd152c7..7edb233 100644 --- a/backend/app/tasks/step_tasks.py +++ b/backend/app/tasks/step_tasks.py @@ -1,6 +1,5 @@ """Celery tasks for STEP file processing and thumbnail generation.""" import logging -import struct from pathlib import Path from app.tasks.celery_app import celery_app from app.core.task_logs import log_task_event @@ -8,44 +7,37 @@ from app.core.task_logs import log_task_event logger = logging.getLogger(__name__) -def _bbox_from_stl(stl_path: str) -> dict | None: - """Extract bounding box from a cached binary STL file. +def _bbox_from_glb(glb_path: str) -> dict | None: + """Extract bounding box from a GLB file (meters → converted to mm). Returns {"dimensions_mm": {x,y,z}, "bbox_center_mm": {x,y,z}} or None on failure. - Reading vertex extremes from an existing STL is ~10-100× faster than re-parsing STEP. + OCC GLB output is in meters; multiply by 1000 to get mm. """ try: - import numpy as np - p = Path(stl_path) - if not p.exists() or p.stat().st_size < 84: + import trimesh + p = Path(glb_path) + if not p.exists(): return None - with p.open("rb") as f: - f.seek(80) # skip 80-byte header - n = struct.unpack(" 0: - store_stl_cache(step_hash, quality, str(stl_out)) - if not (parts_dir / "manifest.json").exists(): - try: - export_per_part_stls(step, parts_dir, quality) - except Exception as pe: - logger.warning(f"Per-part STL export non-fatal: {pe}") - logger.info(f"STL cached: {stl_out} ({stl_out.stat().st_size // 1024} KB)") - except Exception as exc: - logger.error(f"STL generation failed for {cad_file_id} quality={quality}: {exc}") - raise self.retry(exc=exc, countdown=30, max_retries=2) - - @celery_app.task(bind=True, name="app.tasks.step_tasks.generate_gltf_geometry_task", queue="thumbnail_rendering", max_retries=1) def generate_gltf_geometry_task(self, cad_file_id: str): - """Export a GLB via Blender with material substitution and OCC sharp-edge data. + """Export a geometry GLB directly from STEP via OCC (no STL intermediary). Pipeline: - 1. Reads sharp_edge_midpoints from cad_file.mesh_attributes (from OCC extraction) - 2. Resolves material_map via alias lookup (part_name → SCHAEFFLER library material) - 3. Runs Blender headless with export_gltf.py: STL → GLB with library materials + sharp edges - 4. Falls back to trimesh (geometry-only, no materials) if Blender is unavailable + 1. Reads STEP file directly (no STL needed) + 2. Builds color_map from product.cad_part_materials (hex colors) + 3. Runs export_step_to_gltf.py (Python/OCP): STEP → GLB with per-part colors + 4. Stores result as gltf_geometry MediaAsset (replaces any existing one) - Replaces the existing gltf_geometry MediaAsset for this CadFile on each run. + Output is in meters, Y-up (glTF convention). """ import json as _json import os as _os import subprocess as _subprocess + import sys as _sys from pathlib import Path as _Path from sqlalchemy import create_engine, select as _select from sqlalchemy.orm import Session @@ -422,115 +371,68 @@ def generate_gltf_geometry_task(self, cad_file_id: str): logger.error("generate_gltf_geometry_task: no stored_path for %s", cad_file_id) return step_path_str = cad_file.stored_path - mesh_attrs = cad_file.mesh_attributes or {} - sharp_edge_midpoints = mesh_attrs.get("sharp_edge_midpoints", []) - # Load product materials for this CAD file + # Build hex color_map from product.cad_part_materials from app.domains.products.models import Product product = session.execute( _select(Product).where(Product.cad_file_id == cad_file.id) ).scalar_one_or_none() - raw_material_map: dict[str, str] = {} + color_map: dict[str, str] = {} + product_id = str(product.id) if product else None if product and product.cad_part_materials: for entry in product.cad_part_materials: part_name = entry.get("part_name") or entry.get("name", "") - mat_name = entry.get("material_name") or entry.get("material", "") - if part_name and mat_name: - raw_material_map[part_name] = mat_name + hex_color = entry.get("hex_color") or entry.get("color", "") + if part_name and hex_color: + color_map[part_name] = hex_color eng.dispose() - # Resolve aliases: "Steel--Stahl" → "SCHAEFFLER_010101_Steel-Bare" - from app.services.material_service import resolve_material_map - material_map = resolve_material_map(raw_material_map) - - # Get asset library .blend path from system settings - from app.services.template_service import get_material_library_path - asset_library_blend = get_material_library_path() - step = _Path(step_path_str) - stl_path = step.parent / f"{step.stem}_low.stl" - if not stl_path.exists(): - log_task_event(self.request.id, f"Failed: STL cache not found: {stl_path}", "error") - raise RuntimeError(f"STL cache not found: {stl_path}") + if not step.exists(): + log_task_event(self.request.id, f"Failed: STEP file not found: {step}", "error") + raise RuntimeError(f"STEP file not found: {step}") output_path = step.parent / f"{step.stem}_geometry.glb" log_task_event( self.request.id, - f"Starting GLB export: {len(material_map)} materials, " - f"{len(sharp_edge_midpoints)} sharp-edge hints, " - f"library={'yes' if asset_library_blend else 'no'}", + f"Starting OCC GLB export: {len(color_map)} part colors", "info", ) - # --- Blender path --- - blender_bin = _os.environ.get("BLENDER_BIN", "blender") + # Run export_step_to_gltf.py as a subprocess so OCP imports don't pollute worker state scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) - script_path = scripts_dir / "export_gltf.py" - blender_ok = False + script_path = scripts_dir / "export_step_to_gltf.py" - if _Path(blender_bin).exists() and script_path.exists(): - cmd = [ - blender_bin, "--background", "--python", str(script_path), "--", - "--stl_path", str(stl_path), - "--output_path", str(output_path), - "--material_map", _json.dumps(material_map), - "--sharp_edges_json", _json.dumps(sharp_edge_midpoints), - ] - if asset_library_blend and _Path(asset_library_blend).exists(): - cmd += ["--asset_library_blend", asset_library_blend] + python_bin = _sys.executable + cmd = [ + python_bin, str(script_path), + "--step_path", str(step), + "--output_path", str(output_path), + "--color_map", _json.dumps(color_map), + ] - try: - result = _subprocess.run(cmd, capture_output=True, text=True, timeout=180) - if result.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0: - blender_ok = True - logger.info("generate_gltf_geometry_task: Blender export succeeded (%s KB)", - output_path.stat().st_size // 1024) - else: - logger.warning( - "Blender GLB export failed (exit %d) — falling back to trimesh.\n" - "STDOUT: %s\nSTDERR: %s", - result.returncode, result.stdout[-1500:], result.stderr[-500:], - ) - except Exception as exc: - logger.warning("Blender GLB export error (%s) — falling back to trimesh", exc) - else: - logger.warning( - "Blender not available at '%s' or script missing at '%s' — using trimesh fallback", - blender_bin, script_path, - ) + try: + result = _subprocess.run(cmd, capture_output=True, text=True, timeout=120) + for line in result.stdout.splitlines(): + logger.info("[occ-gltf] %s", line) + for line in result.stderr.splitlines(): + logger.warning("[occ-gltf stderr] %s", line) - # --- Trimesh fallback (geometry only, no materials) --- - if not blender_ok: - try: - import trimesh - import trimesh as _trimesh + if result.returncode != 0 or not output_path.exists() or output_path.stat().st_size == 0: + raise RuntimeError( + f"export_step_to_gltf.py failed (exit {result.returncode}).\n" + f"STDERR: {result.stderr[-1000:]}" + ) + except Exception as exc: + log_task_event(self.request.id, f"Failed: {exc}", "error") + logger.error("generate_gltf_geometry_task OCC export failed: %s", exc) + raise self.retry(exc=exc, countdown=15) - def _process_mesh(m): - m.apply_scale(0.001) - try: - _trimesh.smoothing.filter_laplacian(m, lamb=0.5, iterations=5) - except Exception: - pass - - mesh = trimesh.load(str(stl_path)) - if hasattr(mesh, 'geometry'): - for sub in mesh.geometry.values(): - _process_mesh(sub) - else: - _process_mesh(mesh) - mesh.export(str(output_path)) - log_task_event(self.request.id, "Trimesh fallback export completed (no materials)", "done") - except Exception as exc: - log_task_event(self.request.id, f"Failed: {exc}", "error") - logger.error("generate_gltf_geometry_task trimesh fallback failed: %s", exc) - raise self.retry(exc=exc, countdown=15) - else: - log_task_event(self.request.id, f"Blender GLB export completed: {output_path.name}", "done") + log_task_event(self.request.id, f"OCC GLB export completed: {output_path.name}", "done") # --- Store MediaAsset (replace existing gltf_geometry for this cad_file) --- - # Use sync SQLAlchemy to avoid asyncio event-loop conflicts in Celery workers. import uuid as _uuid from sqlalchemy import create_engine as _ce, delete as _del from sqlalchemy.orm import Session as _Session @@ -551,6 +453,7 @@ def generate_gltf_geometry_task(self, cad_file_id: str): _key = _key[len(_prefix):] asset = MediaAsset( cad_file_id=_uuid.UUID(cad_file_id), + product_id=_uuid.UUID(product_id) if product_id else None, asset_type=MediaAssetType.gltf_geometry, storage_key=_key, mime_type="model/gltf-binary", @@ -565,6 +468,146 @@ def generate_gltf_geometry_task(self, cad_file_id: str): return {"glb_path": str(output_path), "asset_id": asset_id} +@celery_app.task( + bind=True, + name="app.tasks.step_tasks.generate_gltf_production_task", + queue="thumbnail_rendering", + max_retries=2, +) +def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None = None) -> dict: + """Generate a production GLB (Blender + PBR materials) from a geometry GLB via export_gltf.py. + + 1. Ensures a gltf_geometry MediaAsset exists (runs OCC export inline if not). + 2. Resolves SCHAEFFLER material map for the CadFile's product. + 3. Runs Blender headless with export_gltf.py → production GLB. + 4. Stores result as gltf_production MediaAsset. + """ + import json as _json + import os as _os + import subprocess as _subprocess + import uuid as _uuid + from pathlib import Path as _Path + + from sqlalchemy import create_engine as _ce, delete as _del, select as _sel + from sqlalchemy.orm import Session as _Session + + from app.config import settings as app_settings + from app.domains.media.models import MediaAsset, MediaAssetType + from app.services.render_blender import find_blender, is_blender_available + + log_task_event(self.request.id, f"generate_gltf_production_task started for cad {cad_file_id}", "info") + + _sync_url = app_settings.database_url.replace("+asyncpg", "") + _eng = _ce(_sync_url) + + # --- 1. Resolve geometry GLB path from existing gltf_geometry MediaAsset --- + with _Session(_eng) as _sess: + _row = _sess.execute( + _sel(MediaAsset).where( + MediaAsset.cad_file_id == _uuid.UUID(cad_file_id), + MediaAsset.asset_type == MediaAssetType.gltf_geometry, + ) + ).scalar_one_or_none() + geom_glb_key = _row.storage_key if _row else None + + if not geom_glb_key: + # Trigger geometry generation first and retry this task + log_task_event(self.request.id, "No gltf_geometry asset found — queuing geometry task first", "info") + generate_gltf_geometry_task.delay(cad_file_id, product_id) + raise self.retry(exc=RuntimeError("gltf_geometry not yet available"), countdown=30, max_retries=2) + + geom_glb_path = _Path(app_settings.upload_dir) / geom_glb_key + if not geom_glb_path.exists(): + raise RuntimeError(f"Geometry GLB not found on disk: {geom_glb_path}") + + # --- 2. Resolve material map (SCHAEFFLER library names) --- + from app.services.material_service import resolve_material_map + + with _Session(_eng) as _sess: + from app.models.cad_file import CadFile as _CF + _cad = _sess.execute(_sel(_CF).where(_CF.id == _uuid.UUID(cad_file_id))).scalar_one_or_none() + raw_mat_map: dict = {} + if _cad and _cad.cad_part_materials: + raw_mat_map = _cad.cad_part_materials + + mat_map = resolve_material_map(raw_mat_map) + + # --- 3. Resolve asset library .blend path from system settings --- + from app.models.system_setting import SystemSetting + with _Session(_eng) as _sess: + _setting = _sess.execute( + _sel(SystemSetting).where(SystemSetting.key == "asset_library_blend") + ).scalar_one_or_none() + asset_library_blend = _setting.value if _setting and _setting.value else "" + _eng.dispose() + + # Output path next to geometry GLB + output_path = geom_glb_path.parent / (geom_glb_path.stem.replace("_geometry", "") + "_production.glb") + + scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) + export_script = scripts_dir / "export_gltf.py" + + if not is_blender_available(): + raise RuntimeError("Blender is not available — cannot generate production GLB") + if not export_script.exists(): + raise RuntimeError(f"export_gltf.py not found at {export_script}") + + blender_bin = find_blender() + cmd = [ + blender_bin, "--background", + "--python", str(export_script), + "--", + "--glb_path", str(geom_glb_path), + "--output_path", str(output_path), + "--material_map", _json.dumps(mat_map), + ] + if asset_library_blend: + cmd += ["--asset_library_blend", asset_library_blend] + + log_task_event(self.request.id, f"Running Blender export_gltf.py for {geom_glb_path.name}", "info") + try: + result = _subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if result.returncode != 0: + raise RuntimeError( + f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}" + ) + except Exception as exc: + log_task_event(self.request.id, f"Blender production GLB failed: {exc}", "error") + logger.error("generate_gltf_production_task Blender failed for cad %s: %s", cad_file_id, exc) + raise self.retry(exc=exc, countdown=30) + + log_task_event(self.request.id, f"Production GLB exported: {output_path.name}", "done") + + # --- 4. Store MediaAsset (replace existing gltf_production for this cad_file) --- + _eng2 = _ce(_sync_url) + with _Session(_eng2) as _sess: + _sess.execute( + _del(MediaAsset).where( + MediaAsset.cad_file_id == _uuid.UUID(cad_file_id), + MediaAsset.asset_type == MediaAssetType.gltf_production, + ) + ) + _key = str(output_path) + _prefix = str(app_settings.upload_dir).rstrip("/") + "/" + if _key.startswith(_prefix): + _key = _key[len(_prefix):] + asset = MediaAsset( + cad_file_id=_uuid.UUID(cad_file_id), + product_id=_uuid.UUID(product_id) if product_id else None, + asset_type=MediaAssetType.gltf_production, + storage_key=_key, + mime_type="model/gltf-binary", + file_size_bytes=output_path.stat().st_size if output_path.exists() else None, + ) + _sess.add(asset) + _sess.commit() + asset_id = str(asset.id) + _eng2.dispose() + + logger.info("generate_gltf_production_task: MediaAsset %s created for cad %s", asset_id, cad_file_id) + return {"glb_path": str(output_path), "asset_id": asset_id} + + @celery_app.task(bind=True, name="app.tasks.step_tasks.regenerate_thumbnail", queue="thumbnail_rendering") def regenerate_thumbnail(self, cad_file_id: str, part_colors: dict): """Regenerate thumbnail with per-part colours.""" diff --git a/frontend/src/api/cad.ts b/frontend/src/api/cad.ts index 574f56b..914f18a 100644 --- a/frontend/src/api/cad.ts +++ b/frontend/src/api/cad.ts @@ -67,30 +67,6 @@ export async function getCadObjects(cadFileId: string): Promise { return res.data } -/** - * Download the cached STL for a CAD file as a file-save dialog. - * quality: 'low' | 'high' - * The backend returns a human-readable filename, but we derive it client-side too. - */ -export async function downloadStl(cadFileId: string, quality: 'low' | 'high', suggestedName?: string): Promise { - const res = await api.get(`/cad/${cadFileId}/stl/${quality}`, { - responseType: 'blob', - }) - const url = URL.createObjectURL(res.data) - const a = document.createElement('a') - a.href = url - a.download = suggestedName ? `${suggestedName}_${quality}.stl` : `model_${quality}.stl` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) -} - -export async function generateStl(cadFileId: string, quality: 'low' | 'high'): Promise<{ task_id: string }> { - const res = await api.post<{ task_id: string }>(`/cad/${cadFileId}/generate-stl/${quality}`) - return res.data -} - /** * Ask the backend to re-queue STEP processing for a CAD file (admin only). * Returns the Celery task_id (or null if the worker is not available). @@ -110,23 +86,14 @@ export interface GenerateGltfResponse { cad_file_id: string } -/** - * Queue GLB geometry export from existing STL cache (trimesh, no Blender). - * The STL low-quality cache must already exist. - */ +/** Queue geometry GLB export directly from STEP via OCC (no Blender, no STL). */ export async function generateGltfGeometry(cadFileId: string): Promise { const res = await api.post(`/cad/${cadFileId}/generate-gltf-geometry`) return res.data } -export const exportGltfColored = (id: string): Promise => - api.get(`/cad/${id}/export-gltf-colored`, { responseType: 'blob' }).then(r => { - const url = URL.createObjectURL(r.data) - const a = document.createElement('a') - a.href = url - a.download = `${id}_colored.glb` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - }) +/** Queue production GLB export (Blender + PBR materials) from geometry GLB. */ +export async function generateGltfProduction(cadFileId: string): Promise { + const res = await api.post(`/cad/${cadFileId}/generate-gltf-production`) + return res.data +} diff --git a/frontend/src/api/products.ts b/frontend/src/api/products.ts index 2d2110f..f4dd03d 100644 --- a/frontend/src/api/products.ts +++ b/frontend/src/api/products.ts @@ -55,7 +55,6 @@ export interface Product { thumbnail_url: string | null render_image_url: string | null processing_status: string | null - stl_cached: string[] cad_parsed_objects: string[] | null cad_mesh_attributes?: { dimensions_mm?: { x: number; y: number; z: number } diff --git a/frontend/src/components/cad/InlineCadViewer.tsx b/frontend/src/components/cad/InlineCadViewer.tsx index 98f8fa8..77ff400 100644 --- a/frontend/src/components/cad/InlineCadViewer.tsx +++ b/frontend/src/components/cad/InlineCadViewer.tsx @@ -31,7 +31,7 @@ function GlbModel({ url, wireframe }: { url: string; wireframe: boolean }) { if (obj instanceof THREE.Mesh && obj.geometry) { let geo = obj.geometry.clone() if (!geo.index) { - // Non-indexed geometry (STL→GLB via trimesh): each triangle has unique vertices, + // Non-indexed geometry: each triangle has unique vertices, // so computeVertexNormals() would give per-face normals (flat shading). // mergeVertices() creates an indexed geometry with shared vertices first, // so the subsequent normal computation averages across adjacent faces → smooth. @@ -208,7 +208,7 @@ export default function InlineCadViewer({ className="btn-secondary text-xs" onClick={() => generateMut.mutate()} disabled={generateMut.isPending || generating} - title="Export STL to GLB and load 3D viewer" + title="Export geometry GLB from STEP via OCC and load 3D viewer" > {generating ? 'Generating…' : generateMut.isPending ? 'Queuing…' : 'Load 3D Model'} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 07e1225..9aadaf9 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -73,7 +73,6 @@ export default function AdminPage() { blender_eevee_samples: number threejs_render_size: number thumbnail_format: string - stl_quality: string blender_smooth_angle: number cycles_device: string blender_max_concurrent_renders: number @@ -159,14 +158,6 @@ export default function AdminPage() { onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'), }) - const generateMissingStlsMut = useMutation({ - mutationFn: () => api.post('/admin/settings/generate-missing-stls'), - onSuccess: (res) => { - toast.success(res.data.message || 'STL generation queued') - }, - onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'), - }) - const reextractMetadataMut = useMutation({ mutationFn: () => api.post('/admin/settings/reextract-metadata'), onSuccess: (res) => { @@ -397,29 +388,6 @@ export default function AdminPage() { - {/* STL quality */} -
- STL quality - {(['low', 'high'] as const).map((q) => ( - - ))} -

- {blender.stl_quality === 'high' - ? 'Fine mesh (tol=0.01) — slower STEP→STL, sharper edges.' - : 'Coarse mesh (tol=0.3) — faster, good for previews.'} -

-
- {/* Smooth by angle */}
Smooth angle @@ -704,18 +672,6 @@ export default function AdminPage() {

Registers existing renders & CAD thumbnails in the Media Browser.

-
- -

Generates low + high STL files for completed STEP files missing them.

-
-
- - - - - {product?.processing_status === 'completed' && ( - - )} -
-

STL:

- {(['low', 'high'] as const).map((q) => - product.stl_cached.includes(q) ? ( - - ) : ( - - ) - )} -
- - )} - {!isPrivileged && product.cad_file_id && ( + {/* Right: Action buttons */} +
- )} -
- {/* Mesh attributes */} - {(() => { - // Prefer cad_mesh_attributes (reliably populated by API) over cad_file.mesh_attributes - const mesh_attrs: Record = (product.cad_mesh_attributes ?? product.cad_file?.mesh_attributes) as Record ?? {} - if (Object.keys(mesh_attrs).length === 0) return null - const dims = mesh_attrs.dimensions_mm as { x: number; y: number; z: number } | undefined - const bbox = mesh_attrs.bbox as { x?: number; y?: number; z?: number } | undefined - return ( -
-

- - Geometry -

-
- {dims != null && ( - <> - Dimensions - {dims.x.toFixed(1)} × {dims.y.toFixed(1)} × {dims.z.toFixed(1)} mm - - )} - {dims == null && bbox != null && ( - <> - BBox - - {bbox.x?.toFixed(1)} × {bbox.y?.toFixed(1)} × {bbox.z?.toFixed(1)} mm - - - )} - {(mesh_attrs.volume_mm3 as number | undefined) != null && ( - <> - Volume - {((mesh_attrs.volume_mm3 as number) / 1000).toFixed(2)} cm³ - - )} - {(mesh_attrs.surface_area_mm2 as number | undefined) != null && ( - <> - Surface - {((mesh_attrs.surface_area_mm2 as number) / 100).toFixed(1)} cm² - - )} - {mesh_attrs.suggested_smooth_angle !== undefined && ( - <> - Sharp angle - {mesh_attrs.suggested_smooth_angle as number}° - - )} -
-
- ) - })()} + {isPrivileged && ( + <> +
+
+ + +
+ + +
+ +
+ + +
+ + )} + + {/* Material assignments */} {isPrivileged && ( diff --git a/render-worker/scripts/blender_render.py b/render-worker/scripts/blender_render.py index d494a25..66b40e1 100644 --- a/render-worker/scripts/blender_render.py +++ b/render-worker/scripts/blender_render.py @@ -57,12 +57,12 @@ else: if len(argv) < 4: print("Usage: blender --background --python blender_render.py -- " - " [engine] [samples] [smooth_angle] [cycles_device] [transparent_bg]") + " [engine] [samples] [smooth_angle] [cycles_device] [transparent_bg]") sys.exit(1) import json as _json -stl_path = argv[0] +glb_path = argv[0] output_path = argv[1] width = int(argv[2]) height = int(argv[3]) @@ -173,23 +173,7 @@ def _assign_palette_material(part_obj, index): import re as _re -def _scale_mm_to_m(parts): - """Scale imported STL objects from mm to Blender metres (×0.001). - - STEP/STL coordinates are in mm; Blender's default unit is metres. - Without scaling a 50 mm part appears as 50 m inside Blender — way too large - relative to any template environment designed in metric units. - """ - if not parts: - return - bpy.ops.object.select_all(action='DESELECT') - for p in parts: - p.scale = (0.001, 0.001, 0.001) - p.location *= 0.001 - p.select_set(True) - bpy.context.view_layer.objects.active = parts[0] - bpy.ops.object.transform_apply(scale=True, location=False, rotation=False) - print(f"[blender_render] scaled {len(parts)} parts mm→m (×0.001)") +# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already. def _apply_rotation(parts, rx, ry, rz): @@ -276,85 +260,37 @@ def _mark_sharp_and_seams(obj, smooth_angle_deg: float, sharp_edge_midpoints=Non bpy.ops.object.mode_set(mode='OBJECT') -def _import_stl(stl_file): - """Import STL into Blender, using per-part STLs if available. +def _import_glb(glb_file): + """Import OCC-generated GLB into Blender. - Checks for {stl_stem}_parts/manifest.json next to the STL file. - - Per-part mode: imports each part STL, names Blender object after STEP part name. - - Fallback: imports combined STL and splits by loose geometry. + OCC exports one mesh object per STEP part, already in metres. + Blender's native GLTF importer preserves part names. - Returns list of Blender mesh objects, centred at origin. + Returns list of Blender mesh objects, centred at world origin. """ - stl_dir = os.path.dirname(stl_file) - stl_stem = os.path.splitext(os.path.basename(stl_file))[0] - parts_dir = os.path.join(stl_dir, stl_stem + "_parts") - manifest_path = os.path.join(parts_dir, "manifest.json") + bpy.ops.object.select_all(action='DESELECT') + bpy.ops.import_scene.gltf(filepath=glb_file) + parts = [o for o in bpy.context.selected_objects if o.type == 'MESH'] - parts = [] - - if os.path.isfile(manifest_path): - # ── Per-part mode ──────────────────────────────────────────────── - try: - with open(manifest_path, "r") as f: - manifest = _json.loads(f.read()) - part_entries = manifest.get("parts", []) - except Exception as e: - print(f"[blender_render] WARNING: failed to read manifest: {e}") - part_entries = [] - - if part_entries: - for entry in part_entries: - part_file = os.path.join(parts_dir, entry["file"]) - part_name = entry["name"] - if not os.path.isfile(part_file): - print(f"[blender_render] WARNING: part STL missing: {part_file}") - continue - - bpy.ops.object.select_all(action='DESELECT') - bpy.ops.wm.stl_import(filepath=part_file) - imported = bpy.context.selected_objects - if imported: - obj = imported[0] - obj.name = part_name - if obj.data: - obj.data.name = part_name - parts.append(obj) - - if parts: - print(f"[blender_render] imported {len(parts)} named parts from per-part STLs") - - # ── Fallback: combined STL + separate by loose ─────────────────────── if not parts: - bpy.ops.wm.stl_import(filepath=stl_file) - obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None - if obj is None: - print(f"ERROR: No objects imported from {stl_file}") - sys.exit(1) + print(f"ERROR: No mesh objects imported from {glb_file}") + sys.exit(1) - bpy.context.view_layer.objects.active = obj - bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS') - obj.location = (0.0, 0.0, 0.0) + print(f"[blender_render] imported {len(parts)} part(s) from GLB: " + f"{[p.name for p in parts[:5]]}") - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.separate(type='LOOSE') - bpy.ops.object.mode_set(mode='OBJECT') - - parts = list(bpy.context.selected_objects) - print(f"[blender_render] fallback: separated into {len(parts)} part(s)") - return parts - - # ── Centre per-part imports at origin (combined bbox) ──────────────── + # Centre combined bbox at world origin all_corners = [] for p in parts: all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box) if all_corners: mins = Vector((min(v.x for v in all_corners), - min(v.y for v in all_corners), - min(v.z for v in all_corners))) + min(v.y for v in all_corners), + min(v.z for v in all_corners))) maxs = Vector((max(v.x for v in all_corners), - max(v.y for v in all_corners), - max(v.z for v in all_corners))) + max(v.y for v in all_corners), + max(v.z for v in all_corners))) center = (mins + maxs) * 0.5 for p in parts: p.location -= center @@ -453,10 +389,8 @@ if use_template: # Find or create target collection target_col = _ensure_collection(target_collection) - # Import and split STL - parts = _import_stl(stl_path) - # Scale mm→m: STEP coords are mm, Blender default unit is metres - _scale_mm_to_m(parts) + # Import OCC GLB (already in metres, one object per STEP part) + parts = _import_glb(glb_path) # Apply render position rotation (before camera/bbox calculations) _apply_rotation(parts, rotation_x, rotation_y, rotation_z) @@ -538,9 +472,8 @@ else: # ── MODE A: Factory settings (original behavior) ───────────────────────── needs_auto_camera = True bpy.ops.wm.read_factory_settings(use_empty=True) - parts = _import_stl(stl_path) - # Scale mm→m: STEP coords are mm, Blender default unit is metres - _scale_mm_to_m(parts) + # Import OCC GLB (already in metres, one object per STEP part) + parts = _import_glb(glb_path) # Apply render position rotation (before camera/bbox calculations) _apply_rotation(parts, rotation_x, rotation_y, rotation_z) diff --git a/render-worker/scripts/export_blend.py b/render-worker/scripts/export_blend.py index 2257686..dd58423 100644 --- a/render-worker/scripts/export_blend.py +++ b/render-worker/scripts/export_blend.py @@ -1,14 +1,14 @@ -"""Blender headless script: export a STEP-derived scene as a production .blend. +"""Blender headless script: export a geometry GLB as a production .blend. Usage: blender --background --python export_blend.py -- \\ - --stl_path /path/to/file.stl \\ + --glb_path /path/to/geometry.glb \\ --output_path /path/to/output.blend \\ [--asset_library_blend /path/to/library.blend] \\ [--material_map '{"SrcMat": "LibMat"}'] The script: -1. Imports the STL file (with mm→m scale). +1. Imports the geometry GLB (already in metres, Y-up). 2. Optionally applies asset library materials from a .blend. 3. Packs all external data. 4. Saves a copy as the output .blend. @@ -28,7 +28,8 @@ def parse_args() -> argparse.Namespace: sys.exit(1) rest = argv[argv.index("--") + 1:] parser = argparse.ArgumentParser() - parser.add_argument("--stl_path", required=True) + parser.add_argument("--glb_path", required=True, + help="Geometry GLB from export_step_to_gltf.py (already in metres)") parser.add_argument("--output_path", required=True) parser.add_argument("--asset_library_blend", default=None) parser.add_argument("--material_map", default="{}") @@ -44,14 +45,8 @@ def main() -> None: # Clean scene bpy.ops.wm.read_factory_settings(use_empty=True) - # Import STL - bpy.ops.import_mesh.stl(filepath=args.stl_path) - - # Scale mm → m - for obj in bpy.context.selected_objects: - obj.scale = (0.001, 0.001, 0.001) - bpy.context.view_layer.objects.active = obj - bpy.ops.object.transform_apply(scale=True) + # Import geometry GLB (metres, Y-up — no rescaling needed) + bpy.ops.import_scene.gltf(filepath=args.glb_path) # Apply asset library materials if provided if args.asset_library_blend and material_map: diff --git a/render-worker/scripts/export_gltf.py b/render-worker/scripts/export_gltf.py index 28ae6ea..a2aae73 100644 --- a/render-worker/scripts/export_gltf.py +++ b/render-worker/scripts/export_gltf.py @@ -27,78 +27,30 @@ def parse_args() -> argparse.Namespace: sys.exit(1) rest = argv[argv.index("--") + 1:] parser = argparse.ArgumentParser() - parser.add_argument("--stl_path", required=True) + parser.add_argument("--glb_path", required=True, + help="Geometry GLB from export_step_to_gltf.py (already in metres)") parser.add_argument("--output_path", required=True) parser.add_argument("--asset_library_blend", default=None) parser.add_argument("--material_map", default="{}") - parser.add_argument("--sharp_edges_json", default="[]", - help="JSON array of [x, y, z] midpoints (mm) to mark as sharp edges") return parser.parse_args(rest) -def mark_sharp_edges_by_proximity(midpoints_mm: list, threshold_mm: float = 1.0) -> None: - """Mark Blender mesh edges as sharp based on proximity to OCC-derived midpoints. - - midpoints_mm: list of [x, y, z] in mm (from OCC coordinate space). - After STL import + scale-apply (mm→m), Blender vertices are in meters, so we - convert the edge midpoint back to mm before comparing. - threshold_mm: snap distance in mm (default 1.0 mm). - """ - if not midpoints_mm: - return - - import bpy # type: ignore[import] - import math - - for obj in bpy.data.objects: - if obj.type != "MESH": - continue - mesh = obj.data - # Blender 4.1+ removed use_auto_smooth — use shade_smooth_by_angle instead - bpy.context.view_layer.objects.active = obj - obj.select_set(True) - try: - bpy.ops.object.shade_smooth_by_angle(angle=math.radians(30)) - except Exception: - pass # fallback: stay flat-shaded - mw = obj.matrix_world - for edge in mesh.edges: - v1 = mw @ mesh.vertices[edge.vertices[0]].co - v2 = mw @ mesh.vertices[edge.vertices[1]].co - # Convert Blender meters → mm for comparison - mid_mm = [ - (v1.x + v2.x) / 2 * 1000, - (v1.y + v2.y) / 2 * 1000, - (v1.z + v2.z) / 2 * 1000, - ] - for hint in midpoints_mm: - dist_sq = sum((a - b) ** 2 for a, b in zip(mid_mm, hint)) - if dist_sq < threshold_mm ** 2: - edge.use_edge_sharp = True - break - - def main() -> None: args = parse_args() material_map: dict = json.loads(args.material_map) - sharp_edge_midpoints: list = json.loads(args.sharp_edges_json) import bpy # type: ignore[import] + import math as _math # Clean scene bpy.ops.wm.read_factory_settings(use_empty=True) - # Import STL (bpy.ops.wm.stl_import is the Blender 4.0+ API) - bpy.ops.wm.stl_import(filepath=args.stl_path) - - # Scale mm → m - for obj in bpy.context.selected_objects: - obj.scale = (0.001, 0.001, 0.001) - bpy.context.view_layer.objects.active = obj - bpy.ops.object.transform_apply(scale=True) + # Import geometry GLB from export_step_to_gltf.py (already in metres, Y-up) + bpy.ops.import_scene.gltf(filepath=args.glb_path) + print(f"Imported geometry GLB: {args.glb_path} " + f"({len([o for o in bpy.data.objects if o.type == 'MESH'])} mesh objects)") # Apply smooth shading with 30° angle threshold (Blender 4.1+ API) - import math as _math for obj in bpy.data.objects: if obj.type == "MESH": bpy.context.view_layer.objects.active = obj @@ -108,37 +60,30 @@ def main() -> None: except Exception: pass - # Mark sharp edges for better UV seams - if sharp_edge_midpoints: - mark_sharp_edges_by_proximity(sharp_edge_midpoints) - print(f"Marked sharp edges from {len(sharp_edge_midpoints)} hint points") - # Apply asset library materials if provided. - # link=False (append) is required for GLB export: the GLTF exporter can only - # traverse local (appended) Principled BSDF node trees to extract PBR values. - # Linked materials are external references whose node data is not accessible. + # link=False (append) is required: the GLTF exporter can only traverse + # local (appended) Principled BSDF node trees to extract PBR values. if args.asset_library_blend and material_map: import os sys.path.insert(0, os.path.dirname(__file__)) from asset_library import apply_asset_library_materials apply_asset_library_materials(args.asset_library_blend, material_map, link=False) - # Export GLB with full PBR material data - # Note: export_colors was removed in Blender 4.x — do not pass it. + # Export production GLB with full PBR material data try: bpy.ops.export_scene.gltf( filepath=args.output_path, export_format="GLB", export_apply=True, use_selection=False, - export_materials="EXPORT", # export all materials (Principled BSDF → glTF PBR) - export_image_format="AUTO", # embed textures (base color, normal, roughness maps) + export_materials="EXPORT", + export_image_format="AUTO", ) except Exception as exc: print(f"GLB export failed: {exc}", file=sys.stderr) sys.exit(1) - print(f"GLB exported to {args.output_path}") + print(f"Production GLB exported to {args.output_path}") try: diff --git a/render-worker/scripts/export_step_to_gltf.py b/render-worker/scripts/export_step_to_gltf.py new file mode 100644 index 0000000..592d3c8 --- /dev/null +++ b/render-worker/scripts/export_step_to_gltf.py @@ -0,0 +1,232 @@ +"""OCC-native STEP → GLB export script. + +Reads a STEP file via OCP/XCAF (preserving part names and embedded colors), +tessellates with BRepMesh, optionally applies per-part hex colors, and writes +a binary GLB in meters (Y-up, glTF convention). + +No Blender required. Uses the same OCP bindings that cadquery ships with. + +Usage: + python3 export_step_to_gltf.py \ + --step_path /path/to/file.stp \ + --output_path /path/to/output.glb \ + [--linear_deflection 0.1] \ + [--angular_deflection 0.5] \ + [--color_map '{"RingInner": "#4C9BE8", "RingOuter": "#E85B4C"}'] + +Exit 0 on success, exit 1 on failure. +""" +from __future__ import annotations + +import argparse +import json +import sys +import traceback +from pathlib import Path + +PALETTE_HEX = [ + "#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8", + "#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8", +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--step_path", required=True) + parser.add_argument("--output_path", required=True) + parser.add_argument( + "--linear_deflection", type=float, default=0.1, + help="OCC linear deflection for tessellation (mm). Smaller = finer mesh. Default 0.1", + ) + parser.add_argument( + "--angular_deflection", type=float, default=0.5, + help="OCC angular deflection (radians). Default 0.5", + ) + parser.add_argument( + "--color_map", default="{}", + help='JSON dict mapping part name → hex color, e.g. \'{"Ring": "#4C9BE8"}\'', + ) + return parser.parse_args() + + +def _hex_to_occ_color(hex_color: str): + """Convert '#RRGGBB' → Quantity_Color (linear float).""" + from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB + h = hex_color.lstrip("#") + if len(h) < 6: + return Quantity_Color(0.7, 0.7, 0.7, Quantity_TOC_RGB) + r = int(h[0:2], 16) / 255.0 + g = int(h[2:4], 16) / 255.0 + b = int(h[4:6], 16) / 255.0 + return Quantity_Color(r, g, b, Quantity_TOC_RGB) + + +def _apply_color_map(shape_tool, color_tool, free_labels, color_map: dict) -> None: + """Apply hex colors from color_map to matching shapes by name (case-insensitive substring).""" + from OCP.TDF import TDF_LabelSequence + from OCP.TDataStd import TDataStd_Name + from OCP.XCAFDoc import XCAFDoc_ShapeTool + + # XCAFDoc_ColorType: XCAFDoc_ColorGen=0, XCAFDoc_ColorSurf=1, XCAFDoc_ColorCurv=2 + try: + from OCP.XCAFDoc import XCAFDoc_ColorSurf as COLOR_SURF + except ImportError: + COLOR_SURF = 1 # integer fallback + + def _visit(label) -> None: + name_attr = TDataStd_Name() + name = "" + if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr): + name = name_attr.Get().ToExtString() + + if name: + for part_name, hex_color in color_map.items(): + if part_name.lower() in name.lower() or name.lower() in part_name.lower(): + color_tool.SetColor(label, _hex_to_occ_color(hex_color), COLOR_SURF) + break + + components = TDF_LabelSequence() + XCAFDoc_ShapeTool.GetComponents_s(label, components) + for i in range(1, components.Length() + 1): + _visit(components.Value(i)) + + for i in range(1, free_labels.Length() + 1): + _visit(free_labels.Value(i)) + + +def _apply_palette_colors(shape_tool, color_tool, free_labels) -> None: + """Assign palette colors to leaf shapes when no color_map is provided.""" + from OCP.TDF import TDF_LabelSequence + from OCP.XCAFDoc import XCAFDoc_ShapeTool + + try: + from OCP.XCAFDoc import XCAFDoc_ColorSurf as COLOR_SURF + except ImportError: + COLOR_SURF = 1 + + leaves: list = [] + + def _collect(label) -> None: + components = TDF_LabelSequence() + XCAFDoc_ShapeTool.GetComponents_s(label, components) + if components.Length() == 0: + leaves.append(label) + else: + for i in range(1, components.Length() + 1): + _collect(components.Value(i)) + + for i in range(1, free_labels.Length() + 1): + _collect(free_labels.Value(i)) + + for idx, label in enumerate(leaves): + occ_color = _hex_to_occ_color(PALETTE_HEX[idx % len(PALETTE_HEX)]) + color_tool.SetColor(label, occ_color, COLOR_SURF) + + +def main() -> None: + args = parse_args() + color_map: dict = json.loads(args.color_map) + + from OCP.STEPCAFControl import STEPCAFControl_Reader + from OCP.TDocStd import TDocStd_Document + from OCP.XCAFApp import XCAFApp_Application + from OCP.XCAFDoc import XCAFDoc_DocumentTool + from OCP.TCollection import TCollection_ExtendedString, TCollection_AsciiString + from OCP.TDF import TDF_LabelSequence + from OCP.BRepMesh import BRepMesh_IncrementalMesh + from OCP.IFSelect import IFSelect_RetDone + from OCP.Message import Message_ProgressRange + + # --- Init XDE document --- + app = XCAFApp_Application.GetApplication_s() + doc = TDocStd_Document(TCollection_ExtendedString("MDTV-CAF")) + app.InitDocument(doc) + + # --- Read STEP into XDE (preserves part names + embedded colors) --- + reader = STEPCAFControl_Reader() + reader.SetNameMode(True) + reader.SetColorMode(True) + reader.SetLayerMode(True) + status = reader.ReadFile(args.step_path) + if status != IFSelect_RetDone: + print(f"ERROR: STEPCAFControl_Reader failed (status={status})", file=sys.stderr) + sys.exit(1) + reader.Transfer(doc) + + shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()) + color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main()) + + # --- Tessellate all free shapes --- + free_labels = TDF_LabelSequence() + shape_tool.GetFreeShapes(free_labels) + print(f"Found {free_labels.Length()} root shape(s), tessellating " + f"(linear={args.linear_deflection}mm, angular={args.angular_deflection}rad) …") + + for i in range(1, free_labels.Length() + 1): + shape = shape_tool.GetShape_s(free_labels.Value(i)) + if not shape.IsNull(): + BRepMesh_IncrementalMesh( + shape, + args.linear_deflection, + False, # isRelative + args.angular_deflection, + True, # isInParallel + ) + + # --- Apply colors --- + if color_map: + _apply_color_map(shape_tool, color_tool, free_labels, color_map) + print(f"Applied color_map ({len(color_map)} entries)") + else: + _apply_palette_colors(shape_tool, color_tool, free_labels) + print("Applied palette colors (no color_map provided)") + + # --- Scale shapes mm → m before GLB export --- + # RWMesh_CoordinateSystemConverter is not wrapped in OCP Python bindings. + # Pre-scale each free shape by 0.001 (mm → m) using BRepBuilderAPI_Transform. + from OCP.gp import gp_Trsf + from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform + + trsf = gp_Trsf() + trsf.SetScaleFactor(0.001) + + for i in range(1, free_labels.Length() + 1): + label = free_labels.Value(i) + orig_shape = shape_tool.GetShape_s(label) + if not orig_shape.IsNull(): + scaled = BRepBuilderAPI_Transform(orig_shape, trsf, True).Shape() + shape_tool.SetShape(label, scaled) + + print("Shapes scaled mm → m") + + # --- Export GLB via RWGltf_CafWriter --- + from OCP.RWGltf import RWGltf_CafWriter + + writer = RWGltf_CafWriter(TCollection_AsciiString(args.output_path), True) # True = binary GLB + # Z-up → Y-up rotation is applied by RWGltf_CafWriter by default (OCC 7.6+). + + # Perform export + try: + from OCP.TColStd import TColStd_IndexedDataMapOfStringString + metadata = TColStd_IndexedDataMapOfStringString() + ok = writer.Perform(doc, metadata, Message_ProgressRange()) + except TypeError: + # Older API without metadata dict + ok = writer.Perform(doc, Message_ProgressRange()) + + out = Path(args.output_path) + if not ok or not out.exists() or out.stat().st_size == 0: + print(f"ERROR: RWGltf_CafWriter.Perform returned ok={ok}, file exists={out.exists()}", + file=sys.stderr) + sys.exit(1) + + print(f"GLB exported: {out.name} ({out.stat().st_size // 1024} KB)") + + +try: + main() +except SystemExit: + raise +except Exception: + traceback.print_exc() + sys.exit(1) diff --git a/render-worker/scripts/still_render.py b/render-worker/scripts/still_render.py index 13a863f..990f41f 100644 --- a/render-worker/scripts/still_render.py +++ b/render-worker/scripts/still_render.py @@ -87,23 +87,7 @@ def _apply_smooth(part_obj, angle_deg): import re as _re -def _scale_mm_to_m(parts): - """Scale imported STL objects from mm to Blender metres (×0.001). - - STEP/STL coordinates are in mm; Blender's default unit is metres. - Without scaling a 50 mm part appears as 50 m inside Blender — way too large - relative to any template environment designed in metric units. - """ - if not parts: - return - bpy.ops.object.select_all(action='DESELECT') - for p in parts: - p.scale = (0.001, 0.001, 0.001) - p.location *= 0.001 - p.select_set(True) - bpy.context.view_layer.objects.active = parts[0] - bpy.ops.object.transform_apply(scale=True, location=False, rotation=False) - print(f"[still_render] scaled {len(parts)} parts mm→m (×0.001)") +# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already. def _apply_rotation(parts, rx, ry, rz): @@ -209,85 +193,35 @@ def _mark_sharp_and_seams(obj, smooth_angle_deg: float, sharp_edge_midpoints=Non bpy.ops.object.mode_set(mode='OBJECT') -def _import_stl(stl_file): - """Import STL into Blender, using per-part STLs if available. +def _import_glb(glb_file): + """Import OCC-generated GLB into Blender. - Checks for {stl_stem}_parts/manifest.json next to the STL file. - - Per-part mode: imports each part STL, names Blender object after STEP part name. - - Fallback: imports combined STL and splits by loose geometry. - - Returns list of Blender mesh objects, centred at origin. + OCC exports one mesh object per STEP part, already in metres. + Returns list of Blender mesh objects, centred at world origin. """ - stl_dir = os.path.dirname(stl_file) - stl_stem = os.path.splitext(os.path.basename(stl_file))[0] - parts_dir = os.path.join(stl_dir, stl_stem + "_parts") - manifest_path = os.path.join(parts_dir, "manifest.json") + bpy.ops.object.select_all(action='DESELECT') + bpy.ops.import_scene.gltf(filepath=glb_file) + parts = [o for o in bpy.context.selected_objects if o.type == 'MESH'] - parts = [] - - if os.path.isfile(manifest_path): - # ── Per-part mode ──────────────────────────────────────────────── - try: - with open(manifest_path, "r") as f: - manifest = json.loads(f.read()) - part_entries = manifest.get("parts", []) - except Exception as e: - print(f"[still_render] WARNING: failed to read manifest: {e}") - part_entries = [] - - if part_entries: - for entry in part_entries: - part_file = os.path.join(parts_dir, entry["file"]) - part_name = entry["name"] - if not os.path.isfile(part_file): - print(f"[still_render] WARNING: part STL missing: {part_file}") - continue - - bpy.ops.object.select_all(action='DESELECT') - bpy.ops.wm.stl_import(filepath=part_file) - imported = bpy.context.selected_objects - if imported: - obj = imported[0] - obj.name = part_name - if obj.data: - obj.data.name = part_name - parts.append(obj) - - if parts: - print(f"[still_render] imported {len(parts)} named parts from per-part STLs") - - # ── Fallback: combined STL + separate by loose ─────────────────────── if not parts: - bpy.ops.wm.stl_import(filepath=stl_file) - obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None - if obj is None: - print(f"ERROR: No objects imported from {stl_file}") - sys.exit(1) + print(f"ERROR: No mesh objects imported from {glb_file}") + sys.exit(1) - bpy.context.view_layer.objects.active = obj - bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS') - obj.location = (0.0, 0.0, 0.0) + print(f"[still_render] imported {len(parts)} part(s) from GLB: " + f"{[p.name for p in parts[:5]]}") - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.separate(type='LOOSE') - bpy.ops.object.mode_set(mode='OBJECT') - - parts = list(bpy.context.selected_objects) - print(f"[still_render] fallback: separated into {len(parts)} part(s)") - return parts - - # ── Centre per-part imports at origin (combined bbox) ──────────────── + # Centre combined bbox at world origin all_corners = [] for p in parts: all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box) if all_corners: mins = Vector((min(v.x for v in all_corners), - min(v.y for v in all_corners), - min(v.z for v in all_corners))) + min(v.y for v in all_corners), + min(v.z for v in all_corners))) maxs = Vector((max(v.x for v in all_corners), - max(v.y for v in all_corners), - max(v.z for v in all_corners))) + max(v.y for v in all_corners), + max(v.z for v in all_corners))) center = (mins + maxs) * 0.5 for p in parts: p.location -= center @@ -376,7 +310,7 @@ def main(): argv = sys.argv args = argv[argv.index("--") + 1:] - stl_path = args[0] + glb_path = args[0] output_path = args[1] width = int(args[2]) height = int(args[3]) @@ -460,10 +394,8 @@ def main(): # Find or create target collection target_col = _ensure_collection(target_collection) - # Import and split STL - parts = _import_stl(stl_path) - # Scale mm→m: STEP coords are mm, Blender default unit is metres - _scale_mm_to_m(parts) + # Import OCC GLB (already in metres, one object per STEP part) + parts = _import_glb(glb_path) # Apply render position rotation (before camera/bbox calculations) _apply_rotation(parts, rotation_x, rotation_y, rotation_z) # Apply OCC topology-based shading overrides @@ -562,9 +494,7 @@ def main(): needs_auto_camera = True bpy.ops.wm.read_factory_settings(use_empty=True) - parts = _import_stl(stl_path) - # Scale mm→m: STEP coords are mm, Blender default unit is metres - _scale_mm_to_m(parts) + parts = _import_glb(glb_path) # Apply render position rotation (before camera/bbox calculations) _apply_rotation(parts, rotation_x, rotation_y, rotation_z) # Apply OCC topology-based shading overrides @@ -839,7 +769,7 @@ def main(): draw.rectangle([0, 0, W - 1, bar_h - 1], fill=(0, 137, 61, 255)) # Model name strip at bottom - model_name = os.path.splitext(os.path.basename(stl_path))[0] + model_name = os.path.splitext(os.path.basename(glb_path))[0] label_h = max(20, H // 20) img.alpha_composite( Image.new("RGBA", (W, label_h), (30, 30, 30, 180)), diff --git a/render-worker/scripts/turntable_render.py b/render-worker/scripts/turntable_render.py index f5e8888..3b0f107 100644 --- a/render-worker/scripts/turntable_render.py +++ b/render-worker/scripts/turntable_render.py @@ -138,23 +138,7 @@ def _set_fcurves_linear(action): kp.interpolation = 'LINEAR' -def _scale_mm_to_m(parts): - """Scale imported STL objects from mm to Blender metres (×0.001). - - STEP/STL coordinates are in mm; Blender's default unit is metres. - Without scaling a 50 mm part appears as 50 m inside Blender — way too large - relative to any template environment designed in metric units. - """ - if not parts: - return - bpy.ops.object.select_all(action='DESELECT') - for p in parts: - p.scale = (0.001, 0.001, 0.001) - p.location *= 0.001 - p.select_set(True) - bpy.context.view_layer.objects.active = parts[0] - bpy.ops.object.transform_apply(scale=True, location=False, rotation=False) - print(f"[turntable_render] scaled {len(parts)} parts mm→m (×0.001)") +# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already. def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None: @@ -179,85 +163,35 @@ def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None: obj.data.auto_smooth_angle = threshold_rad -def _import_stl(stl_file): - """Import STL into Blender, using per-part STLs if available. +def _import_glb(glb_file): + """Import OCC-generated GLB into Blender. - Checks for {stl_stem}_parts/manifest.json next to the STL file. - - Per-part mode: imports each part STL, names Blender object after STEP part name. - - Fallback: imports combined STL and splits by loose geometry. - - Returns list of Blender mesh objects, centred at origin. + OCC exports one mesh object per STEP part, already in metres. + Returns list of Blender mesh objects, centred at world origin. """ - stl_dir = os.path.dirname(stl_file) - stl_stem = os.path.splitext(os.path.basename(stl_file))[0] - parts_dir = os.path.join(stl_dir, stl_stem + "_parts") - manifest_path = os.path.join(parts_dir, "manifest.json") + bpy.ops.object.select_all(action='DESELECT') + bpy.ops.import_scene.gltf(filepath=glb_file) + parts = [o for o in bpy.context.selected_objects if o.type == 'MESH'] - parts = [] - - if os.path.isfile(manifest_path): - # ── Per-part mode ──────────────────────────────────────────────── - try: - with open(manifest_path, "r") as f: - manifest = json.loads(f.read()) - part_entries = manifest.get("parts", []) - except Exception as e: - print(f"[turntable_render] WARNING: failed to read manifest: {e}") - part_entries = [] - - if part_entries: - for entry in part_entries: - part_file = os.path.join(parts_dir, entry["file"]) - part_name = entry["name"] - if not os.path.isfile(part_file): - print(f"[turntable_render] WARNING: part STL missing: {part_file}") - continue - - bpy.ops.object.select_all(action='DESELECT') - bpy.ops.wm.stl_import(filepath=part_file) - imported = bpy.context.selected_objects - if imported: - obj = imported[0] - obj.name = part_name - if obj.data: - obj.data.name = part_name - parts.append(obj) - - if parts: - print(f"[turntable_render] imported {len(parts)} named parts from per-part STLs") - - # ── Fallback: combined STL + separate by loose ─────────────────────── if not parts: - bpy.ops.wm.stl_import(filepath=stl_file) - obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None - if obj is None: - print(f"ERROR: No objects imported from {stl_file}") - sys.exit(1) + print(f"ERROR: No mesh objects imported from {glb_file}") + sys.exit(1) - bpy.context.view_layer.objects.active = obj - bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS') - obj.location = (0.0, 0.0, 0.0) + print(f"[turntable_render] imported {len(parts)} part(s) from GLB: " + f"{[p.name for p in parts[:5]]}") - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.separate(type='LOOSE') - bpy.ops.object.mode_set(mode='OBJECT') - - parts = list(bpy.context.selected_objects) - print(f"[turntable_render] fallback: separated into {len(parts)} part(s)") - return parts - - # ── Centre per-part imports at origin (combined bbox) ──────────────── + # Centre combined bbox at world origin all_corners = [] for p in parts: all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box) if all_corners: mins = Vector((min(v.x for v in all_corners), - min(v.y for v in all_corners), - min(v.z for v in all_corners))) + min(v.y for v in all_corners), + min(v.z for v in all_corners))) maxs = Vector((max(v.x for v in all_corners), - max(v.y for v in all_corners), - max(v.z for v in all_corners))) + max(v.y for v in all_corners), + max(v.z for v in all_corners))) center = (mins + maxs) * 0.5 for p in parts: p.location -= center @@ -347,7 +281,7 @@ def main(): # Everything after "--" is our args args = argv[argv.index("--") + 1:] - stl_path = args[0] + glb_path = args[0] frames_dir = args[1] frame_count = int(args[2]) degrees = int(args[3]) @@ -427,10 +361,8 @@ def main(): # Find or create target collection target_col = _ensure_collection(target_collection) - # Import and split STL - parts = _import_stl(stl_path) - # Scale mm→m: STEP coords are mm, Blender default unit is metres - _scale_mm_to_m(parts) + # Import OCC GLB (already in metres, one object per STEP part) + parts = _import_glb(glb_path) # Apply render position rotation before material/camera setup _apply_rotation(parts, rotation_x, rotation_y, rotation_z) # Apply OCC topology-based shading overrides @@ -508,9 +440,7 @@ def main(): needs_auto_camera = True bpy.ops.wm.read_factory_settings(use_empty=True) - parts = _import_stl(stl_path) - # Scale mm→m: STEP coords are mm, Blender default unit is metres - _scale_mm_to_m(parts) + parts = _import_glb(glb_path) # Apply render position rotation before material/camera setup _apply_rotation(parts, rotation_x, rotation_y, rotation_z) # Apply OCC topology-based shading overrides