# Realistic Render View — Implementation Plan ## Context Add a **"Render"** feature to Fluency CAD that opens a separate window for photorealistic rendering of the selected component or assembly (like KeyShot/Cacles). **Constraints:** - Open in a **new window** — don't clutter the workspace - **Keep existing OCCRenderer** for the interactive 3D viewport — untouched - Render backend must be a **separate, swappable module** so we can change the renderer later - Use **Mitsuba 3** as the initial backend (`pip install mitsuba`, ~50MB) --- ## Architecture ``` ┌─────────────────────────────────────────────────────────┐ │ Main Fluency Window (existing OCCRenderer — untouched) │ │ │ │ [Select body/assembly] → [Click "Render"] │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────┐ │ │ │ RenderWindow (separate QMainWindow)│ │ │ │ │ │ │ │ ┌───────────────────────────────┐ │ │ │ │ │ RenderBackend (ABC) │ │ │ │ │ │ ├─ MitsubaBackend ← current │ │ │ │ │ │ ├─ (future: BlenderBackend) │ │ │ │ │ │ └─ (future: CyclesBackend) │ │ │ │ │ └───────────────────────────────┘ │ │ │ │ │ │\n│ │ [Image preview] [Progress bar] │ │ │ │ [Material ▾] [Quality ▾] [Render] │ │ │ │ [Export PNG] │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ ``` ### Swappable Backend Interface ```python from abc import ABC, abstractmethod from dataclasses import dataclass import numpy as np @dataclass class RenderMaterial: name: str color: tuple[float, float, float] = (0.7, 0.7, 0.7) metallic: float = 0.0 # 0.0–1.0 roughness: float = 0.5 # 0.0–1.0 bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic @dataclass class RenderCamera: origin: tuple[float, float, float] = (100, 100, 100) target: tuple[float, float, float] = (0, 0, 0) up: tuple[float, float, float] = (0, 0, 1) fov: float = 45.0 @dataclass class RenderSettings: width: int = 1920 height: int = 1080 spp: int = 256 # samples per pixel max_depth: int = 8 # path tracer bounces class RenderBackend(ABC): """Swap this to change the rendering engine.""" @abstractmethod def render(self, obj_path: str, material: RenderMaterial, camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ... @abstractmethod def render_preview(self, obj_path: str, material: RenderMaterial, camera: RenderCamera, settings: RenderSettings) -> np.ndarray: ... @abstractmethod def name(self) -> str: ... ``` Switching backends later = write a new class implementing `RenderBackend`. One import change. --- ## Mitsuba 3 Backend ### Why Mitsuba | Feature | Status | |---------|--------| | `pip install mitsuba` | Single install, no system deps | | True path tracing | GI, caustics, spectral rendering | | PBR materials | `roughconductor`, `roughdielectric`, `diffuse`, `plastic` | | Python dict API | Build scenes programmatically, no XML | | CPU + GPU backends | `scalar_rgb` (CPU), `cuda_rgb` (NVIDIA) | | Output formats | PNG, EXR (HDR) with tonemapping | ### OCC → OBJ Conversion Path ```python from OCP.BRepMesh import BRepMesh_IncrementalMesh from OCP.StlAPI import StlAPI_Writer from OCP.BRep import BRep_Builder import tempfile, os def occ_shape_to_obj(shape, obj_path: str, linear_deflection: float = 0.1): """Tessellate OCC shape and write as OBJ for Mitsuba.""" tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True) tess.Perform() # Write STL (reliable), then convert to OBJ via trimesh or direct writer = StlAPI_Writer() writer.SetASCIIMode(False) stl_path = obj_path.replace(".obj", ".stl") writer.Write(shape, stl_path) # Mitsuba can read STL directly, or we convert to OBJ return stl_path ``` ### Mitsuba Scene Construction ```python import mitsuba as mi mi.set_variant("scalar_rgb") def build_scene(mesh_path: str, material: RenderMaterial, camera: RenderCamera, settings: RenderSettings) -> mi.Scene: # Map our material to Mitsuba BSDF bsdf_map = { "diffuse": {"type": "diffuse", "reflectance": {"type": "rgb", "value": material.color}}, "roughconductor": { "type": "roughconductor", "material": "copper", # or铝, 钢, etc. "alpha": material.roughness, }, "roughdielectric": { "type": "roughdielectric", "int_ior": 1.5, "alpha": material.roughness, }, "plastic": { "type": "plastic", "diffuse_reflectance": {"type": "rgb", "value": material.color}, "int_ior": 1.5, }, } return mi.load_dict({ "type": "scene", "integrator": {"type": "path", "max_depth": settings.max_depth}, "sensor": { "type": "perspective", "fov": camera.fov, "to_world": mi.ScalarTransform4f.look_at( origin=camera.origin, target=camera.target, up=camera.up ), "film": {"type": "hdrfilm", "width": settings.width, "height": settings.height}, "sampler": {"type": "independent", "sample_count": settings.spp}, }, "emitter": {"type": "constant"}, "shape": { "type": "stl", # or "obj" "filename": mesh_path, "bsdf": bsdf_map.get(material.bsdf_type, bsdf_map["diffuse"]), }, }) ``` --- ## Files to Create/Modify | File | Action | Description | |------|--------|-------------| | `src/fluency/rendering/render_backend.py` | **NEW** | Abstract `RenderBackend`, `RenderMaterial`, `RenderCamera`, `RenderSettings` | | `src/fluency/rendering/mitsuba_backend.py` | **NEW** | `MitsubaBackend(RenderBackend)` implementation | | `src/fluency/rendering/occ_to_mesh.py` | **NEW** | OCC `TopoDS_Shape` → STL/OBJ tessellation | | `src/fluency/rendering/material_presets.py` | **NEW** | Preset library: Steel, Aluminum, Brass, Chrome, Plastic, Rubber, Wood | | `src/fluency/ui/render_window.py` | **NEW** | `RenderWindow(QMainWindow)` — image preview, material/quality controls, render/export | | `src/fluency/ui/main_window.py` | MODIFY | Add "Render" button → get selected shapes → open `RenderWindow` | --- ## UI: RenderWindow ``` ┌──────────────────────────────────────────┐ │ Render — [Part Name] [─][□][×] │ ├──────────────────────────────────────────┤ │ │ │ ┌──────────────────────────────────┐ │ │ │ │ │ │ │ Rendered Image Preview │ │ │ │ (QLabel with QPixmap) │ │ │ │ │ │ │ └──────────────────────────────────┘ │ │ │ │ Material: [Steel ▾] │ │ Quality: [256 SPP ▾] │ │ Resolution: [1920×1080 ▾] │ │ │ │ [▶ Render] [⏹ Cancel] [💾 Export PNG] │ │ │ │ ████████████████░░░░░░ 65% (23s left) │ └──────────────────────────────────────────┘ ``` - **Preview**: progressive refinement (low SPP first, then ramp) - **Cancel**: kill Mitsuba render thread - **Export**: save to PNG/EXR --- ## Material Presets | Preset | Color | Metallic | Roughness | BSDF | |--------|-------|----------|-----------|------| | Brushed Steel | (0.65, 0.67, 0.72) | 0.9 | 0.35 | roughconductor | | Polished Chrome | (0.8, 0.8, 0.8) | 1.0 | 0.05 | roughconductor | | Brushed Aluminum | (0.75, 0.75, 0.75) | 0.85 | 0.25 | roughconductor | | Copper | (0.95, 0.64, 0.54) | 0.95 | 0.15 | roughconductor | | Gold | (1.0, 0.76, 0.33) | 1.0 | 0.1 | roughconductor | | Blackened Steel | (0.15, 0.15, 0.17) | 0.8 | 0.4 | roughconductor | | Matte Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.6 | plastic | | Glossy Plastic | (0.2, 0.5, 0.8) | 0.0 | 0.1 | plastic | | White Nylon | (0.85, 0.85, 0.83) | 0.0 | 0.45 | plastic | | Black ABS | (0.05, 0.05, 0.05) | 0.0 | 0.35 | plastic | | Red PA12 | (0.75, 0.08, 0.08) | 0.0 | 0.4 | plastic | | Rubber | (0.1, 0.1, 0.1) | 0.0 | 0.9 | diffuse | | Ceramic White | (0.92, 0.91, 0.88) | 0.0 | 0.15 | dielectric | | Glass | (0.95, 0.95, 0.95) | 0.0 | 0.0 | dielectric | | Wood | (0.6, 0.4, 0.2) | 0.0 | 0.7 | diffuse | **Note:** Mitsuba pip installs don't include spectral metal data files (iron.spd, copper.spd, etc.), so metal presets use `material="none"` with `specular_reflectance` set to the metal color instead. --- ## Risks & Mitigations | Risk | Mitigation | |------|-----------| | Mitsuba not installed | Graceful error: "pip install mitsuba" shown in UI | | Slow CPU rendering | Default to low SPP (64) for preview; offer GPU variant if CUDA available | | Large meshes slow to tessellate | Progress indicator; optional mesh decimation | | Mitsuba STL/OCC compatibility | Test tessellation quality; tune `linear_deflection` | --- ## Estimated Effort - **Phase 1** (abstract backend + OCC→mesh + Mitsuba impl): ~4-6 hours - **Phase 2** (render window UI + material presets): ~3-4 hours - **Phase 3** (polish, export, swap test): ~2-3 hours - **Total**: ~9-13 hours