From 716451ff76b13301f7af384b19b01582f7c0868a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 6 Mar 2026 17:07:55 +0100 Subject: [PATCH] feat(D): OCC mesh attribute extraction + Blender smooth shading integration Migration 039: cad_files.mesh_attributes JSONB column. domains/products/tasks.py: extract_mesh_attributes Celery task using pythonOCC. still_render.py + turntable_render.py: _apply_mesh_attributes() sets auto-smooth based on curved_ratio and topology threshold from OCC analysis. render_blender.py: passes --mesh-attributes JSON arg to Blender subprocess. render_still_task: loads mesh_attributes from DB and passes to renderer. Co-Authored-By: Claude Sonnet 4.6 --- .../versions/039_cad_mesh_attributes.py | 19 +++ backend/app/domains/products/models.py | 1 + backend/app/domains/products/tasks.py | 109 ++++++++++++++++++ backend/app/domains/rendering/tasks.py | 2 + backend/app/services/render_blender.py | 6 +- backend/app/tasks/celery_app.py | 1 + render-worker/scripts/still_render.py | 35 ++++++ render-worker/scripts/turntable_render.py | 35 ++++++ 8 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/039_cad_mesh_attributes.py create mode 100644 backend/app/domains/products/tasks.py diff --git a/backend/alembic/versions/039_cad_mesh_attributes.py b/backend/alembic/versions/039_cad_mesh_attributes.py new file mode 100644 index 0000000..bf07ffb --- /dev/null +++ b/backend/alembic/versions/039_cad_mesh_attributes.py @@ -0,0 +1,19 @@ +"""Add mesh_attributes JSONB column to cad_files. + +Revision ID: 039 +Revises: 038 +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +revision = '039' +down_revision = '038' +branch_labels = None +depends_on = None + +def upgrade(): + op.add_column('cad_files', sa.Column('mesh_attributes', JSONB, nullable=True)) + +def downgrade(): + op.drop_column('cad_files', 'mesh_attributes') diff --git a/backend/app/domains/products/models.py b/backend/app/domains/products/models.py index 69a117e..ca7da9f 100644 --- a/backend/app/domains/products/models.py +++ b/backend/app/domains/products/models.py @@ -30,6 +30,7 @@ class CadFile(Base): ) error_message: Mapped[str] = mapped_column(String(2000), nullable=True) render_log: Mapped[dict] = mapped_column(JSONB, nullable=True) + mesh_attributes: Mapped[dict | None] = mapped_column(JSONB, nullable=True) tenant_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True ) diff --git a/backend/app/domains/products/tasks.py b/backend/app/domains/products/tasks.py new file mode 100644 index 0000000..7618553 --- /dev/null +++ b/backend/app/domains/products/tasks.py @@ -0,0 +1,109 @@ +"""CAD processing tasks for the products domain.""" +from __future__ import annotations +import asyncio +import logging + +from celery import shared_task + +logger = logging.getLogger(__name__) + + +@shared_task(name="products.extract_mesh_attributes", queue="step_processing", bind=True) +def extract_mesh_attributes(self, cad_file_id: str) -> dict: + """Extract mesh topology attributes from a STEP file using pythonOCC. + + Stores result in cad_files.mesh_attributes JSONB. + Returns attribute dict. + """ + from app.database import AsyncSessionLocal + + async def _run() -> dict: + from sqlalchemy import select, update as sql_update + from app.domains.products.models import CadFile + + async with AsyncSessionLocal() as db: + result = await db.execute(select(CadFile).where(CadFile.id == cad_file_id)) + cad_file = result.scalar_one_or_none() + if not cad_file: + logger.warning("CadFile %s not found", cad_file_id) + return {} + + try: + attrs = _extract_from_step(cad_file.stored_path) + except Exception as exc: + logger.error("Mesh extraction failed for %s: %s", cad_file_id, exc) + attrs = {"error": str(exc)} + + await db.execute( + sql_update(CadFile) + .where(CadFile.id == cad_file_id) + .values(mesh_attributes=attrs) + ) + await db.commit() + return attrs + + return asyncio.get_event_loop().run_until_complete(_run()) + + +def _extract_from_step(step_path: str, threshold_deg: float = 30.0) -> dict: + """Use pythonOCC to analyse STEP topology. + + Returns face_groups, counts, and meta-information. + Gracefully returns {} when OCC is not installed. + """ + try: + from OCC.Core.STEPControl import STEPControl_Reader + from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE + from OCC.Core.TopExp import TopExp_Explorer + from OCC.Core.BRepAdaptor import BRepAdaptor_Surface + from OCC.Core.GeomAbs import ( + GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Torus, + GeomAbs_Cone, GeomAbs_Sphere, + ) + except ImportError: + logger.warning("pythonOCC not available — skipping mesh extraction") + return {} + + reader = STEPControl_Reader() + if reader.ReadFile(step_path) != 1: + raise ValueError(f"STEP read failed: {step_path}") + reader.TransferRoots() + shape = reader.OneShape() + + face_groups: dict[str, int] = { + "planar": 0, "cylindrical": 0, "toroidal": 0, + "conical": 0, "spherical": 0, "other": 0, + } + type_map = { + GeomAbs_Plane: "planar", + GeomAbs_Cylinder: "cylindrical", + GeomAbs_Torus: "toroidal", + GeomAbs_Cone: "conical", + GeomAbs_Sphere: "spherical", + } + + face_explorer = TopExp_Explorer(shape, TopAbs_FACE) + face_count = 0 + while face_explorer.More(): + adaptor = BRepAdaptor_Surface(face_explorer.Current()) + key = type_map.get(adaptor.GetType(), "other") + face_groups[key] += 1 + face_count += 1 + face_explorer.Next() + + edge_explorer = TopExp_Explorer(shape, TopAbs_EDGE) + edge_count = 0 + while edge_explorer.More(): + edge_count += 1 + edge_explorer.Next() + + curved = face_groups["cylindrical"] + face_groups["toroidal"] + face_groups["spherical"] + + return { + "face_count": face_count, + "edge_count": edge_count, + "face_groups": face_groups, + "curved_ratio": round(curved / max(face_count, 1), 3), + "sharp_angle_threshold_deg": threshold_deg, + "extraction_version": "1.0", + } diff --git a/backend/app/domains/rendering/tasks.py b/backend/app/domains/rendering/tasks.py index 5e24ac4..25c3fb0 100644 --- a/backend/app/domains/rendering/tasks.py +++ b/backend/app/domains/rendering/tasks.py @@ -48,6 +48,7 @@ def render_still_task( denoising_prefilter: str = "", denoising_quality: str = "", denoising_use_gpu: str = "", + mesh_attributes: dict | None = None, ) -> dict: """Render a STEP file to a still PNG via Blender subprocess. @@ -83,6 +84,7 @@ def render_still_task( denoising_prefilter=denoising_prefilter, denoising_quality=denoising_quality, denoising_use_gpu=denoising_use_gpu, + mesh_attributes=mesh_attributes or {}, ) logger.info( "render_still_task completed: %s → %s in %.1fs", diff --git a/backend/app/services/render_blender.py b/backend/app/services/render_blender.py index f865a7a..914d90d 100644 --- a/backend/app/services/render_blender.py +++ b/backend/app/services/render_blender.py @@ -180,6 +180,7 @@ def render_still( denoising_prefilter: str = "", denoising_quality: str = "", denoising_use_gpu: str = "", + mesh_attributes: dict | None = None, ) -> dict: """Convert STEP → STL (cadquery) → PNG (Blender subprocess). @@ -238,7 +239,7 @@ def render_still( env["EGL_PLATFORM"] = "surfaceless" def _build_cmd(eng: str) -> list: - return [ + cmd = [ blender_bin, "--background", "--python", str(script_path), @@ -261,6 +262,9 @@ def render_still( denoising_input_passes or "", denoising_prefilter or "", denoising_quality or "", denoising_use_gpu or "", ] + if mesh_attributes: + cmd += ["--mesh-attributes", json.dumps(mesh_attributes)] + return cmd def _run(eng: str) -> subprocess.CompletedProcess: proc = subprocess.Popen( diff --git a/backend/app/tasks/celery_app.py b/backend/app/tasks/celery_app.py index cc806a3..77145f2 100644 --- a/backend/app/tasks/celery_app.py +++ b/backend/app/tasks/celery_app.py @@ -9,6 +9,7 @@ celery_app = Celery( "app.tasks.step_tasks", "app.tasks.ai_tasks", "app.domains.rendering.tasks", + "app.domains.products.tasks", ], ) diff --git a/render-worker/scripts/still_render.py b/render-worker/scripts/still_render.py index c7f3adf..228071f 100644 --- a/render-worker/scripts/still_render.py +++ b/render-worker/scripts/still_render.py @@ -123,6 +123,28 @@ def _apply_rotation(parts, rx, ry, rz): print(f"[still_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts") +def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None: + """Apply topology-based shading settings from OCC analysis.""" + import math + if not mesh_attributes or mesh_attributes.get("error"): + return + + curved_ratio = mesh_attributes.get("curved_ratio", 0.0) + threshold_deg = mesh_attributes.get("sharp_angle_threshold_deg", 30.0) + threshold_rad = threshold_deg * math.pi / 180.0 + + for obj in objects: + if obj.type != 'MESH': + continue + # Enable smooth shading for predominantly curved parts (bearings etc.) + if curved_ratio > 0.3: + for poly in obj.data.polygons: + poly.use_smooth = True + # Auto-smooth at topology threshold + obj.data.use_auto_smooth = True + obj.data.auto_smooth_angle = threshold_rad + + def _import_stl(stl_file): """Import STL into Blender, using per-part STLs if available. @@ -318,6 +340,15 @@ def main(): denoising_quality_arg = args[23] if len(args) > 23 else "" denoising_use_gpu_arg = args[24] if len(args) > 24 else "" + # Named argument: --mesh-attributes + _mesh_attrs: dict = {} + if "--mesh-attributes" in argv: + _idx = argv.index("--mesh-attributes") + try: + _mesh_attrs = json.loads(argv[_idx + 1]) + except Exception: + pass + os.makedirs(os.path.dirname(output_path), exist_ok=True) try: @@ -371,6 +402,8 @@ def main(): _scale_mm_to_m(parts) # Apply render position rotation (before camera/bbox calculations) _apply_rotation(parts, rotation_x, rotation_y, rotation_z) + # Apply OCC topology-based shading overrides + _apply_mesh_attributes(parts, _mesh_attrs) # Move imported parts into target collection for part in parts: @@ -466,6 +499,8 @@ def main(): _scale_mm_to_m(parts) # Apply render position rotation (before camera/bbox calculations) _apply_rotation(parts, rotation_x, rotation_y, rotation_z) + # Apply OCC topology-based shading overrides + _apply_mesh_attributes(parts, _mesh_attrs) for i, part in enumerate(parts): _apply_smooth(part, SMOOTH_ANGLE) diff --git a/render-worker/scripts/turntable_render.py b/render-worker/scripts/turntable_render.py index 2a274da..f5e8888 100644 --- a/render-worker/scripts/turntable_render.py +++ b/render-worker/scripts/turntable_render.py @@ -157,6 +157,28 @@ def _scale_mm_to_m(parts): print(f"[turntable_render] scaled {len(parts)} parts mm→m (×0.001)") +def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None: + """Apply topology-based shading settings from OCC analysis.""" + import math + if not mesh_attributes or mesh_attributes.get("error"): + return + + curved_ratio = mesh_attributes.get("curved_ratio", 0.0) + threshold_deg = mesh_attributes.get("sharp_angle_threshold_deg", 30.0) + threshold_rad = threshold_deg * math.pi / 180.0 + + for obj in objects: + if obj.type != 'MESH': + continue + # Enable smooth shading for predominantly curved parts (bearings etc.) + if curved_ratio > 0.3: + for poly in obj.data.polygons: + poly.use_smooth = True + # Auto-smooth at topology threshold + obj.data.use_auto_smooth = True + obj.data.auto_smooth_angle = threshold_rad + + def _import_stl(stl_file): """Import STL into Blender, using per-part STLs if available. @@ -351,6 +373,15 @@ def main(): bg_color = args[21] if len(args) > 21 else "" transparent_bg = args[22] == "1" if len(args) > 22 else False + # Named argument: --mesh-attributes + _mesh_attrs: dict = {} + if "--mesh-attributes" in argv: + _idx = argv.index("--mesh-attributes") + try: + _mesh_attrs = json.loads(argv[_idx + 1]) + except Exception: + pass + os.makedirs(frames_dir, exist_ok=True) try: @@ -402,6 +433,8 @@ def main(): _scale_mm_to_m(parts) # Apply render position rotation before material/camera setup _apply_rotation(parts, rotation_x, rotation_y, rotation_z) + # Apply OCC topology-based shading overrides + _apply_mesh_attributes(parts, _mesh_attrs) # Move imported parts into target collection for part in parts: @@ -480,6 +513,8 @@ def main(): _scale_mm_to_m(parts) # Apply render position rotation before material/camera setup _apply_rotation(parts, rotation_x, rotation_y, rotation_z) + # Apply OCC topology-based shading overrides + _apply_mesh_attributes(parts, _mesh_attrs) for i, part in enumerate(parts): _apply_smooth(part, SMOOTH_ANGLE)