9f1387fe68
- Added undo
254 lines
11 KiB
Markdown
254 lines
11 KiB
Markdown
# 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
|