- added renderer
- Added undo
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
# 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
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Material presets for the render backend.
|
||||
|
||||
Each preset is a RenderMaterial with physically-plausible values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from .render_backend import RenderMaterial
|
||||
|
||||
# ── Preset library ──────────────────────────────────────────────────────
|
||||
# Note: Mitsuba pip installs don't include spectral metal data files,
|
||||
# so metal_preset is not used. Instead, metals use material="none" with
|
||||
# specular_reflectance set to the metal color.
|
||||
|
||||
PRESETS: Dict[str, RenderMaterial] = {
|
||||
# ── Metals ──────────────────────────────────────────────────────
|
||||
"Brushed Steel": RenderMaterial(
|
||||
name="Brushed Steel",
|
||||
color=(0.65, 0.67, 0.72),
|
||||
metallic=0.9,
|
||||
roughness=0.35,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Polished Chrome": RenderMaterial(
|
||||
name="Polished Chrome",
|
||||
color=(0.8, 0.8, 0.8),
|
||||
metallic=1.0,
|
||||
roughness=0.05,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Brushed Aluminum": RenderMaterial(
|
||||
name="Brushed Aluminum",
|
||||
color=(0.75, 0.75, 0.75),
|
||||
metallic=0.85,
|
||||
roughness=0.25,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Copper": RenderMaterial(
|
||||
name="Copper",
|
||||
color=(0.95, 0.64, 0.54),
|
||||
metallic=0.95,
|
||||
roughness=0.15,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Gold": RenderMaterial(
|
||||
name="Gold",
|
||||
color=(1.0, 0.76, 0.33),
|
||||
metallic=1.0,
|
||||
roughness=0.1,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
"Blackened Steel": RenderMaterial(
|
||||
name="Blackened Steel",
|
||||
color=(0.15, 0.15, 0.17),
|
||||
metallic=0.8,
|
||||
roughness=0.4,
|
||||
bsdf_type="roughconductor",
|
||||
),
|
||||
# ── Plastics ────────────────────────────────────────────────────
|
||||
"Matte Plastic": RenderMaterial(
|
||||
name="Matte Plastic",
|
||||
color=(0.2, 0.5, 0.8),
|
||||
metallic=0.0,
|
||||
roughness=0.6,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.5,
|
||||
),
|
||||
"Glossy Plastic": RenderMaterial(
|
||||
name="Glossy Plastic",
|
||||
color=(0.2, 0.5, 0.8),
|
||||
metallic=0.0,
|
||||
roughness=0.1,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.5,
|
||||
),
|
||||
"White Nylon": RenderMaterial(
|
||||
name="White Nylon",
|
||||
color=(0.85, 0.85, 0.83),
|
||||
metallic=0.0,
|
||||
roughness=0.45,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.53,
|
||||
),
|
||||
"Black ABS": RenderMaterial(
|
||||
name="Black ABS",
|
||||
color=(0.05, 0.05, 0.05),
|
||||
metallic=0.0,
|
||||
roughness=0.35,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.54,
|
||||
),
|
||||
"Red PA12": RenderMaterial(
|
||||
name="Red PA12",
|
||||
color=(0.75, 0.08, 0.08),
|
||||
metallic=0.0,
|
||||
roughness=0.4,
|
||||
bsdf_type="plastic",
|
||||
int_ior=1.53,
|
||||
),
|
||||
# ── Other ───────────────────────────────────────────────────────
|
||||
"Rubber": RenderMaterial(
|
||||
name="Rubber",
|
||||
color=(0.1, 0.1, 0.1),
|
||||
metallic=0.0,
|
||||
roughness=0.9,
|
||||
bsdf_type="diffuse",
|
||||
),
|
||||
"Ceramic White": RenderMaterial(
|
||||
name="Ceramic White",
|
||||
color=(0.92, 0.91, 0.88),
|
||||
metallic=0.0,
|
||||
roughness=0.15,
|
||||
bsdf_type="dielectric",
|
||||
int_ior=1.55,
|
||||
),
|
||||
"Glass": RenderMaterial(
|
||||
name="Glass",
|
||||
color=(0.95, 0.95, 0.95),
|
||||
metallic=0.0,
|
||||
roughness=0.0,
|
||||
bsdf_type="dielectric",
|
||||
int_ior=1.52,
|
||||
),
|
||||
"Wood": RenderMaterial(
|
||||
name="Wood",
|
||||
color=(0.6, 0.4, 0.2),
|
||||
metallic=0.0,
|
||||
roughness=0.7,
|
||||
bsdf_type="diffuse",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_preset(name: str) -> RenderMaterial:
|
||||
"""Get a material preset by name. Falls back to default if not found."""
|
||||
if name in PRESETS:
|
||||
return PRESETS[name]
|
||||
return default_material()
|
||||
|
||||
|
||||
def default_material() -> RenderMaterial:
|
||||
"""Return the default grey material."""
|
||||
return RenderMaterial(
|
||||
name="Default",
|
||||
color=(0.7, 0.7, 0.7),
|
||||
metallic=0.0,
|
||||
roughness=0.5,
|
||||
bsdf_type="diffuse",
|
||||
)
|
||||
|
||||
|
||||
def preset_names() -> List[str]:
|
||||
"""Return sorted list of available preset names."""
|
||||
return sorted(PRESETS.keys())
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Mitsuba 3 photorealistic render backend.
|
||||
|
||||
Requires: ``pip install mitsuba``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .render_backend import RenderBackend, RenderCamera, RenderMaterial, RenderSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MitsubaBackend(RenderBackend):
|
||||
"""Render backend using Mitsuba 3 path tracer."""
|
||||
|
||||
def name(self) -> str:
|
||||
return "Mitsuba 3"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
import sys
|
||||
import io
|
||||
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = io.StringIO()
|
||||
try:
|
||||
import mitsuba # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
# ── Scene construction ──────────────────────────────────────────
|
||||
|
||||
def _build_scene_dict(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
) -> dict:
|
||||
"""Build a Mitsuba scene dictionary from our data classes.
|
||||
|
||||
Uses a 3-point lighting setup (key + fill + rim) plus an ambient
|
||||
environment emitter for soft fill, giving well-balanced shading on
|
||||
all faces of the model. Lighting intensities and colors come from
|
||||
``settings.lighting``; ground plane comes from ``settings.ground_plane``.
|
||||
"""
|
||||
import mitsuba as mi
|
||||
|
||||
lighting = settings.lighting
|
||||
ground = settings.ground_plane
|
||||
|
||||
# Map our BSDF types to Mitsuba BSDF dicts
|
||||
bsdf = self._make_bsdf(material)
|
||||
|
||||
# Determine mesh file type from extension
|
||||
ext = os.path.splitext(mesh_path)[1].lower()
|
||||
shape_type = "ply" if ext == ".ply" else "obj"
|
||||
|
||||
# Build camera-to-world transform using the Python API
|
||||
cam_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=list(camera.origin),
|
||||
target=list(camera.target),
|
||||
up=list(camera.up),
|
||||
)
|
||||
|
||||
scene = {
|
||||
"type": "scene",
|
||||
# Integrator
|
||||
"integrator": {
|
||||
"type": "path",
|
||||
"max_depth": settings.max_depth,
|
||||
},
|
||||
# Camera
|
||||
"sensor": {
|
||||
"type": "perspective",
|
||||
"fov": camera.fov,
|
||||
"to_world": cam_to_world,
|
||||
"film": {
|
||||
"type": "hdrfilm",
|
||||
"width": settings.width,
|
||||
"height": settings.height,
|
||||
"rfilter": {"type": "gaussian"},
|
||||
},
|
||||
"sampler": {
|
||||
"type": "independent",
|
||||
"sample_count": settings.spp,
|
||||
},
|
||||
},
|
||||
# Ambient environment fill
|
||||
"emitter": {
|
||||
"type": "constant",
|
||||
"radiance": {
|
||||
"type": "rgb",
|
||||
"value": [
|
||||
lighting.ambient_intensity,
|
||||
lighting.ambient_intensity * 0.97,
|
||||
lighting.ambient_intensity * 0.94,
|
||||
],
|
||||
},
|
||||
},
|
||||
# Shape
|
||||
"shape": {
|
||||
"type": shape_type,
|
||||
"filename": mesh_path,
|
||||
"bsdf": bsdf,
|
||||
},
|
||||
}
|
||||
|
||||
# ── 3-point lighting (colors and intensities from config) ───
|
||||
key_rgb = [
|
||||
c * lighting.key_intensity for c in lighting.key_color
|
||||
]
|
||||
key_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=[1.0, -0.8, 1.2],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
)
|
||||
scene["key_light"] = {
|
||||
"type": "directional",
|
||||
"to_world": key_to_world,
|
||||
"irradiance": {"type": "rgb", "value": key_rgb},
|
||||
}
|
||||
|
||||
fill_rgb = [
|
||||
c * lighting.fill_intensity for c in lighting.fill_color
|
||||
]
|
||||
fill_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=[-1.0, 0.6, 0.8],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
)
|
||||
scene["fill_light"] = {
|
||||
"type": "directional",
|
||||
"to_world": fill_to_world,
|
||||
"irradiance": {"type": "rgb", "value": fill_rgb},
|
||||
}
|
||||
|
||||
rim_rgb = [
|
||||
c * lighting.rim_intensity for c in lighting.rim_color
|
||||
]
|
||||
rim_to_world = mi.ScalarTransform4f.look_at(
|
||||
origin=[-0.3, 1.2, -0.8],
|
||||
target=[0.0, 0.0, 0.0],
|
||||
up=[0.0, 0.0, 1.0],
|
||||
)
|
||||
scene["rim_light"] = {
|
||||
"type": "directional",
|
||||
"to_world": rim_to_world,
|
||||
"irradiance": {"type": "rgb", "value": rim_rgb},
|
||||
}
|
||||
|
||||
# ── Ground plane (optional) ─────────────────────────────────
|
||||
if ground.enabled:
|
||||
scene["ground_plane"] = {
|
||||
"type": "rectangle",
|
||||
"size": [500.0, 500.0],
|
||||
"to_world": mi.ScalarTransform4f.translate(
|
||||
[0.0, 0.0, -ground.distance_below]
|
||||
)
|
||||
@ mi.ScalarTransform4f.rotate_about_z(90),
|
||||
"bsdf": {
|
||||
"type": "diffuse",
|
||||
"reflectance": {
|
||||
"type": "rgb",
|
||||
"value": list(ground.color),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return scene
|
||||
|
||||
def _make_bsdf(self, material: RenderMaterial) -> dict:
|
||||
"""Convert a RenderMaterial to a Mitsuba BSDF dict."""
|
||||
mt = material.bsdf_type
|
||||
|
||||
if mt == "roughconductor":
|
||||
# Use material="none" with specular_reflectance set to the
|
||||
# metal color. The pip-installed Mitsuba doesn't include
|
||||
# spectral metal data files (iron.spd, copper.spd, etc.).
|
||||
return {
|
||||
"type": "roughconductor",
|
||||
"material": "none",
|
||||
"alpha": max(material.roughness, 0.01),
|
||||
"specular_reflectance": {
|
||||
"type": "rgb",
|
||||
"value": list(material.color),
|
||||
},
|
||||
}
|
||||
|
||||
if mt == "roughdielectric":
|
||||
return {
|
||||
"type": "roughdielectric",
|
||||
"int_ior": material.int_ior,
|
||||
"ext_ior": 1.0,
|
||||
"alpha": max(material.roughness, 0.01),
|
||||
}
|
||||
|
||||
if mt == "dielectric":
|
||||
return {
|
||||
"type": "dielectric",
|
||||
"int_ior": material.int_ior,
|
||||
"ext_ior": 1.0,
|
||||
}
|
||||
|
||||
if mt == "plastic":
|
||||
return {
|
||||
"type": "plastic",
|
||||
"diffuse_reflectance": {
|
||||
"type": "rgb",
|
||||
"value": list(material.color),
|
||||
},
|
||||
"int_ior": material.int_ior,
|
||||
}
|
||||
|
||||
# Default: diffuse
|
||||
return {
|
||||
"type": "diffuse",
|
||||
"reflectance": {
|
||||
"type": "rgb",
|
||||
"value": list(material.color),
|
||||
},
|
||||
}
|
||||
|
||||
# ── Rendering ───────────────────────────────────────────────────
|
||||
|
||||
def render(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
progress_callback: Optional[Callable[[float], None]] = None,
|
||||
) -> np.ndarray:
|
||||
"""Render a mesh file and return (H, W, 3) float32 RGB array."""
|
||||
self._set_variant()
|
||||
import mitsuba as mi
|
||||
|
||||
scene_dict = self._build_scene_dict(mesh_path, material, camera, settings)
|
||||
scene = mi.load_dict(scene_dict)
|
||||
|
||||
logger.info(
|
||||
f"Rendering {settings.width}x{settings.height} @ {settings.spp} spp"
|
||||
)
|
||||
|
||||
# Render
|
||||
image = mi.render(scene, spp=settings.spp, seed=settings.seed or 0)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(1.0)
|
||||
|
||||
# Convert to numpy (H, W, 3)
|
||||
arr = np.array(image, dtype=np.float32)
|
||||
|
||||
# Apply approximate sRGB tonemapping
|
||||
arr = np.clip(arr, 0.0, None)
|
||||
arr = np.power(arr, 1.0 / 2.2) # gamma
|
||||
arr = np.clip(arr, 0.0, 1.0)
|
||||
|
||||
return arr
|
||||
|
||||
def render_preview(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
) -> np.ndarray:
|
||||
"""Quick low-quality preview (4x fewer spp)."""
|
||||
preview_settings = RenderSettings(
|
||||
width=settings.width // 2,
|
||||
height=settings.height // 2,
|
||||
spp=max(settings.spp // 4, 16),
|
||||
max_depth=min(settings.max_depth, 4),
|
||||
seed=settings.seed,
|
||||
)
|
||||
return self.render(mesh_path, material, camera, preview_settings)
|
||||
|
||||
# ── Export ──────────────────────────────────────────────────────
|
||||
|
||||
def export_image(self, image: np.ndarray, path: str) -> None:
|
||||
"""Save a rendered image to PNG or EXR."""
|
||||
from PIL import Image
|
||||
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
|
||||
if ext == ".exr":
|
||||
# Save as EXR (HDR) — no tonemapping
|
||||
try:
|
||||
import OpenEXR
|
||||
import Imath
|
||||
|
||||
h, w = image.shape[:2]
|
||||
header = OpenEXR.Header(w, h)
|
||||
header["channels"] = {
|
||||
"R": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||
"G": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||
"B": Imath.PixelType(Imath.PixelType.FLOAT),
|
||||
}
|
||||
exr = OpenEXR.OutputFile(path, header)
|
||||
exr.write(
|
||||
{
|
||||
"R": image[:, :, 0].tobytes(),
|
||||
"G": image[:, :, 1].tobytes(),
|
||||
"B": image[:, :, 2].tobytes(),
|
||||
}
|
||||
)
|
||||
exr.close()
|
||||
except ImportError:
|
||||
# Fallback: save as 16-bit PNG
|
||||
logger.warning("OpenEXR not available, saving as 16-bit PNG")
|
||||
img = Image.fromarray((image * 65535).astype(np.uint16), "RGB")
|
||||
img.save(path)
|
||||
else:
|
||||
# PNG / JPEG — already tonemapped
|
||||
img = Image.fromarray((image * 255).astype(np.uint8), "RGB")
|
||||
img.save(path)
|
||||
|
||||
logger.info(f"Exported render to {path}")
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _set_variant(self) -> None:
|
||||
"""Set the Mitsuba variant (called once)."""
|
||||
import sys
|
||||
import io
|
||||
|
||||
# Suppress the harmless "LLVM API initialization failed" warning
|
||||
# that drjit emits on macOS ARM when scalar variant is used.
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = io.StringIO()
|
||||
try:
|
||||
import mitsuba as mi
|
||||
|
||||
mi.set_variant("scalar_rgb")
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Convert OCC BRep shapes to mesh files for render backends.
|
||||
|
||||
Outputs PLY files (preferred by Mitsuba) or STL files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def occ_shape_to_ply(
|
||||
shape,
|
||||
output_path: Optional[str] = None,
|
||||
linear_deflection: float = 0.1,
|
||||
angular_deflection: float = 0.15,
|
||||
) -> str:
|
||||
"""Tessellate an OCC TopoDS_Shape and write as PLY.
|
||||
|
||||
Returns the path to the written PLY file.
|
||||
"""
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopAbs import TopAbs_FACE
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
|
||||
# Tessellate
|
||||
tess = BRepMesh_IncrementalMesh(
|
||||
shape, linear_deflection, False, angular_deflection, True
|
||||
)
|
||||
tess.Perform()
|
||||
|
||||
# Extract triangulation from all faces
|
||||
all_vertices: List[List[float]] = []
|
||||
all_faces: List[List[int]] = []
|
||||
vertex_offset = 0
|
||||
|
||||
explorer = TopExp_Explorer(shape, TopAbs_FACE)
|
||||
while explorer.More():
|
||||
face = TopoDS.Face_s(explorer.Current())
|
||||
location = TopLoc_Location()
|
||||
triangulation = BRep_Tool.Triangulation_s(face, location)
|
||||
|
||||
if triangulation is None:
|
||||
explorer.Next()
|
||||
continue
|
||||
|
||||
# Transform
|
||||
trsf = location.Transformation()
|
||||
|
||||
# Extract vertices (apply location transform to positions)
|
||||
nb_nodes = triangulation.NbNodes()
|
||||
for i in range(1, nb_nodes + 1):
|
||||
node = triangulation.Node(i)
|
||||
pnt = node.Transformed(trsf)
|
||||
all_vertices.append([pnt.X(), pnt.Y(), pnt.Z()])
|
||||
|
||||
# Extract triangles
|
||||
nb_triangles = triangulation.NbTriangles()
|
||||
for i in range(1, nb_triangles + 1):
|
||||
tri = triangulation.Triangle(i)
|
||||
n1, n2, n3 = tri.Get()
|
||||
all_faces.append([
|
||||
n1 - 1 + vertex_offset,
|
||||
n2 - 1 + vertex_offset,
|
||||
n3 - 1 + vertex_offset,
|
||||
])
|
||||
|
||||
vertex_offset += nb_nodes
|
||||
explorer.Next()
|
||||
|
||||
if not all_vertices:
|
||||
raise ValueError("Tessellation produced no vertices")
|
||||
|
||||
vertices = np.array(all_vertices, dtype=np.float32)
|
||||
faces = np.array(all_faces, dtype=np.uint32)
|
||||
|
||||
logger.info(
|
||||
f"Tessellation: {len(vertices)} vertices, {len(faces)} triangles"
|
||||
)
|
||||
|
||||
# Compute outward-facing vertex normals from triangle geometry.
|
||||
# This ensures consistent lighting even when OCC triangulation winding
|
||||
# is inconsistent across faces (e.g. after location transforms).
|
||||
normals = _compute_outward_normals(vertices, faces, shape)
|
||||
|
||||
# Write PLY with normals
|
||||
if output_path is None:
|
||||
fd, output_path = tempfile.mkstemp(suffix=".ply", prefix="fluency_render_")
|
||||
os.close(fd)
|
||||
|
||||
_write_ply(output_path, vertices, faces, normals)
|
||||
logger.info(f"Wrote PLY: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def _compute_outward_normals(
|
||||
vertices: np.ndarray,
|
||||
faces: np.ndarray,
|
||||
shape,
|
||||
) -> np.ndarray:
|
||||
"""Compute outward-facing vertex normals.
|
||||
|
||||
1. Compute per-face normals from cross product of triangle edges.
|
||||
2. Determine correct orientation by checking face normals against the
|
||||
shape centroid (outward = away from center).
|
||||
3. Flip triangles with inward normals before accumulating to vertices.
|
||||
4. Average and normalize per-vertex normals.
|
||||
"""
|
||||
n_verts = len(vertices)
|
||||
v_normals = np.zeros((n_verts, 3), dtype=np.float64)
|
||||
|
||||
# Compute shape centroid for outward direction reference.
|
||||
# Use OCC bounding box if available, otherwise fall back to vertex bounds.
|
||||
if shape is not None:
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
|
||||
bbox = Bnd_Box()
|
||||
BRepBndLib.Add_s(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
else:
|
||||
vmin = vertices.min(axis=0).astype(np.float64)
|
||||
vmax = vertices.max(axis=0).astype(np.float64)
|
||||
xmin, ymin, zmin = vmin
|
||||
xmax, ymax, zmax = vmax
|
||||
|
||||
centroid = np.array(
|
||||
[(xmin + xmax) / 2, (ymin + ymax) / 2, (zmin + zmax) / 2],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
# Ensure faces is 2D (numpy creates (3,) for single-face meshes)
|
||||
if faces.ndim == 1:
|
||||
faces = faces.reshape(1, -1)
|
||||
|
||||
# Compute face normals from triangle geometry
|
||||
v0 = vertices[faces[:, 0]]
|
||||
v1 = vertices[faces[:, 1]]
|
||||
v2 = vertices[faces[:, 2]]
|
||||
|
||||
edge1 = v1 - v0
|
||||
edge2 = v2 - v0
|
||||
face_normals = np.cross(edge1, edge2)
|
||||
|
||||
# Triangle centroids to test direction from shape center
|
||||
tri_centers = (v0 + v1 + v2) / 3.0
|
||||
to_tri = tri_centers - centroid
|
||||
|
||||
# Dot product: positive means normal points away from centroid (outward)
|
||||
dots = np.sum(face_normals * to_tri, axis=1)
|
||||
|
||||
# Faces with negative dot have inward normals — swap columns 1 and 2
|
||||
flip_mask = dots < 0
|
||||
corrected_faces = faces.copy()
|
||||
col1 = corrected_faces[:, 1]
|
||||
col2 = corrected_faces[:, 2]
|
||||
corrected_faces[flip_mask, 1] = col2[flip_mask]
|
||||
corrected_faces[flip_mask, 2] = col1[flip_mask]
|
||||
|
||||
# Recompute face normals after correction
|
||||
v0c = vertices[corrected_faces[:, 0]]
|
||||
v1c = vertices[corrected_faces[:, 1]]
|
||||
v2c = vertices[corrected_faces[:, 2]]
|
||||
fn = np.cross(v1c - v0c, v2c - v0c)
|
||||
|
||||
# Normalize face normals
|
||||
lengths = np.linalg.norm(fn, axis=1, keepdims=True)
|
||||
lengths[lengths < 1e-10] = 1.0
|
||||
fn /= lengths
|
||||
|
||||
# Accumulate to vertices
|
||||
for i in range(len(corrected_faces)):
|
||||
idx = corrected_faces[i]
|
||||
v_normals[idx[0]] += fn[i]
|
||||
v_normals[idx[1]] += fn[i]
|
||||
v_normals[idx[2]] += fn[i]
|
||||
|
||||
# Normalize vertex normals
|
||||
v_lengths = np.linalg.norm(v_normals, axis=1, keepdims=True)
|
||||
v_lengths[v_lengths < 1e-10] = 1.0
|
||||
v_normals /= v_lengths
|
||||
|
||||
return v_normals.astype(np.float32)
|
||||
|
||||
|
||||
def occ_shape_to_stl(
|
||||
shape,
|
||||
output_path: Optional[str] = None,
|
||||
linear_deflection: float = 0.1,
|
||||
) -> str:
|
||||
"""Tessellate an OCC TopoDS_Shape and write as binary STL.
|
||||
|
||||
Returns the path to the written STL file.
|
||||
"""
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.StlAPI import StlAPI_Writer
|
||||
|
||||
# Tessellate
|
||||
tess = BRepMesh_IncrementalMesh(shape, linear_deflection, False, 0.5, True)
|
||||
tess.Perform()
|
||||
|
||||
if output_path is None:
|
||||
fd, output_path = tempfile.mkstemp(suffix=".stl", prefix="fluency_render_")
|
||||
os.close(fd)
|
||||
|
||||
writer = StlAPI_Writer()
|
||||
writer.SetASCIIMode(False)
|
||||
writer.Write(shape, output_path)
|
||||
logger.info(f"Wrote STL: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def occ_shape_bounds(shape) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
|
||||
"""Return (min_xyz, max_xyz) bounding box of an OCC shape."""
|
||||
from OCP.Bnd import Bnd_Box
|
||||
from OCP.BRepBndLib import BRepBndLib
|
||||
|
||||
bbox = Bnd_Box()
|
||||
BRepBndLib.Add_s(shape, bbox)
|
||||
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
|
||||
return (xmin, ymin, zmin), (xmax, ymax, zmax)
|
||||
|
||||
|
||||
def _write_ply(
|
||||
path: str,
|
||||
vertices: np.ndarray,
|
||||
faces: np.ndarray,
|
||||
normals: Optional[np.ndarray] = None,
|
||||
) -> None:
|
||||
"""Write a binary PLY file (little-endian) with optional vertex normals."""
|
||||
import struct
|
||||
|
||||
n_verts = len(vertices)
|
||||
n_faces = len(faces)
|
||||
has_normals = normals is not None and len(normals) == n_verts
|
||||
|
||||
with open(path, "wb") as f:
|
||||
# Header
|
||||
header_lines = [
|
||||
"ply",
|
||||
"format binary_little_endian 1.0",
|
||||
f"element vertex {n_verts}",
|
||||
"property float x",
|
||||
"property float y",
|
||||
"property float z",
|
||||
]
|
||||
if has_normals:
|
||||
header_lines.extend([
|
||||
"property float nx",
|
||||
"property float ny",
|
||||
"property float nz",
|
||||
])
|
||||
header_lines.append(f"element face {n_faces}")
|
||||
header_lines.append("property list uchar int vertex_indices")
|
||||
header_lines.append("end_header")
|
||||
|
||||
f.write(("\n".join(header_lines) + "\n").encode("ascii"))
|
||||
|
||||
# Vertex positions (+ normals if available)
|
||||
for i in range(n_verts):
|
||||
f.write(struct.pack("<fff", vertices[i, 0], vertices[i, 1], vertices[i, 2]))
|
||||
if has_normals:
|
||||
f.write(struct.pack("<fff", normals[i, 0], normals[i, 1], normals[i, 2]))
|
||||
|
||||
# Faces
|
||||
for face in faces:
|
||||
f.write(struct.pack("<B", 3))
|
||||
f.write(struct.pack("<iii", int(face[0]), int(face[1]), int(face[2])))
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Abstract render backend interface.
|
||||
|
||||
Any photorealistic renderer (Mitsuba, Blender, Cycles, ...) implements
|
||||
:class:`RenderBackend`. The UI only talks to this ABC so backends can be
|
||||
swapped by changing one import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderMaterial:
|
||||
"""PBR material description for the render backend."""
|
||||
|
||||
name: str = "Default"
|
||||
color: tuple[float, float, float] = (0.7, 0.7, 0.7)
|
||||
metallic: float = 0.0 # 0.0 = dielectric, 1.0 = metal
|
||||
roughness: float = 0.5 # 0.0 = mirror, 1.0 = fully rough
|
||||
bsdf_type: str = "diffuse" # diffuse | roughconductor | roughdielectric | plastic
|
||||
# Optional: named metal preset (copper, aluminium, gold, chrome, steel)
|
||||
metal_preset: Optional[str] = None
|
||||
# For dielectric / plastic
|
||||
int_ior: float = 1.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderCamera:
|
||||
"""Camera parameters for the render."""
|
||||
|
||||
origin: tuple[float, float, float] = (100.0, 100.0, 100.0)
|
||||
target: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
up: tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||
fov: float = 60.0 # vertical field of view in degrees
|
||||
|
||||
|
||||
@dataclass
|
||||
class LightingConfig:
|
||||
"""Lighting configuration for the render scene."""
|
||||
|
||||
ambient_intensity: float = 0.3 # constant environment fill [0..1]
|
||||
key_color: tuple[float, float, float] = (1.0, 0.98, 0.95) # RGB key light color
|
||||
key_intensity: float = 3.5 # key light irradiance multiplier
|
||||
fill_color: tuple[float, float, float] = (0.92, 0.94, 1.0) # RGB fill light color
|
||||
fill_intensity: float = 1.5 # fill light irradiance multiplier
|
||||
rim_color: tuple[float, float, float] = (1.0, 0.98, 0.96) # RGB rim light color
|
||||
rim_intensity: float = 1.2 # rim light irradiance multiplier
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroundPlaneConfig:
|
||||
"""Ground plane configuration for the render scene."""
|
||||
|
||||
enabled: bool = False
|
||||
color: tuple[float, float, float] = (0.5, 0.5, 0.5) # RGB diffuse color
|
||||
roughness: float = 0.8 # surface roughness [0..1]
|
||||
distance_below: float = 0.0 # mm below origin (positive = below)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderSettings:
|
||||
"""Quality / resolution settings."""
|
||||
|
||||
width: int = 1920
|
||||
height: int = 1080
|
||||
spp: int = 256 # samples per pixel
|
||||
max_depth: int = 8 # max bounces for path tracer
|
||||
seed: int = 0 # random seed (0 = auto)
|
||||
lighting: LightingConfig = field(default_factory=LightingConfig)
|
||||
ground_plane: GroundPlaneConfig = field(default_factory=GroundPlaneConfig)
|
||||
|
||||
|
||||
class RenderBackend(ABC):
|
||||
"""Abstract photorealistic renderer.
|
||||
|
||||
Implementations live in separate modules so backends can be swapped
|
||||
without touching the UI. Typical call::
|
||||
|
||||
backend = MitsubaBackend()
|
||||
image = backend.render(obj_path, material, camera, settings)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Human-readable backend name (shown in UI)."""
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""Return True if this backend's dependencies are installed."""
|
||||
|
||||
@abstractmethod
|
||||
def render(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
progress_callback=None,
|
||||
) -> "np.ndarray":
|
||||
"""Render a mesh file and return an (H, W, 3) float32 RGB array.
|
||||
|
||||
*mesh_path* is an STL or OBJ file on disk.
|
||||
*progress_callback(fraction)* is called with 0.0–1.0 progress.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def render_preview(
|
||||
self,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
) -> "np.ndarray":
|
||||
"""Quick low-quality preview (fewer spp)."""
|
||||
|
||||
@abstractmethod
|
||||
def export_image(self, image: "np.ndarray", path: str) -> None:
|
||||
"""Save a rendered image to PNG / EXR."""
|
||||
|
||||
def default_camera_from_bounds(
|
||||
self, bounds_min: tuple[float, float, float], bounds_max: tuple[float, float, float]
|
||||
) -> RenderCamera:
|
||||
"""Compute a sensible default camera looking at the bbox centre."""
|
||||
import numpy as np
|
||||
|
||||
mn = np.asarray(bounds_min, dtype=float)
|
||||
mx = np.asarray(bounds_max, dtype=float)
|
||||
centre = (mn + mx) / 2.0
|
||||
diag = float(np.linalg.norm(mx - mn))
|
||||
# Place camera at iso-ish position, far enough to see everything.
|
||||
eye = centre + np.array([0.7, -0.7, 0.5]) * diag * 0.8
|
||||
return RenderCamera(
|
||||
origin=tuple(eye.tolist()),
|
||||
target=tuple(centre.tolist()),
|
||||
up=(0.0, 0.0, 1.0),
|
||||
fov=45.0,
|
||||
)
|
||||
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
Undo/Redo manager for OCCSketch using snapshot-based approach.
|
||||
|
||||
python_solvespace has no per-entity delete API — the solver is rebuilt from
|
||||
scratch after every modification. This makes snapshot-based undo natural:
|
||||
we capture the complete sketch state (entities, geometry, constraints) and
|
||||
restore it by rebuilding the solver from the snapshot data.
|
||||
|
||||
Each snapshot is ~10-50 KB depending on sketch complexity, and we cap the
|
||||
stack at a configurable depth (default 50).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SketchSnapshot:
|
||||
"""Immutable snapshot of sketch state for undo/redo.
|
||||
|
||||
Captures everything needed to fully reconstruct an OCCSketch:
|
||||
entities, points, lines, circles, arcs, counter, constraint log,
|
||||
and the special entity id sets (centerlines, external/underlay).
|
||||
"""
|
||||
|
||||
# Entity registry: id → (entity_type, geometry, is_construction, is_external, constraints_list)
|
||||
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = field(default_factory=dict)
|
||||
|
||||
# Geometry sub-indices
|
||||
points: Dict[int, Tuple[float, float]] = field(default_factory=dict)
|
||||
lines: Dict[int, Tuple[int, int]] = field(default_factory=dict)
|
||||
circles: Dict[int, Tuple[int, float]] = field(default_factory=dict)
|
||||
arcs: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
# Counters and flags
|
||||
entity_counter: int = 0
|
||||
constraint_count: int = 0
|
||||
first_point_id: Optional[int] = None
|
||||
|
||||
# Constraint replay log
|
||||
constraint_log: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Special entity sets
|
||||
centerline_ids: Set[int] = field(default_factory=set)
|
||||
external_entity_ids: Set[int] = field(default_factory=set)
|
||||
|
||||
# Workplane (so undo doesn't lose the placement plane)
|
||||
wp_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
wp_normal: Tuple[float, float, float] = (0.0, 0.0, 1.0)
|
||||
wp_x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0)
|
||||
wp_y_dir: Tuple[float, float, float] = (0.0, 1.0, 0.0)
|
||||
|
||||
|
||||
class SketchUndoManager:
|
||||
"""Manages undo/redo stacks of SketchSnapshot for an OCCSketch.
|
||||
|
||||
Usage::
|
||||
|
||||
undo_mgr = SketchUndoManager(sketch)
|
||||
|
||||
# Before any modification:
|
||||
undo_mgr.save_state()
|
||||
|
||||
# ... perform modification ...
|
||||
|
||||
# Ctrl+Z:
|
||||
undo_mgr.undo()
|
||||
|
||||
# Ctrl+Y / Ctrl+Shift+Z:
|
||||
undo_mgr.redo()
|
||||
"""
|
||||
|
||||
def __init__(self, sketch: Any, max_stack_size: int = 50) -> None:
|
||||
from fluency.geometry_occ.sketch import OCCSketch
|
||||
|
||||
self._sketch: OCCSketch = sketch
|
||||
self._max_stack_size = max_stack_size
|
||||
self._undo_stack: List[SketchSnapshot] = []
|
||||
self._redo_stack: List[SketchSnapshot] = []
|
||||
|
||||
# ─── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def can_undo(self) -> bool:
|
||||
"""True if there is a state to undo to."""
|
||||
return len(self._undo_stack) > 0
|
||||
|
||||
@property
|
||||
def can_redo(self) -> bool:
|
||||
"""True if there is a state to redo to."""
|
||||
return len(self._redo_stack) > 0
|
||||
|
||||
@property
|
||||
def undo_depth(self) -> int:
|
||||
"""Number of undo levels available."""
|
||||
return len(self._undo_stack)
|
||||
|
||||
@property
|
||||
def redo_depth(self) -> int:
|
||||
"""Number of redo levels available."""
|
||||
return len(self._redo_stack)
|
||||
|
||||
def save_state(self) -> None:
|
||||
"""Capture the current sketch state and push it onto the undo stack.
|
||||
|
||||
Call this **before** any modifying operation (draw, delete, move,
|
||||
constraint add/remove, construction toggle, etc.).
|
||||
|
||||
The redo stack is cleared whenever a new state is saved (i.e. when
|
||||
the user makes a new change after undoing).
|
||||
"""
|
||||
snapshot = self._capture()
|
||||
self._undo_stack.append(snapshot)
|
||||
|
||||
# Cap the stack size.
|
||||
if len(self._undo_stack) > self._max_stack_size:
|
||||
self._undo_stack.pop(0)
|
||||
|
||||
# New mutation invalidates the redo history.
|
||||
self._redo_stack.clear()
|
||||
|
||||
logger.debug(
|
||||
f"save_state: undo_depth={len(self._undo_stack)} "
|
||||
f"entities={len(snapshot.entities)}"
|
||||
)
|
||||
|
||||
def undo(self) -> bool:
|
||||
"""Restore the previous sketch state.
|
||||
|
||||
Returns True if a state was restored, False if the undo stack is empty.
|
||||
"""
|
||||
if not self._undo_stack:
|
||||
logger.debug("undo: stack empty")
|
||||
return False
|
||||
|
||||
# Save current state to redo stack before restoring.
|
||||
current_snapshot = self._capture()
|
||||
self._redo_stack.append(current_snapshot)
|
||||
|
||||
# Pop and restore.
|
||||
snapshot = self._undo_stack.pop()
|
||||
self._restore(snapshot)
|
||||
|
||||
logger.debug(
|
||||
f"undo: restored state with {len(snapshot.entities)} entities, "
|
||||
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
|
||||
)
|
||||
return True
|
||||
|
||||
def redo(self) -> bool:
|
||||
"""Re-apply the most recently undone state.
|
||||
|
||||
Returns True if a state was restored, False if the redo stack is empty.
|
||||
"""
|
||||
if not self._redo_stack:
|
||||
logger.debug("redo: stack empty")
|
||||
return False
|
||||
|
||||
# Save current state to undo stack before restoring.
|
||||
current_snapshot = self._capture()
|
||||
self._undo_stack.append(current_snapshot)
|
||||
|
||||
# Pop and restore.
|
||||
snapshot = self._redo_stack.pop()
|
||||
self._restore(snapshot)
|
||||
|
||||
logger.debug(
|
||||
f"redo: restored state with {len(snapshot.entities)} entities, "
|
||||
f"undo_depth={len(self._undo_stack)} redo_depth={len(self._redo_stack)}"
|
||||
)
|
||||
return True
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear both stacks (e.g. when loading a new sketch)."""
|
||||
self._undo_stack.clear()
|
||||
self._redo_stack.clear()
|
||||
logger.debug("undo stacks cleared")
|
||||
|
||||
# ─── Snapshot Capture ──────────────────────────────────────────────────
|
||||
|
||||
def _capture(self) -> SketchSnapshot:
|
||||
"""Capture the current sketch state into a snapshot."""
|
||||
sketch = self._sketch
|
||||
|
||||
# Capture entities: id → (type, geometry, is_construction, is_external, constraints)
|
||||
entities: Dict[int, Tuple[str, Any, bool, bool, List[str]]] = {}
|
||||
for eid, ent in sketch._entities.items():
|
||||
entities[eid] = (
|
||||
ent.entity_type,
|
||||
ent.geometry,
|
||||
ent.is_construction,
|
||||
ent.is_external,
|
||||
list(ent.constraints), # copy the constraints list
|
||||
)
|
||||
|
||||
# Deep copy the mutable dicts (points coords are tuples, so shallow is fine,
|
||||
# but arcs contain dicts so we deep-copy those).
|
||||
points = dict(sketch._points)
|
||||
lines = dict(sketch._lines)
|
||||
circles = dict(sketch._circles)
|
||||
arcs = {k: copy.deepcopy(v) for k, v in sketch._arcs.items()}
|
||||
|
||||
# Constraint log entries contain tuples and sets — need careful copy.
|
||||
constraint_log = []
|
||||
for entry in sketch._constraint_log:
|
||||
copied = {
|
||||
"type": entry["type"],
|
||||
"ids": tuple(entry["ids"]),
|
||||
"params": tuple(entry["params"]) if entry.get("params") else (),
|
||||
"labels": set(entry["labels"]) if entry.get("labels") else set(),
|
||||
}
|
||||
constraint_log.append(copied)
|
||||
|
||||
return SketchSnapshot(
|
||||
entities=entities,
|
||||
points=points,
|
||||
lines=lines,
|
||||
circles=circles,
|
||||
arcs=arcs,
|
||||
entity_counter=sketch._entity_counter,
|
||||
constraint_count=sketch._constraint_count,
|
||||
first_point_id=sketch._first_point_id,
|
||||
constraint_log=constraint_log,
|
||||
centerline_ids=set(sketch._centerline_ids),
|
||||
external_entity_ids=set(sketch._external_entity_ids),
|
||||
wp_origin=sketch._wp_origin,
|
||||
wp_normal=sketch._wp_normal,
|
||||
wp_x_dir=sketch._wp_x_dir,
|
||||
wp_y_dir=sketch._wp_y_dir,
|
||||
)
|
||||
|
||||
# ─── Snapshot Restore ──────────────────────────────────────────────────
|
||||
|
||||
def _restore(self, snapshot: SketchSnapshot) -> None:
|
||||
"""Restore the sketch to a previously captured snapshot state."""
|
||||
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
|
||||
|
||||
sketch = self._sketch
|
||||
|
||||
# Clear the current solver and rebuild from scratch.
|
||||
sketch._solver = sketch._solver.__class__() # SolverSystem()
|
||||
sketch._wp = sketch._solver.create_2d_base()
|
||||
sketch._first_point_id = None
|
||||
|
||||
# Restore counters and flags.
|
||||
sketch._entity_counter = snapshot.entity_counter
|
||||
sketch._constraint_count = snapshot.constraint_count
|
||||
sketch._centerline_ids = set(snapshot.centerline_ids)
|
||||
sketch._external_entity_ids = set(snapshot.external_entity_ids)
|
||||
|
||||
# Restore workplane.
|
||||
sketch._wp_origin = snapshot.wp_origin
|
||||
sketch._wp_normal = snapshot.wp_normal
|
||||
sketch._wp_x_dir = snapshot.wp_x_dir
|
||||
sketch._wp_y_dir = snapshot.wp_y_dir
|
||||
|
||||
# Rebuild entity objects from the snapshot.
|
||||
sketch._entities.clear()
|
||||
sketch._points.clear()
|
||||
sketch._lines.clear()
|
||||
sketch._circles.clear()
|
||||
sketch._arcs.clear()
|
||||
|
||||
# First pass: re-add all points to the solver.
|
||||
for eid in sorted(snapshot.entities.keys()):
|
||||
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[eid]
|
||||
if etype == "point" and eid in snapshot.points:
|
||||
x, y = snapshot.points[eid]
|
||||
solver_handle = sketch._solver.add_point_2d(x, y, sketch._wp)
|
||||
ent = OCCSketchEntity(
|
||||
entity_id=eid,
|
||||
entity_type="point",
|
||||
geometry=(x, y),
|
||||
handle=solver_handle,
|
||||
)
|
||||
ent.is_construction = is_constr
|
||||
ent.is_external = is_ext
|
||||
ent.constraints = list(constraints)
|
||||
sketch._entities[eid] = ent
|
||||
sketch._points[eid] = (x, y)
|
||||
|
||||
# Anchor the first point (or first external point) for solver stability.
|
||||
if sketch._first_point_id is None and not is_ext:
|
||||
sketch._first_point_id = eid
|
||||
sketch._solver.dragged(solver_handle, sketch._wp)
|
||||
elif sketch._first_point_id is None and is_ext:
|
||||
sketch._first_point_id = eid
|
||||
sketch._solver.dragged(solver_handle, sketch._wp)
|
||||
|
||||
# Second pass: re-add all lines.
|
||||
for lid in sorted(snapshot.lines.keys()):
|
||||
sid, eid2 = snapshot.lines[lid]
|
||||
s_ent = sketch._entities.get(sid)
|
||||
e_ent = sketch._entities.get(eid2)
|
||||
if s_ent is None or e_ent is None or s_ent.handle is None or e_ent.handle is None:
|
||||
continue
|
||||
|
||||
solver_handle = sketch._solver.add_line_2d(s_ent.handle, e_ent.handle, sketch._wp)
|
||||
|
||||
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[lid]
|
||||
ent = OCCSketchEntity(
|
||||
entity_id=lid,
|
||||
entity_type="line",
|
||||
geometry=geometry,
|
||||
handle=solver_handle,
|
||||
)
|
||||
ent.is_construction = is_constr
|
||||
ent.is_external = is_ext
|
||||
ent.constraints = list(constraints)
|
||||
sketch._entities[lid] = ent
|
||||
sketch._lines[lid] = (sid, eid2)
|
||||
|
||||
# Restore circles (not in solver, just tracked).
|
||||
for cid, (center_id, radius) in snapshot.circles.items():
|
||||
if cid in snapshot.entities:
|
||||
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[cid]
|
||||
center_ent = sketch._entities.get(center_id)
|
||||
ent = OCCSketchEntity(
|
||||
entity_id=cid,
|
||||
entity_type="circle",
|
||||
geometry=geometry,
|
||||
handle=None,
|
||||
)
|
||||
ent.is_construction = is_constr
|
||||
ent.is_external = is_ext
|
||||
ent.constraints = list(constraints)
|
||||
sketch._entities[cid] = ent
|
||||
sketch._circles[cid] = (center_id, radius)
|
||||
|
||||
# Restore arcs (not in solver, just tracked).
|
||||
for aid, arc_data in snapshot.arcs.items():
|
||||
if aid in snapshot.entities:
|
||||
etype, geometry, is_constr, is_ext, constraints = snapshot.entities[aid]
|
||||
ent = OCCSketchEntity(
|
||||
entity_id=aid,
|
||||
entity_type="arc",
|
||||
geometry=geometry,
|
||||
handle=None,
|
||||
)
|
||||
ent.is_construction = is_constr
|
||||
ent.is_external = is_ext
|
||||
ent.constraints = list(constraints)
|
||||
sketch._entities[aid] = ent
|
||||
sketch._arcs[aid] = copy.deepcopy(arc_data)
|
||||
|
||||
# Rebuild the constraint log (entries were deep-copied on capture).
|
||||
sketch._constraint_log = []
|
||||
for entry in snapshot.constraint_log:
|
||||
sketch._constraint_log.append({
|
||||
"type": entry["type"],
|
||||
"ids": tuple(entry["ids"]),
|
||||
"params": tuple(entry["params"]) if entry.get("params") else (),
|
||||
"labels": set(entry["labels"]) if entry.get("labels") else set(),
|
||||
})
|
||||
|
||||
# Re-apply all constraints to the solver.
|
||||
for entry in sketch._constraint_log:
|
||||
sketch._apply_constraint_log(entry)
|
||||
|
||||
# Solve to update geometry positions.
|
||||
sketch.solve()
|
||||
|
||||
logger.debug(
|
||||
f"Restored snapshot: {len(sketch._entities)} entities, "
|
||||
f"{len(sketch._constraint_log)} constraints"
|
||||
)
|
||||
@@ -0,0 +1,906 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
################################################################################
|
||||
## Form generated from reading UI file 'gui.ui'
|
||||
##
|
||||
## Created by: Qt User Interface Compiler version 6.10.2
|
||||
##
|
||||
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||
################################################################################
|
||||
|
||||
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||
QMetaObject, QObject, QPoint, QRect,
|
||||
QSize, QTime, QUrl, Qt)
|
||||
from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
|
||||
QCursor, QFont, QFontDatabase, QGradient,
|
||||
QIcon, QImage, QKeySequence, QLinearGradient,
|
||||
QPainter, QPalette, QPixmap, QRadialGradient,
|
||||
QTransform)
|
||||
from PySide6.QtWidgets import (QApplication, QFrame, QGridLayout, QGroupBox,
|
||||
QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QMainWindow, QMenu, QMenuBar, QPushButton,
|
||||
QSizePolicy, QSpinBox, QStatusBar, QTabWidget,
|
||||
QTextEdit, QVBoxLayout, QWidget)
|
||||
|
||||
class Ui_fluencyCAD(object):
|
||||
def setupUi(self, fluencyCAD):
|
||||
if not fluencyCAD.objectName():
|
||||
fluencyCAD.setObjectName(u"fluencyCAD")
|
||||
fluencyCAD.resize(2359, 1285)
|
||||
self.actionNew_Project = QAction(fluencyCAD)
|
||||
self.actionNew_Project.setObjectName(u"actionNew_Project")
|
||||
self.actionOpen_Project = QAction(fluencyCAD)
|
||||
self.actionOpen_Project.setObjectName(u"actionOpen_Project")
|
||||
self.actionSave_Project = QAction(fluencyCAD)
|
||||
self.actionSave_Project.setObjectName(u"actionSave_Project")
|
||||
self.actionSave_Project_As = QAction(fluencyCAD)
|
||||
self.actionSave_Project_As.setObjectName(u"actionSave_Project_As")
|
||||
self.actionImport_File = QAction(fluencyCAD)
|
||||
self.actionImport_File.setObjectName(u"actionImport_File")
|
||||
self.actionExport_Step = QAction(fluencyCAD)
|
||||
self.actionExport_Step.setObjectName(u"actionExport_Step")
|
||||
self.actionExport_Iges = QAction(fluencyCAD)
|
||||
self.actionExport_Iges.setObjectName(u"actionExport_Iges")
|
||||
self.actionExport_Stl = QAction(fluencyCAD)
|
||||
self.actionExport_Stl.setObjectName(u"actionExport_Stl")
|
||||
self.actionExit = QAction(fluencyCAD)
|
||||
self.actionExit.setObjectName(u"actionExit")
|
||||
self.centralwidget = QWidget(fluencyCAD)
|
||||
self.centralwidget.setObjectName(u"centralwidget")
|
||||
self.gridLayout = QGridLayout(self.centralwidget)
|
||||
self.gridLayout.setObjectName(u"gridLayout")
|
||||
self.InputTab = QTabWidget(self.centralwidget)
|
||||
self.InputTab.setObjectName(u"InputTab")
|
||||
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(self.InputTab.sizePolicy().hasHeightForWidth())
|
||||
self.InputTab.setSizePolicy(sizePolicy)
|
||||
self.sketch_tab = QWidget()
|
||||
self.sketch_tab.setObjectName(u"sketch_tab")
|
||||
self.verticalLayout_4 = QVBoxLayout(self.sketch_tab)
|
||||
self.verticalLayout_4.setObjectName(u"verticalLayout_4")
|
||||
self.InputTab.addTab(self.sketch_tab, "")
|
||||
self.code_tab = QWidget()
|
||||
self.code_tab.setObjectName(u"code_tab")
|
||||
self.verticalLayout = QVBoxLayout(self.code_tab)
|
||||
self.verticalLayout.setObjectName(u"verticalLayout")
|
||||
self.textEdit = QTextEdit(self.code_tab)
|
||||
self.textEdit.setObjectName(u"textEdit")
|
||||
|
||||
self.verticalLayout.addWidget(self.textEdit)
|
||||
|
||||
self.groupBox_7 = QGroupBox(self.code_tab)
|
||||
self.groupBox_7.setObjectName(u"groupBox_7")
|
||||
self.gridLayout_5 = QGridLayout(self.groupBox_7)
|
||||
self.gridLayout_5.setObjectName(u"gridLayout_5")
|
||||
self.pushButton_5 = QPushButton(self.groupBox_7)
|
||||
self.pushButton_5.setObjectName(u"pushButton_5")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton_5, 2, 0, 1, 1)
|
||||
|
||||
self.pushButton_4 = QPushButton(self.groupBox_7)
|
||||
self.pushButton_4.setObjectName(u"pushButton_4")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton_4, 2, 1, 1, 1)
|
||||
|
||||
self.pb_apply_code = QPushButton(self.groupBox_7)
|
||||
self.pb_apply_code.setObjectName(u"pb_apply_code")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pb_apply_code, 1, 0, 1, 1)
|
||||
|
||||
self.pushButton = QPushButton(self.groupBox_7)
|
||||
self.pushButton.setObjectName(u"pushButton")
|
||||
|
||||
self.gridLayout_5.addWidget(self.pushButton, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout.addWidget(self.groupBox_7)
|
||||
|
||||
self.InputTab.addTab(self.code_tab, "")
|
||||
|
||||
self.gridLayout.addWidget(self.InputTab, 0, 1, 12, 1)
|
||||
|
||||
self.groupBox_9 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_9.setObjectName(u"groupBox_9")
|
||||
self.groupBox_9.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_7 = QGridLayout(self.groupBox_9)
|
||||
self.gridLayout_7.setObjectName(u"gridLayout_7")
|
||||
self.pb_origin_wp = QPushButton(self.groupBox_9)
|
||||
self.pb_origin_wp.setObjectName(u"pb_origin_wp")
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_origin_wp, 0, 0, 1, 1)
|
||||
|
||||
self.pb_origin_face = QPushButton(self.groupBox_9)
|
||||
self.pb_origin_face.setObjectName(u"pb_origin_face")
|
||||
self.pb_origin_face.setCheckable(True)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_origin_face, 0, 1, 1, 1)
|
||||
|
||||
self.pb_flip_face = QPushButton(self.groupBox_9)
|
||||
self.pb_flip_face.setObjectName(u"pb_flip_face")
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_flip_face, 1, 0, 1, 1)
|
||||
|
||||
self.pb_underlay = QPushButton(self.groupBox_9)
|
||||
self.pb_underlay.setObjectName(u"pb_underlay")
|
||||
self.pb_underlay.setEnabled(False)
|
||||
self.pb_underlay.setCheckable(True)
|
||||
self.pb_underlay.setChecked(True)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_underlay, 3, 0, 1, 1)
|
||||
|
||||
self.pb_clr_face = QPushButton(self.groupBox_9)
|
||||
self.pb_clr_face.setObjectName(u"pb_clr_face")
|
||||
self.pb_clr_face.setEnabled(False)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_clr_face, 3, 1, 1, 1)
|
||||
|
||||
self.pb_to_sketch = QPushButton(self.groupBox_9)
|
||||
self.pb_to_sketch.setObjectName(u"pb_to_sketch")
|
||||
self.pb_to_sketch.setEnabled(False)
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_to_sketch, 4, 0, 1, 2)
|
||||
|
||||
self.pb_wp_new = QPushButton(self.groupBox_9)
|
||||
self.pb_wp_new.setObjectName(u"pb_wp_new")
|
||||
|
||||
self.gridLayout_7.addWidget(self.pb_wp_new, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_9, 0, 0, 1, 1)
|
||||
|
||||
self.assembly_box = QGroupBox(self.centralwidget)
|
||||
self.assembly_box.setObjectName(u"assembly_box")
|
||||
self.assembly_box.setMinimumSize(QSize(0, 50))
|
||||
|
||||
self.gridLayout.addWidget(self.assembly_box, 13, 1, 1, 2)
|
||||
|
||||
self.joint_tools = QGroupBox(self.centralwidget)
|
||||
self.joint_tools.setObjectName(u"joint_tools")
|
||||
self.joint_tools.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_10 = QGridLayout(self.joint_tools)
|
||||
self.gridLayout_10.setObjectName(u"gridLayout_10")
|
||||
self.pb_remove_connector = QPushButton(self.joint_tools)
|
||||
self.pb_remove_connector.setObjectName(u"pb_remove_connector")
|
||||
self.pb_remove_connector.setMinimumSize(QSize(50, 50))
|
||||
self.pb_remove_connector.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_remove_connector, 0, 2, 1, 1)
|
||||
|
||||
self.pb_add_connector = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector.setObjectName(u"pb_add_connector")
|
||||
self.pb_add_connector.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector, 0, 1, 1, 1)
|
||||
|
||||
self.pb_add_connector_2 = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector_2.setObjectName(u"pb_add_connector_2")
|
||||
self.pb_add_connector_2.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector_2.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector_2, 1, 1, 1, 1)
|
||||
|
||||
self.pb_add_connector_3 = QPushButton(self.joint_tools)
|
||||
self.pb_add_connector_3.setObjectName(u"pb_add_connector_3")
|
||||
self.pb_add_connector_3.setMinimumSize(QSize(50, 50))
|
||||
self.pb_add_connector_3.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_10.addWidget(self.pb_add_connector_3, 1, 2, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.joint_tools, 12, 3, 2, 1)
|
||||
|
||||
self.gl_box = QGroupBox(self.centralwidget)
|
||||
self.gl_box.setObjectName(u"gl_box")
|
||||
sizePolicy1 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy1.setHorizontalStretch(0)
|
||||
sizePolicy1.setVerticalStretch(4)
|
||||
sizePolicy1.setHeightForWidth(self.gl_box.sizePolicy().hasHeightForWidth())
|
||||
self.gl_box.setSizePolicy(sizePolicy1)
|
||||
font = QFont()
|
||||
font.setPointSize(12)
|
||||
self.gl_box.setFont(font)
|
||||
self.horizontalLayout_4 = QHBoxLayout(self.gl_box)
|
||||
#ifndef Q_OS_MAC
|
||||
self.horizontalLayout_4.setSpacing(-1)
|
||||
#endif
|
||||
self.horizontalLayout_4.setObjectName(u"horizontalLayout_4")
|
||||
self.horizontalLayout_4.setContentsMargins(12, -1, -1, -1)
|
||||
|
||||
self.gridLayout.addWidget(self.gl_box, 0, 2, 12, 1)
|
||||
|
||||
self.groupBox_11 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_11.setObjectName(u"groupBox_11")
|
||||
sizePolicy2 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy2.setHorizontalStretch(0)
|
||||
sizePolicy2.setVerticalStretch(0)
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_11.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_11.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_11.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_7 = QVBoxLayout(self.groupBox_11)
|
||||
self.verticalLayout_7.setObjectName(u"verticalLayout_7")
|
||||
self.verticalLayout_7.setContentsMargins(5, 5, 5, 5)
|
||||
self.sketch_list = QListWidget(self.groupBox_11)
|
||||
self.sketch_list.setObjectName(u"sketch_list")
|
||||
sizePolicy3 = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
sizePolicy3.setHorizontalStretch(0)
|
||||
sizePolicy3.setVerticalStretch(0)
|
||||
sizePolicy3.setHeightForWidth(self.sketch_list.sizePolicy().hasHeightForWidth())
|
||||
self.sketch_list.setSizePolicy(sizePolicy3)
|
||||
self.sketch_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_7.addWidget(self.sketch_list)
|
||||
|
||||
self.groupBox_6 = QGroupBox(self.groupBox_11)
|
||||
self.groupBox_6.setObjectName(u"groupBox_6")
|
||||
sizePolicy4 = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
|
||||
sizePolicy4.setHorizontalStretch(0)
|
||||
sizePolicy4.setVerticalStretch(0)
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_6.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_6.setSizePolicy(sizePolicy4)
|
||||
self.gridLayout_6 = QGridLayout(self.groupBox_6)
|
||||
self.gridLayout_6.setObjectName(u"gridLayout_6")
|
||||
self.gridLayout_6.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_edt_sktch = QPushButton(self.groupBox_6)
|
||||
self.pb_edt_sktch.setObjectName(u"pb_edt_sktch")
|
||||
|
||||
self.gridLayout_6.addWidget(self.pb_edt_sktch, 1, 1, 1, 1)
|
||||
|
||||
self.pb_nw_sktch = QPushButton(self.groupBox_6)
|
||||
self.pb_nw_sktch.setObjectName(u"pb_nw_sktch")
|
||||
|
||||
self.gridLayout_6.addWidget(self.pb_nw_sktch, 1, 0, 1, 1)
|
||||
|
||||
self.pb_del_sketch = QPushButton(self.groupBox_6)
|
||||
self.pb_del_sketch.setObjectName(u"pb_del_sketch")
|
||||
|
||||
self.gridLayout_6.addWidget(self.pb_del_sketch, 1, 2, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_7.addWidget(self.groupBox_6)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_11, 6, 0, 6, 1)
|
||||
|
||||
self.assembly_tools = QGroupBox(self.centralwidget)
|
||||
self.assembly_tools.setObjectName(u"assembly_tools")
|
||||
self.assembly_tools.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_12 = QGridLayout(self.assembly_tools)
|
||||
self.gridLayout_12.setObjectName(u"gridLayout_12")
|
||||
self.pb_compo_to_assembly = QPushButton(self.assembly_tools)
|
||||
self.pb_compo_to_assembly.setObjectName(u"pb_compo_to_assembly")
|
||||
self.pb_compo_to_assembly.setMinimumSize(QSize(50, 50))
|
||||
self.pb_compo_to_assembly.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_12.addWidget(self.pb_compo_to_assembly, 0, 0, 1, 1)
|
||||
|
||||
self.pb_remove_compo_from_assembly = QPushButton(self.assembly_tools)
|
||||
self.pb_remove_compo_from_assembly.setObjectName(u"pb_remove_compo_from_assembly")
|
||||
self.pb_remove_compo_from_assembly.setEnabled(True)
|
||||
sizePolicy4.setHeightForWidth(self.pb_remove_compo_from_assembly.sizePolicy().hasHeightForWidth())
|
||||
self.pb_remove_compo_from_assembly.setSizePolicy(sizePolicy4)
|
||||
self.pb_remove_compo_from_assembly.setMinimumSize(QSize(50, 50))
|
||||
self.pb_remove_compo_from_assembly.setMaximumSize(QSize(50, 50))
|
||||
self.pb_remove_compo_from_assembly.setLayoutDirection(Qt.LeftToRight)
|
||||
|
||||
self.gridLayout_12.addWidget(self.pb_remove_compo_from_assembly, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.assembly_tools, 13, 0, 1, 1)
|
||||
|
||||
self.compo_tool_box = QGroupBox(self.centralwidget)
|
||||
self.compo_tool_box.setObjectName(u"compo_tool_box")
|
||||
self.compo_tool_box.setMinimumSize(QSize(0, 50))
|
||||
self.gridLayout_9 = QGridLayout(self.compo_tool_box)
|
||||
self.gridLayout_9.setObjectName(u"gridLayout_9")
|
||||
self.pb_new_compo = QPushButton(self.compo_tool_box)
|
||||
self.pb_new_compo.setObjectName(u"pb_new_compo")
|
||||
self.pb_new_compo.setMinimumSize(QSize(50, 50))
|
||||
self.pb_new_compo.setMaximumSize(QSize(50, 50))
|
||||
|
||||
self.gridLayout_9.addWidget(self.pb_new_compo, 0, 0, 1, 1)
|
||||
|
||||
self.pb_del_compo = QPushButton(self.compo_tool_box)
|
||||
self.pb_del_compo.setObjectName(u"pb_del_compo")
|
||||
self.pb_del_compo.setEnabled(True)
|
||||
sizePolicy4.setHeightForWidth(self.pb_del_compo.sizePolicy().hasHeightForWidth())
|
||||
self.pb_del_compo.setSizePolicy(sizePolicy4)
|
||||
self.pb_del_compo.setMinimumSize(QSize(50, 50))
|
||||
self.pb_del_compo.setMaximumSize(QSize(50, 50))
|
||||
self.pb_del_compo.setLayoutDirection(Qt.LeftToRight)
|
||||
|
||||
self.gridLayout_9.addWidget(self.pb_del_compo, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.compo_tool_box, 12, 0, 1, 1)
|
||||
|
||||
self.compo_box = QGroupBox(self.centralwidget)
|
||||
self.compo_box.setObjectName(u"compo_box")
|
||||
self.compo_box.setMinimumSize(QSize(0, 50))
|
||||
|
||||
self.gridLayout.addWidget(self.compo_box, 12, 1, 1, 2)
|
||||
|
||||
self.groupBox_12 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_12.setObjectName(u"groupBox_12")
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_12.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_12.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_12.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_8 = QVBoxLayout(self.groupBox_12)
|
||||
self.verticalLayout_8.setObjectName(u"verticalLayout_8")
|
||||
self.verticalLayout_8.setContentsMargins(5, 5, 5, 5)
|
||||
self.connection_list = QListWidget(self.groupBox_12)
|
||||
self.connection_list.setObjectName(u"connection_list")
|
||||
self.connection_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_8.addWidget(self.connection_list)
|
||||
|
||||
self.groupBox_13 = QGroupBox(self.groupBox_12)
|
||||
self.groupBox_13.setObjectName(u"groupBox_13")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_13.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_13.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_13.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_13 = QGridLayout(self.groupBox_13)
|
||||
self.gridLayout_13.setObjectName(u"gridLayout_13")
|
||||
self.gridLayout_13.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_del_connection = QPushButton(self.groupBox_13)
|
||||
self.pb_del_connection.setObjectName(u"pb_del_connection")
|
||||
|
||||
self.gridLayout_13.addWidget(self.pb_del_connection, 0, 2, 1, 1)
|
||||
|
||||
self.pb_update_connection = QPushButton(self.groupBox_13)
|
||||
self.pb_update_connection.setObjectName(u"pb_update_connection")
|
||||
|
||||
self.gridLayout_13.addWidget(self.pb_update_connection, 0, 0, 1, 1)
|
||||
|
||||
self.pb_edt_sktch_4 = QPushButton(self.groupBox_13)
|
||||
self.pb_edt_sktch_4.setObjectName(u"pb_edt_sktch_4")
|
||||
|
||||
self.gridLayout_13.addWidget(self.pb_edt_sktch_4, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_8.addWidget(self.groupBox_13)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_12, 6, 3, 6, 1)
|
||||
|
||||
self.groupBox_10 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_10.setObjectName(u"groupBox_10")
|
||||
sizePolicy2.setHeightForWidth(self.groupBox_10.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_10.setSizePolicy(sizePolicy2)
|
||||
self.groupBox_10.setMaximumSize(QSize(200, 16777215))
|
||||
self.verticalLayout_6 = QVBoxLayout(self.groupBox_10)
|
||||
self.verticalLayout_6.setObjectName(u"verticalLayout_6")
|
||||
self.verticalLayout_6.setContentsMargins(5, 5, 5, 5)
|
||||
self.body_list = QListWidget(self.groupBox_10)
|
||||
self.body_list.setObjectName(u"body_list")
|
||||
self.body_list.setSelectionRectVisible(True)
|
||||
|
||||
self.verticalLayout_6.addWidget(self.body_list)
|
||||
|
||||
self.groupBox_8 = QGroupBox(self.groupBox_10)
|
||||
self.groupBox_8.setObjectName(u"groupBox_8")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_8.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_8.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_8.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_8 = QGridLayout(self.groupBox_8)
|
||||
self.gridLayout_8.setObjectName(u"gridLayout_8")
|
||||
self.gridLayout_8.setContentsMargins(2, 2, 2, 2)
|
||||
self.pb_del_body = QPushButton(self.groupBox_8)
|
||||
self.pb_del_body.setObjectName(u"pb_del_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_del_body, 0, 2, 1, 1)
|
||||
|
||||
self.pb_update_body = QPushButton(self.groupBox_8)
|
||||
self.pb_update_body.setObjectName(u"pb_update_body")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_update_body, 0, 0, 1, 1)
|
||||
|
||||
self.pb_edt_sktch_3 = QPushButton(self.groupBox_8)
|
||||
self.pb_edt_sktch_3.setObjectName(u"pb_edt_sktch_3")
|
||||
|
||||
self.gridLayout_8.addWidget(self.pb_edt_sktch_3, 0, 1, 1, 1)
|
||||
|
||||
|
||||
self.verticalLayout_6.addWidget(self.groupBox_8)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_10, 3, 3, 3, 1)
|
||||
|
||||
self.groupBox_2 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_2.setObjectName(u"groupBox_2")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_2.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_2.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_2.setMaximumSize(QSize(200, 16777215))
|
||||
self.gridLayout_2 = QGridLayout(self.groupBox_2)
|
||||
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||
self.gridLayout_2.setContentsMargins(10, -1, -1, -1)
|
||||
self.pb_arc_tool = QPushButton(self.groupBox_2)
|
||||
self.pb_arc_tool.setObjectName(u"pb_arc_tool")
|
||||
self.pb_arc_tool.setCheckable(True)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_arc_tool, 2, 0, 1, 1)
|
||||
|
||||
self.pb_rectool = QPushButton(self.groupBox_2)
|
||||
self.pb_rectool.setObjectName(u"pb_rectool")
|
||||
self.pb_rectool.setCheckable(True)
|
||||
self.pb_rectool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_rectool, 0, 1, 1, 1)
|
||||
|
||||
self.pb_circtool = QPushButton(self.groupBox_2)
|
||||
self.pb_circtool.setObjectName(u"pb_circtool")
|
||||
self.pb_circtool.setCheckable(True)
|
||||
self.pb_circtool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_circtool, 1, 0, 1, 1, Qt.AlignTop)
|
||||
|
||||
self.pb_enable_construct = QPushButton(self.groupBox_2)
|
||||
self.pb_enable_construct.setObjectName(u"pb_enable_construct")
|
||||
self.pb_enable_construct.setCheckable(True)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_enable_construct, 4, 0, 1, 1)
|
||||
|
||||
self.pb_enable_snap = QPushButton(self.groupBox_2)
|
||||
self.pb_enable_snap.setObjectName(u"pb_enable_snap")
|
||||
self.pb_enable_snap.setIconSize(QSize(13, 16))
|
||||
self.pb_enable_snap.setCheckable(True)
|
||||
self.pb_enable_snap.setChecked(True)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_enable_snap, 4, 1, 1, 1)
|
||||
|
||||
self.pb_linetool = QPushButton(self.groupBox_2)
|
||||
self.pb_linetool.setObjectName(u"pb_linetool")
|
||||
self.pb_linetool.setCheckable(True)
|
||||
self.pb_linetool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_linetool, 0, 0, 1, 1)
|
||||
|
||||
self.pb_slotool = QPushButton(self.groupBox_2)
|
||||
self.pb_slotool.setObjectName(u"pb_slotool")
|
||||
self.pb_slotool.setCheckable(True)
|
||||
self.pb_slotool.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_slotool, 1, 1, 1, 1, Qt.AlignTop)
|
||||
|
||||
self.line = QFrame(self.groupBox_2)
|
||||
self.line.setObjectName(u"line")
|
||||
self.line.setFrameShape(QFrame.Shape.HLine)
|
||||
self.line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
|
||||
self.gridLayout_2.addWidget(self.line, 3, 0, 1, 2)
|
||||
|
||||
self.pb_offset_tool = QPushButton(self.groupBox_2)
|
||||
self.pb_offset_tool.setObjectName(u"pb_offset_tool")
|
||||
|
||||
self.gridLayout_2.addWidget(self.pb_offset_tool, 2, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_2, 1, 0, 1, 1)
|
||||
|
||||
self.groupBox_3 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_3.setObjectName(u"groupBox_3")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_3.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_3.setSizePolicy(sizePolicy4)
|
||||
self.groupBox_3.setMaximumSize(QSize(200, 16777213))
|
||||
self.gridLayout_4 = QGridLayout(self.groupBox_3)
|
||||
self.gridLayout_4.setObjectName(u"gridLayout_4")
|
||||
self.pb_con_ptpt = QPushButton(self.groupBox_3)
|
||||
self.pb_con_ptpt.setObjectName(u"pb_con_ptpt")
|
||||
self.pb_con_ptpt.setCheckable(True)
|
||||
self.pb_con_ptpt.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_ptpt, 1, 0, 1, 1)
|
||||
|
||||
self.pb_con_vert = QPushButton(self.groupBox_3)
|
||||
self.pb_con_vert.setObjectName(u"pb_con_vert")
|
||||
self.pb_con_vert.setCheckable(True)
|
||||
self.pb_con_vert.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_vert, 3, 1, 1, 1)
|
||||
|
||||
self.pb_con_sym = QPushButton(self.groupBox_3)
|
||||
self.pb_con_sym.setObjectName(u"pb_con_sym")
|
||||
self.pb_con_sym.setCheckable(True)
|
||||
self.pb_con_sym.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_sym, 4, 1, 1, 1)
|
||||
|
||||
self.pb_con_mid = QPushButton(self.groupBox_3)
|
||||
self.pb_con_mid.setObjectName(u"pb_con_mid")
|
||||
self.pb_con_mid.setCheckable(True)
|
||||
self.pb_con_mid.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_mid, 2, 0, 1, 1)
|
||||
|
||||
self.pb_con_line = QPushButton(self.groupBox_3)
|
||||
self.pb_con_line.setObjectName(u"pb_con_line")
|
||||
self.pb_con_line.setCheckable(True)
|
||||
self.pb_con_line.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_line, 1, 1, 1, 1)
|
||||
|
||||
self.pb_con_horiz = QPushButton(self.groupBox_3)
|
||||
self.pb_con_horiz.setObjectName(u"pb_con_horiz")
|
||||
self.pb_con_horiz.setCheckable(True)
|
||||
self.pb_con_horiz.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_horiz, 3, 0, 1, 1)
|
||||
|
||||
self.pb_con_dist = QPushButton(self.groupBox_3)
|
||||
self.pb_con_dist.setObjectName(u"pb_con_dist")
|
||||
self.pb_con_dist.setCheckable(True)
|
||||
self.pb_con_dist.setAutoExclusive(False)
|
||||
self.pb_con_dist.setAutoRepeatDelay(297)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_dist, 4, 0, 1, 1)
|
||||
|
||||
self.pb_con_perp = QPushButton(self.groupBox_3)
|
||||
self.pb_con_perp.setObjectName(u"pb_con_perp")
|
||||
self.pb_con_perp.setCheckable(True)
|
||||
self.pb_con_perp.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_perp, 2, 1, 1, 1)
|
||||
|
||||
self.pb_con_diameter = QPushButton(self.groupBox_3)
|
||||
self.pb_con_diameter.setObjectName(u"pb_con_diameter")
|
||||
|
||||
self.gridLayout_4.addWidget(self.pb_con_diameter, 5, 0, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_3, 2, 0, 1, 1)
|
||||
|
||||
self.groupBox_5 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_5.setObjectName(u"groupBox_5")
|
||||
sizePolicy4.setHeightForWidth(self.groupBox_5.sizePolicy().hasHeightForWidth())
|
||||
self.groupBox_5.setSizePolicy(sizePolicy4)
|
||||
self.gridLayout_11 = QGridLayout(self.groupBox_5)
|
||||
self.gridLayout_11.setObjectName(u"gridLayout_11")
|
||||
self.gridLayout_11.setContentsMargins(12, 12, 12, 12)
|
||||
self.label = QLabel(self.groupBox_5)
|
||||
self.label.setObjectName(u"label")
|
||||
|
||||
self.gridLayout_11.addWidget(self.label, 5, 0, 1, 1)
|
||||
|
||||
self.pb_snap_vert = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_vert.setObjectName(u"pb_snap_vert")
|
||||
self.pb_snap_vert.setCheckable(True)
|
||||
self.pb_snap_vert.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_vert, 2, 1, 1, 1)
|
||||
|
||||
self.line_2 = QFrame(self.groupBox_5)
|
||||
self.line_2.setObjectName(u"line_2")
|
||||
self.line_2.setFrameShape(QFrame.Shape.HLine)
|
||||
self.line_2.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
|
||||
self.gridLayout_11.addWidget(self.line_2, 4, 0, 1, 2)
|
||||
|
||||
self.label_2 = QLabel(self.groupBox_5)
|
||||
self.label_2.setObjectName(u"label_2")
|
||||
|
||||
self.gridLayout_11.addWidget(self.label_2, 5, 1, 1, 1)
|
||||
|
||||
self.spinbox_snap_distance = QSpinBox(self.groupBox_5)
|
||||
self.spinbox_snap_distance.setObjectName(u"spinbox_snap_distance")
|
||||
self.spinbox_snap_distance.setMaximum(30)
|
||||
self.spinbox_snap_distance.setValue(10)
|
||||
|
||||
self.gridLayout_11.addWidget(self.spinbox_snap_distance, 6, 0, 1, 1)
|
||||
|
||||
self.pushButton_7 = QPushButton(self.groupBox_5)
|
||||
self.pushButton_7.setObjectName(u"pushButton_7")
|
||||
self.pushButton_7.setCheckable(True)
|
||||
self.pushButton_7.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pushButton_7, 3, 0, 1, 1)
|
||||
|
||||
self.pb_snap_horiz = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_horiz.setObjectName(u"pb_snap_horiz")
|
||||
self.pb_snap_horiz.setCheckable(True)
|
||||
self.pb_snap_horiz.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_horiz, 2, 0, 1, 1)
|
||||
|
||||
self.spinbox_angle_steps = QSpinBox(self.groupBox_5)
|
||||
self.spinbox_angle_steps.setObjectName(u"spinbox_angle_steps")
|
||||
self.spinbox_angle_steps.setMaximum(180)
|
||||
self.spinbox_angle_steps.setValue(15)
|
||||
|
||||
self.gridLayout_11.addWidget(self.spinbox_angle_steps, 6, 1, 1, 1)
|
||||
|
||||
self.pushButton_8 = QPushButton(self.groupBox_5)
|
||||
self.pushButton_8.setObjectName(u"pushButton_8")
|
||||
self.pushButton_8.setCheckable(True)
|
||||
self.pushButton_8.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pushButton_8, 0, 0, 1, 1)
|
||||
|
||||
self.pb_snap_midp = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_midp.setObjectName(u"pb_snap_midp")
|
||||
self.pb_snap_midp.setCheckable(True)
|
||||
self.pb_snap_midp.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_midp, 0, 1, 1, 1)
|
||||
|
||||
self.pb_snap_angle = QPushButton(self.groupBox_5)
|
||||
self.pb_snap_angle.setObjectName(u"pb_snap_angle")
|
||||
self.pb_snap_angle.setCheckable(True)
|
||||
self.pb_snap_angle.setAutoExclusive(False)
|
||||
|
||||
self.gridLayout_11.addWidget(self.pb_snap_angle, 3, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_5, 3, 0, 1, 1)
|
||||
|
||||
self.groupBox = QGroupBox(self.centralwidget)
|
||||
self.groupBox.setObjectName(u"groupBox")
|
||||
self.gridLayout_3 = QGridLayout(self.groupBox)
|
||||
self.gridLayout_3.setObjectName(u"gridLayout_3")
|
||||
self.pb_revop = QPushButton(self.groupBox)
|
||||
self.pb_revop.setObjectName(u"pb_revop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
|
||||
|
||||
self.pb_extrdop = QPushButton(self.groupBox)
|
||||
self.pb_extrdop.setObjectName(u"pb_extrdop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
|
||||
|
||||
self.pb_arrayop = QPushButton(self.groupBox)
|
||||
self.pb_arrayop.setObjectName(u"pb_arrayop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
|
||||
|
||||
self.pb_cutop = QPushButton(self.groupBox)
|
||||
self.pb_cutop.setObjectName(u"pb_cutop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
|
||||
|
||||
self.pb_combop = QPushButton(self.groupBox)
|
||||
self.pb_combop.setObjectName(u"pb_combop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
|
||||
|
||||
self.pb_moveop = QPushButton(self.groupBox)
|
||||
self.pb_moveop.setObjectName(u"pb_moveop")
|
||||
|
||||
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
|
||||
|
||||
self.groupBox_4 = QGroupBox(self.centralwidget)
|
||||
self.groupBox_4.setObjectName(u"groupBox_4")
|
||||
self.verticalLayout_2 = QVBoxLayout(self.groupBox_4)
|
||||
self.verticalLayout_2.setObjectName(u"verticalLayout_2")
|
||||
self.pushButton_2 = QPushButton(self.groupBox_4)
|
||||
self.pushButton_2.setObjectName(u"pushButton_2")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pushButton_2)
|
||||
|
||||
self.pb_export_step = QPushButton(self.groupBox_4)
|
||||
self.pb_export_step.setObjectName(u"pb_export_step")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pb_export_step)
|
||||
|
||||
self.pb_export_iges = QPushButton(self.groupBox_4)
|
||||
self.pb_export_iges.setObjectName(u"pb_export_iges")
|
||||
|
||||
self.verticalLayout_2.addWidget(self.pb_export_iges)
|
||||
|
||||
|
||||
self.gridLayout.addWidget(self.groupBox_4, 2, 3, 1, 1)
|
||||
|
||||
fluencyCAD.setCentralWidget(self.centralwidget)
|
||||
self.menubar = QMenuBar(fluencyCAD)
|
||||
self.menubar.setObjectName(u"menubar")
|
||||
self.menubar.setGeometry(QRect(0, 0, 2359, 24))
|
||||
self.menuFile = QMenu(self.menubar)
|
||||
self.menuFile.setObjectName(u"menuFile")
|
||||
self.menuSettings = QMenu(self.menubar)
|
||||
self.menuSettings.setObjectName(u"menuSettings")
|
||||
fluencyCAD.setMenuBar(self.menubar)
|
||||
self.statusbar = QStatusBar(fluencyCAD)
|
||||
self.statusbar.setObjectName(u"statusbar")
|
||||
fluencyCAD.setStatusBar(self.statusbar)
|
||||
|
||||
self.menubar.addAction(self.menuFile.menuAction())
|
||||
self.menubar.addAction(self.menuSettings.menuAction())
|
||||
self.menuFile.addAction(self.actionNew_Project)
|
||||
self.menuFile.addAction(self.actionOpen_Project)
|
||||
self.menuFile.addAction(self.actionSave_Project)
|
||||
self.menuFile.addAction(self.actionSave_Project_As)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionImport_File)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionExport_Step)
|
||||
self.menuFile.addAction(self.actionExport_Iges)
|
||||
self.menuFile.addAction(self.actionExport_Stl)
|
||||
self.menuFile.addSeparator()
|
||||
self.menuFile.addAction(self.actionExit)
|
||||
|
||||
self.retranslateUi(fluencyCAD)
|
||||
|
||||
self.InputTab.setCurrentIndex(0)
|
||||
|
||||
|
||||
QMetaObject.connectSlotsByName(fluencyCAD)
|
||||
# setupUi
|
||||
|
||||
def retranslateUi(self, fluencyCAD):
|
||||
fluencyCAD.setWindowTitle(QCoreApplication.translate("fluencyCAD", u"fluencyCAD", None))
|
||||
self.actionNew_Project.setText(QCoreApplication.translate("fluencyCAD", u"New Project", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionNew_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+N", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionOpen_Project.setText(QCoreApplication.translate("fluencyCAD", u"Open Project...", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionOpen_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+O", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave_Project.setText(QCoreApplication.translate("fluencyCAD", u"Save Project", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_Project.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionSave_Project_As.setText(QCoreApplication.translate("fluencyCAD", u"Save Project As...", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionSave_Project_As.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Shift+S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.actionImport_File.setText(QCoreApplication.translate("fluencyCAD", u"Import STEP/IGES...", None))
|
||||
self.actionExport_Step.setText(QCoreApplication.translate("fluencyCAD", u"Export STEP...", None))
|
||||
self.actionExport_Iges.setText(QCoreApplication.translate("fluencyCAD", u"Export IGES...", None))
|
||||
self.actionExport_Stl.setText(QCoreApplication.translate("fluencyCAD", u"Export STL...", None))
|
||||
self.actionExit.setText(QCoreApplication.translate("fluencyCAD", u"Exit", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.actionExit.setShortcut(QCoreApplication.translate("fluencyCAD", u"Ctrl+Q", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.InputTab.setTabText(self.InputTab.indexOf(self.sketch_tab), QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||
self.groupBox_7.setTitle(QCoreApplication.translate("fluencyCAD", u"Executive", None))
|
||||
self.pushButton_5.setText(QCoreApplication.translate("fluencyCAD", u"Load Code", None))
|
||||
self.pushButton_4.setText(QCoreApplication.translate("fluencyCAD", u"Save code", None))
|
||||
self.pb_apply_code.setText(QCoreApplication.translate("fluencyCAD", u"Apply Code", None))
|
||||
self.pushButton.setText(QCoreApplication.translate("fluencyCAD", u"Delete Code", None))
|
||||
self.InputTab.setTabText(self.InputTab.indexOf(self.code_tab), QCoreApplication.translate("fluencyCAD", u"Code", None))
|
||||
self.groupBox_9.setTitle(QCoreApplication.translate("fluencyCAD", u"Workplanes", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_origin_wp.setToolTip(QCoreApplication.translate("fluencyCAD", u"<W>orking Plane at 0, 0, 0", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_origin_wp.setText(QCoreApplication.translate("fluencyCAD", u"WP Origin", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_origin_wp.setShortcut(QCoreApplication.translate("fluencyCAD", u"W", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_origin_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Working Plane >P<rojection at selected edges face", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_origin_face.setText(QCoreApplication.translate("fluencyCAD", u" WP Face", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_origin_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"P", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_flip_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Flip >N<ormal of projected mesh.", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_flip_face.setText(QCoreApplication.translate("fluencyCAD", u"WP Flip", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_flip_face.setShortcut(QCoreApplication.translate("fluencyCAD", u"N", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_underlay.setToolTip(QCoreApplication.translate("fluencyCAD", u"Show / hide the construction lines projected from the source face", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_underlay.setText(QCoreApplication.translate("fluencyCAD", u"Underlay", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_clr_face.setToolTip(QCoreApplication.translate("fluencyCAD", u"Forget the picked source face (keep the workplane)", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_clr_face.setText(QCoreApplication.translate("fluencyCAD", u"ClrFace", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_to_sketch.setToolTip(QCoreApplication.translate("fluencyCAD", u"Convert projected construction lines into real sketch geometry", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_to_sketch.setText(QCoreApplication.translate("fluencyCAD", u"ToSketch", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_wp_new.setToolTip(QCoreApplication.translate("fluencyCAD", u"Create a new independent workplane (datum plane)", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_wp_new.setText(QCoreApplication.translate("fluencyCAD", u"WP New", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_wp_new.setShortcut(QCoreApplication.translate("fluencyCAD", u"Shift+W", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
|
||||
self.joint_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Joint Tools", None))
|
||||
self.pb_remove_connector.setText(QCoreApplication.translate("fluencyCAD", u"- Cnct", None))
|
||||
self.pb_add_connector.setText(QCoreApplication.translate("fluencyCAD", u"+ Cnct", None))
|
||||
self.pb_add_connector_2.setText(QCoreApplication.translate("fluencyCAD", u"+Jnt", None))
|
||||
self.pb_add_connector_3.setText(QCoreApplication.translate("fluencyCAD", u"-Jnt", None))
|
||||
self.gl_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Model Viewer", None))
|
||||
self.groupBox_11.setTitle(QCoreApplication.translate("fluencyCAD", u"Sketch", None))
|
||||
self.groupBox_6.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_edt_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Edt", None))
|
||||
self.pb_nw_sktch.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||
self.pb_del_sketch.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.assembly_tools.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly Tools", None))
|
||||
self.pb_compo_to_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Add", None))
|
||||
self.pb_remove_compo_from_assembly.setText(QCoreApplication.translate("fluencyCAD", u"Rem", None))
|
||||
self.compo_tool_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Tools", None))
|
||||
self.pb_new_compo.setText(QCoreApplication.translate("fluencyCAD", u"New", None))
|
||||
self.pb_del_compo.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.compo_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Components", None))
|
||||
self.groupBox_12.setTitle(QCoreApplication.translate("fluencyCAD", u"Component Connections", None))
|
||||
self.groupBox_13.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_del_connection.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.pb_update_connection.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_edt_sktch_4.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
|
||||
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
|
||||
self.pb_del_body.setText(QCoreApplication.translate("fluencyCAD", u"Del", None))
|
||||
self.pb_update_body.setText(QCoreApplication.translate("fluencyCAD", u"Upd", None))
|
||||
self.pb_edt_sktch_3.setText(QCoreApplication.translate("fluencyCAD", u"Nothing", None))
|
||||
self.groupBox_2.setTitle(QCoreApplication.translate("fluencyCAD", u"Drawing", None))
|
||||
self.pb_arc_tool.setText(QCoreApplication.translate("fluencyCAD", u"Arc", None))
|
||||
self.pb_rectool.setText(QCoreApplication.translate("fluencyCAD", u"Rctgl", None))
|
||||
self.pb_circtool.setText(QCoreApplication.translate("fluencyCAD", u"Circle", None))
|
||||
self.pb_enable_construct.setText(QCoreApplication.translate("fluencyCAD", u"Cstrct", None))
|
||||
self.pb_enable_snap.setText(QCoreApplication.translate("fluencyCAD", u"Snap", None))
|
||||
self.pb_linetool.setText(QCoreApplication.translate("fluencyCAD", u"Line", None))
|
||||
#if QT_CONFIG(shortcut)
|
||||
self.pb_linetool.setShortcut(QCoreApplication.translate("fluencyCAD", u"S", None))
|
||||
#endif // QT_CONFIG(shortcut)
|
||||
self.pb_slotool.setText(QCoreApplication.translate("fluencyCAD", u"Slot", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_offset_tool.setToolTip(QCoreApplication.translate("fluencyCAD", u"Offset selected sketch face (duplicate + offset boundary)", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
|
||||
self.groupBox_3.setTitle(QCoreApplication.translate("fluencyCAD", u"Constrain", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_ptpt.setToolTip(QCoreApplication.translate("fluencyCAD", u"Poin to Point Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_ptpt.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Pt", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_vert.setToolTip(QCoreApplication.translate("fluencyCAD", u"Vertical Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||
self.pb_con_sym.setText(QCoreApplication.translate("fluencyCAD", u"Symetrc", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_mid.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Middle Point Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_mid.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Mid_L", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_line.setToolTip(QCoreApplication.translate("fluencyCAD", u"Point to Line Constrain", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_line.setText(QCoreApplication.translate("fluencyCAD", u"Pt_Lne", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_horiz.setToolTip(QCoreApplication.translate("fluencyCAD", u"Horizontal Constrain ", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_dist.setToolTip(QCoreApplication.translate("fluencyCAD", u"Dimension of Line of Distance from Point to Line", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_dist.setText(QCoreApplication.translate("fluencyCAD", u"Distnce", None))
|
||||
#if QT_CONFIG(tooltip)
|
||||
self.pb_con_perp.setToolTip(QCoreApplication.translate("fluencyCAD", u"Constrain Line perpendicular to another line.", None))
|
||||
#endif // QT_CONFIG(tooltip)
|
||||
self.pb_con_perp.setText(QCoreApplication.translate("fluencyCAD", u"Perp_Lne", None))
|
||||
self.pb_con_diameter.setText(QCoreApplication.translate("fluencyCAD", u"Diameter", None))
|
||||
self.groupBox_5.setTitle(QCoreApplication.translate("fluencyCAD", u"Snapping Points", None))
|
||||
self.label.setText(QCoreApplication.translate("fluencyCAD", u"Snp Dst", None))
|
||||
self.pb_snap_vert.setText(QCoreApplication.translate("fluencyCAD", u"Vert", None))
|
||||
self.label_2.setText(QCoreApplication.translate("fluencyCAD", u"Angl Stps", None))
|
||||
self.spinbox_snap_distance.setSuffix(QCoreApplication.translate("fluencyCAD", u"mm", None))
|
||||
self.pushButton_7.setText(QCoreApplication.translate("fluencyCAD", u"Grid", None))
|
||||
self.pb_snap_horiz.setText(QCoreApplication.translate("fluencyCAD", u"Horiz", None))
|
||||
self.spinbox_angle_steps.setSuffix(QCoreApplication.translate("fluencyCAD", u"\u00b0", None))
|
||||
self.pushButton_8.setText(QCoreApplication.translate("fluencyCAD", u"Pnt", None))
|
||||
self.pb_snap_midp.setText(QCoreApplication.translate("fluencyCAD", u"MidP", None))
|
||||
self.pb_snap_angle.setText(QCoreApplication.translate("fluencyCAD", u"Angles", None))
|
||||
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
|
||||
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
|
||||
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
|
||||
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
|
||||
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
|
||||
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
|
||||
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
|
||||
self.groupBox_4.setTitle(QCoreApplication.translate("fluencyCAD", u"Export", None))
|
||||
self.pushButton_2.setText(QCoreApplication.translate("fluencyCAD", u"STL", None))
|
||||
self.pb_export_step.setText(QCoreApplication.translate("fluencyCAD", u"STEP", None))
|
||||
self.pb_export_iges.setText(QCoreApplication.translate("fluencyCAD", u"IGES", None))
|
||||
self.menuFile.setTitle(QCoreApplication.translate("fluencyCAD", u"File", None))
|
||||
self.menuSettings.setTitle(QCoreApplication.translate("fluencyCAD", u"Settings", None))
|
||||
# retranslateUi
|
||||
|
||||
@@ -0,0 +1,969 @@
|
||||
"""Render window — separate photorealistic rendering of a selected shape.
|
||||
|
||||
Opens as an independent QMainWindow so it doesn't clutter the workspace.
|
||||
Uses a swappable RenderBackend (default: Mitsuba 3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import Qt, Signal, Slot, QThread, QTimer
|
||||
from PySide6.QtGui import QImage, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QColorDialog,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QSplitter,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from fluency.rendering.material_presets import get_preset, preset_names
|
||||
from fluency.rendering.occ_to_mesh import occ_shape_bounds, occ_shape_to_ply
|
||||
from fluency.rendering.render_backend import (
|
||||
GroundPlaneConfig,
|
||||
LightingConfig,
|
||||
RenderBackend,
|
||||
RenderCamera,
|
||||
RenderMaterial,
|
||||
RenderSettings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Background render thread ────────────────────────────────────────
|
||||
|
||||
|
||||
class _RenderThread(QThread):
|
||||
"""Background thread for Mitsuba rendering."""
|
||||
|
||||
finished = Signal(np.ndarray)
|
||||
error = Signal(str)
|
||||
progress = Signal(float)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: RenderBackend,
|
||||
mesh_path: str,
|
||||
material: RenderMaterial,
|
||||
camera: RenderCamera,
|
||||
settings: RenderSettings,
|
||||
parent=None,
|
||||
is_preview: bool = False,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._backend = backend
|
||||
self._mesh_path = mesh_path
|
||||
self._material = material
|
||||
self._camera = camera
|
||||
self._settings = settings
|
||||
self._is_preview = is_preview
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self):
|
||||
self._cancelled = True
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
if self._is_preview:
|
||||
image = self._backend.render_preview(
|
||||
self._mesh_path,
|
||||
self._material,
|
||||
self._camera,
|
||||
self._settings,
|
||||
)
|
||||
else:
|
||||
image = self._backend.render(
|
||||
self._mesh_path,
|
||||
self._material,
|
||||
self._camera,
|
||||
self._settings,
|
||||
progress_callback=lambda p: self.progress.emit(p),
|
||||
)
|
||||
if not self._cancelled:
|
||||
self.finished.emit(image)
|
||||
except Exception as e:
|
||||
if not self._cancelled:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
# ── Render window ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RenderWindow(QMainWindow):
|
||||
"""Standalone render window for photorealistic rendering.
|
||||
|
||||
Usage::
|
||||
|
||||
from fluency.ui.render_window import RenderWindow
|
||||
win = RenderWindow(shape=some_topods_shape, parent=main_window)
|
||||
win.show()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
shape=None,
|
||||
backend: Optional[RenderBackend] = None,
|
||||
camera: Optional[RenderCamera] = None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._shape = shape
|
||||
self._backend = backend
|
||||
self._mesh_path: Optional[str] = None
|
||||
|
||||
# Rendering threads & images
|
||||
self._render_thread: Optional[_RenderThread] = None
|
||||
self._preview_thread: Optional[_RenderThread] = None
|
||||
self._last_image: Optional[np.ndarray] = None # full render result
|
||||
self._last_preview: Optional[np.ndarray] = None # preview result
|
||||
self._camera: Optional[RenderCamera] = camera
|
||||
self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5)
|
||||
|
||||
# Which operation is currently running? "render" | "preview" | None
|
||||
self._active_mode: Optional[str] = None
|
||||
|
||||
# Auto-preview debounce timer (500 ms)
|
||||
self._auto_preview_timer: Optional[QTimer] = None
|
||||
|
||||
self.setWindowTitle("Render")
|
||||
self.setMinimumSize(800, 600)
|
||||
self.resize(1024, 768)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose)
|
||||
|
||||
self._init_ui()
|
||||
self._init_backend()
|
||||
self._setup_auto_preview()
|
||||
self._prepare_mesh()
|
||||
self._populate_camera_controls()
|
||||
|
||||
# ── UI Setup ────────────────────────────────────────────────────
|
||||
|
||||
def _init_ui(self):
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
main_layout = QHBoxLayout(central)
|
||||
main_layout.setContentsMargins(8, 8, 8, 8)
|
||||
main_layout.setSpacing(8)
|
||||
|
||||
# ── Left sidebar: Render Settings ───────────────────────────
|
||||
self._settings_panel = self._build_settings_panel()
|
||||
main_layout.addWidget(self._settings_panel)
|
||||
|
||||
# ── Right side: Image + Controls ────────────────────────────
|
||||
right = QVBoxLayout()
|
||||
right.setSpacing(6)
|
||||
|
||||
# Image preview area with status badge below
|
||||
self._image_container = QVBoxLayout()
|
||||
self._image_container.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self._image_label = QLabel("Click Preview or Render to start")
|
||||
self._image_label.setAlignment(Qt.AlignCenter)
|
||||
self._image_label.setSizePolicy(
|
||||
QSizePolicy.Expanding, QSizePolicy.Expanding
|
||||
)
|
||||
self._image_label.setMinimumSize(400, 300)
|
||||
self._image_label.setStyleSheet(
|
||||
"background: #1e1e2e; color: #6c7086; font-size: 14px;"
|
||||
)
|
||||
self._image_container.addWidget(self._image_label, stretch=1)
|
||||
|
||||
# Status badge ("Preview" / "Full Render") — shown below image
|
||||
self._status_badge = QLabel("")
|
||||
self._status_badge.setAlignment(Qt.AlignCenter)
|
||||
self._status_badge.setStyleSheet(
|
||||
"color: #a6adc8; font-size: 11px; padding: 2px;"
|
||||
)
|
||||
self._image_container.addWidget(self._status_badge)
|
||||
|
||||
right.addLayout(self._image_container, stretch=1)
|
||||
|
||||
# Controls row
|
||||
controls = QHBoxLayout()
|
||||
controls.setSpacing(8)
|
||||
|
||||
# Material preset
|
||||
controls.addWidget(QLabel("Material:"))
|
||||
self._material_combo = QComboBox()
|
||||
for name in preset_names():
|
||||
self._material_combo.addItem(name)
|
||||
self._material_combo.setCurrentText("Brushed Steel")
|
||||
self._material_combo.setMinimumWidth(140)
|
||||
controls.addWidget(self._material_combo)
|
||||
|
||||
# Auto-preview checkbox
|
||||
self._auto_preview_cb = QCheckBox("Auto-preview")
|
||||
self._auto_preview_cb.setChecked(True)
|
||||
self._auto_preview_cb.setToolTip(
|
||||
"Automatically preview when settings change"
|
||||
)
|
||||
controls.addWidget(self._auto_preview_cb)
|
||||
|
||||
# Samples per pixel
|
||||
controls.addWidget(QLabel("Quality:"))
|
||||
self._spp_spin = QSpinBox()
|
||||
self._spp_spin.setRange(16, 4096)
|
||||
self._spp_spin.setValue(256)
|
||||
self._spp_spin.setSingleStep(64)
|
||||
self._spp_spin.setSuffix(" spp")
|
||||
self._spp_spin.setMinimumWidth(100)
|
||||
controls.addWidget(self._spp_spin)
|
||||
|
||||
# Resolution
|
||||
controls.addWidget(QLabel("Resolution:"))
|
||||
self._res_combo = QComboBox()
|
||||
self._res_combo.addItems(
|
||||
["1280×720", "1920×1080", "2560×1440", "3840×2160"]
|
||||
)
|
||||
self._res_combo.setCurrentText("1920×1080")
|
||||
controls.addWidget(self._res_combo)
|
||||
|
||||
controls.addStretch()
|
||||
|
||||
# Preview button (fast, low quality)
|
||||
self._preview_btn = QPushButton("⚡ Preview")
|
||||
self._preview_btn.setMinimumWidth(90)
|
||||
self._preview_btn.clicked.connect(self._on_preview)
|
||||
controls.addWidget(self._preview_btn)
|
||||
|
||||
# Render button (full quality)
|
||||
self._render_btn = QPushButton("▶ Render")
|
||||
self._render_btn.setMinimumWidth(100)
|
||||
self._render_btn.clicked.connect(self._on_render)
|
||||
controls.addWidget(self._render_btn)
|
||||
|
||||
# Cancel button
|
||||
self._cancel_btn = QPushButton("⏹ Cancel")
|
||||
self._cancel_btn.setMinimumWidth(80)
|
||||
self._cancel_btn.setEnabled(False)
|
||||
self._cancel_btn.clicked.connect(self._on_cancel)
|
||||
controls.addWidget(self._cancel_btn)
|
||||
|
||||
# Export button
|
||||
self._export_btn = QPushButton("💾 Export")
|
||||
self._export_btn.setMinimumWidth(80)
|
||||
self._export_btn.setEnabled(False)
|
||||
self._export_btn.clicked.connect(self._on_export)
|
||||
controls.addWidget(self._export_btn)
|
||||
|
||||
right.addLayout(controls)
|
||||
|
||||
# Progress bar
|
||||
self._progress = QProgressBar()
|
||||
self._progress.setRange(0, 100)
|
||||
self._progress.setValue(0)
|
||||
self._progress.setTextVisible(True)
|
||||
self._progress.setFormat("%p%")
|
||||
right.addWidget(self._progress)
|
||||
|
||||
main_layout.addLayout(right, stretch=1)
|
||||
|
||||
# ── Settings panel builder ─────────────────────────────────────
|
||||
|
||||
def _build_settings_panel(self) -> QWidget:
|
||||
"""Build the left sidebar with camera, lighting, and ground plane controls."""
|
||||
panel = QWidget()
|
||||
panel.setFixedWidth(240)
|
||||
layout = QVBoxLayout(panel)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# ── Camera Parameters ───────────────────────────────────────
|
||||
camera_gb = QGroupBox("Camera")
|
||||
camera_layout = QVBoxLayout(camera_gb)
|
||||
camera_layout.setSpacing(4)
|
||||
|
||||
# Origin
|
||||
origin_grid = QGridLayout()
|
||||
origin_grid.addWidget(QLabel("Origin"), 0, 0)
|
||||
self._cam_origin_x = QDoubleSpinBox() # type: ignore[attr-defined]
|
||||
self._cam_origin_y = QDoubleSpinBox() # type: ignore[attr-defined]
|
||||
self._cam_origin_z = QDoubleSpinBox() # type: ignore[attr-defined]
|
||||
for spin in (self._cam_origin_x, self._cam_origin_y, self._cam_origin_z):
|
||||
spin.setRange(-10000, 10000)
|
||||
spin.setDecimals(1)
|
||||
spin.setSuffix(" mm")
|
||||
spin.setFixedWidth(80)
|
||||
origin_grid.addWidget(QLabel("X:"), 1, 0)
|
||||
origin_grid.addWidget(self._cam_origin_x, 1, 1)
|
||||
origin_grid.addWidget(QLabel("Y:"), 2, 0)
|
||||
origin_grid.addWidget(self._cam_origin_y, 2, 1)
|
||||
origin_grid.addWidget(QLabel("Z:"), 3, 0)
|
||||
origin_grid.addWidget(self._cam_origin_z, 3, 1)
|
||||
camera_layout.addLayout(origin_grid)
|
||||
|
||||
# Target
|
||||
target_grid = QGridLayout()
|
||||
target_grid.addWidget(QLabel("Target"), 0, 0)
|
||||
self._cam_target_x = QDoubleSpinBox() # type: ignore[attr-defined]
|
||||
self._cam_target_y = QDoubleSpinBox() # type: ignore[attr-defined]
|
||||
self._cam_target_z = QDoubleSpinBox() # type: ignore[attr-defined]
|
||||
for spin in (self._cam_target_x, self._cam_target_y, self._cam_target_z):
|
||||
spin.setRange(-10000, 10000)
|
||||
spin.setDecimals(1)
|
||||
spin.setSuffix(" mm")
|
||||
spin.setFixedWidth(80)
|
||||
target_grid.addWidget(QLabel("X:"), 1, 0)
|
||||
target_grid.addWidget(self._cam_target_x, 1, 1)
|
||||
target_grid.addWidget(QLabel("Y:"), 2, 0)
|
||||
target_grid.addWidget(self._cam_target_y, 2, 1)
|
||||
target_grid.addWidget(QLabel("Z:"), 3, 0)
|
||||
target_grid.addWidget(self._cam_target_z, 3, 1)
|
||||
camera_layout.addLayout(target_grid)
|
||||
|
||||
# Up vector
|
||||
up_grid = QGridLayout()
|
||||
up_grid.addWidget(QLabel("Up"), 0, 0)
|
||||
self._cam_up_x = QDoubleSpinBox() # type: ignore[attr-defined]
|
||||
self._cam_up_y = QDoubleSpinBox() # type: ignore[attr-defined]
|
||||
self._cam_up_z = QDoubleSpinBox() # type: ignore[attr-defined]
|
||||
for spin in (self._cam_up_x, self._cam_up_y, self._cam_up_z):
|
||||
spin.setRange(-10, 10)
|
||||
spin.setDecimals(1)
|
||||
spin.setFixedWidth(80)
|
||||
up_grid.addWidget(QLabel("X:"), 1, 0)
|
||||
up_grid.addWidget(self._cam_up_x, 1, 1)
|
||||
up_grid.addWidget(QLabel("Y:"), 2, 0)
|
||||
up_grid.addWidget(self._cam_up_y, 2, 1)
|
||||
up_grid.addWidget(QLabel("Z:"), 3, 0)
|
||||
up_grid.addWidget(self._cam_up_z, 3, 1)
|
||||
camera_layout.addLayout(up_grid)
|
||||
|
||||
# FOV
|
||||
fov_row = QHBoxLayout()
|
||||
fov_row.addWidget(QLabel("FOV:"))
|
||||
self._cam_fov_spin = QDoubleSpinBox() # type: ignore[attr-defined]
|
||||
self._cam_fov_spin.setRange(1, 179)
|
||||
self._cam_fov_spin.setValue(45.0)
|
||||
self._cam_fov_spin.setSuffix("°")
|
||||
self._cam_fov_spin.setFixedWidth(80)
|
||||
fov_row.addWidget(self._cam_fov_spin)
|
||||
fov_row.addStretch()
|
||||
camera_layout.addLayout(fov_row)
|
||||
|
||||
# Reset button
|
||||
reset_cam_btn = QPushButton("Reset to Viewport")
|
||||
reset_cam_btn.clicked.connect(self._reset_camera_to_viewport)
|
||||
camera_layout.addWidget(reset_cam_btn)
|
||||
|
||||
layout.addWidget(camera_gb)
|
||||
|
||||
# ── Lighting ────────────────────────────────────────────────
|
||||
light_gb = QGroupBox("Lighting")
|
||||
light_layout = QVBoxLayout(light_gb)
|
||||
light_layout.setSpacing(4)
|
||||
|
||||
# Ambient intensity slider
|
||||
ambient_row = QHBoxLayout()
|
||||
ambient_row.addWidget(QLabel("Ambient:"))
|
||||
self._light_ambient_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined]
|
||||
self._light_ambient_slider.setRange(0, 100)
|
||||
self._light_ambient_slider.setValue(30)
|
||||
ambient_row.addWidget(self._light_ambient_slider)
|
||||
self._light_ambient_label = QLabel("0.30")
|
||||
self._light_ambient_label.setFixedWidth(40)
|
||||
ambient_row.addWidget(self._light_ambient_label)
|
||||
light_layout.addLayout(ambient_row)
|
||||
|
||||
# Key light
|
||||
key_row = QHBoxLayout()
|
||||
key_row.addWidget(QLabel("Key:"))
|
||||
self._light_key_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined]
|
||||
self._light_key_slider.setRange(0, 200)
|
||||
self._light_key_slider.setValue(70)
|
||||
key_row.addWidget(self._light_key_slider)
|
||||
self._light_key_label = QLabel("3.5")
|
||||
self._light_key_label.setFixedWidth(40)
|
||||
key_row.addWidget(self._light_key_label)
|
||||
light_layout.addLayout(key_row)
|
||||
|
||||
# Fill light
|
||||
fill_row = QHBoxLayout()
|
||||
fill_row.addWidget(QLabel("Fill:"))
|
||||
self._light_fill_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined]
|
||||
self._light_fill_slider.setRange(0, 200)
|
||||
self._light_fill_slider.setValue(30)
|
||||
fill_row.addWidget(self._light_fill_slider)
|
||||
self._light_fill_label = QLabel("1.5")
|
||||
self._light_fill_label.setFixedWidth(40)
|
||||
fill_row.addWidget(self._light_fill_label)
|
||||
light_layout.addLayout(fill_row)
|
||||
|
||||
# Rim light
|
||||
rim_row = QHBoxLayout()
|
||||
rim_row.addWidget(QLabel("Rim:"))
|
||||
self._light_rim_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined]
|
||||
self._light_rim_slider.setRange(0, 200)
|
||||
self._light_rim_slider.setValue(24)
|
||||
rim_row.addWidget(self._light_rim_slider)
|
||||
self._light_rim_label = QLabel("1.2")
|
||||
self._light_rim_label.setFixedWidth(40)
|
||||
rim_row.addWidget(self._light_rim_label)
|
||||
light_layout.addLayout(rim_row)
|
||||
|
||||
# Lighting presets
|
||||
preset_row = QHBoxLayout()
|
||||
preset_row.addWidget(QLabel("Preset:"))
|
||||
self._light_preset_combo = QComboBox()
|
||||
self._light_preset_combo.addItems([
|
||||
"Studio (default)", "Soft", "Dramatic", "Bright",
|
||||
])
|
||||
self._light_preset_combo.setCurrentIndex(0)
|
||||
self._light_preset_combo.currentTextChanged.connect(
|
||||
self._on_lighting_preset_changed
|
||||
)
|
||||
preset_row.addWidget(self._light_preset_combo)
|
||||
light_layout.addLayout(preset_row)
|
||||
|
||||
# Wire slider value changes to labels and auto-preview
|
||||
self._wire_light_slider(
|
||||
self._light_ambient_slider, self._light_ambient_label, 100
|
||||
)
|
||||
self._wire_light_slider(
|
||||
self._light_key_slider, self._light_key_label, 50
|
||||
)
|
||||
self._wire_light_slider(
|
||||
self._light_fill_slider, self._light_fill_label, 50
|
||||
)
|
||||
self._wire_light_slider(
|
||||
self._light_rim_slider, self._light_rim_label, 50
|
||||
)
|
||||
|
||||
layout.addWidget(light_gb)
|
||||
|
||||
# ── Ground Plane ────────────────────────────────────────────
|
||||
ground_gb = QGroupBox("Ground Plane")
|
||||
ground_layout = QVBoxLayout(ground_gb)
|
||||
ground_layout.setSpacing(4)
|
||||
|
||||
# Enabled checkbox
|
||||
self._ground_enabled_cb = QCheckBox("Enabled")
|
||||
self._ground_enabled_cb.setChecked(False)
|
||||
self._ground_enabled_cb.toggled.connect(
|
||||
lambda: self._toggle_ground_plane_widgets()
|
||||
)
|
||||
ground_layout.addWidget(self._ground_enabled_cb)
|
||||
|
||||
# Color button
|
||||
color_row = QHBoxLayout()
|
||||
color_row.addWidget(QLabel("Color:"))
|
||||
self._ground_color_btn = QPushButton()
|
||||
self._ground_color_btn.setFixedSize(40, 24)
|
||||
self._ground_color_btn.setStyleSheet(
|
||||
"background-color: rgb(128, 128, 128); border: 1px solid #555;"
|
||||
)
|
||||
self._ground_color_btn.clicked.connect(self._on_ground_color_pick)
|
||||
color_row.addWidget(self._ground_color_btn)
|
||||
color_row.addStretch()
|
||||
ground_layout.addLayout(color_row)
|
||||
|
||||
# Roughness slider
|
||||
rough_row = QHBoxLayout()
|
||||
rough_row.addWidget(QLabel("Roughness:"))
|
||||
self._ground_rough_slider = QSlider(Qt.Horizontal) # type: ignore[attr-defined]
|
||||
self._ground_rough_slider.setRange(0, 100)
|
||||
self._ground_rough_slider.setValue(80)
|
||||
rough_row.addWidget(self._ground_rough_slider)
|
||||
self._ground_rough_label = QLabel("0.80")
|
||||
self._ground_rough_label.setFixedWidth(40)
|
||||
rough_row.addWidget(self._ground_rough_label)
|
||||
ground_layout.addLayout(rough_row)
|
||||
|
||||
# Distance below slider
|
||||
dist_row = QHBoxLayout()
|
||||
dist_row.addWidget(QLabel("Offset:"))
|
||||
self._ground_dist_spin = QDoubleSpinBox() # type: ignore[attr-defined]
|
||||
self._ground_dist_spin.setRange(0, 500)
|
||||
self._ground_dist_spin.setValue(0.0)
|
||||
self._ground_dist_spin.setSuffix(" mm")
|
||||
self._ground_dist_spin.setFixedWidth(80)
|
||||
dist_row.addWidget(self._ground_dist_spin)
|
||||
dist_row.addStretch()
|
||||
ground_layout.addLayout(dist_row)
|
||||
|
||||
# Wire roughness slider to label and auto-preview
|
||||
self._ground_rough_slider.valueChanged.connect(
|
||||
lambda v: (
|
||||
self._ground_rough_label.setText(f"{v / 100:.2f}"),
|
||||
self._schedule_auto_preview(),
|
||||
)
|
||||
)
|
||||
# Wire distance spinbox to auto-preview
|
||||
self._ground_dist_spin.valueChanged.connect(
|
||||
lambda: self._schedule_auto_preview()
|
||||
)
|
||||
|
||||
# Initially disable ground widgets since checkbox is unchecked
|
||||
self._toggle_ground_plane_widgets()
|
||||
|
||||
layout.addWidget(ground_gb)
|
||||
layout.addStretch()
|
||||
|
||||
return panel
|
||||
|
||||
def _wire_light_slider(self, slider, label, divisor):
|
||||
"""Connect a lighting slider to its value label and auto-preview."""
|
||||
slider.valueChanged.connect(
|
||||
lambda v: (
|
||||
label.setText(f"{v / divisor:.1f}"),
|
||||
self._schedule_auto_preview(),
|
||||
)
|
||||
)
|
||||
|
||||
def _toggle_ground_plane_widgets(self):
|
||||
"""Enable/disable ground plane widgets based on checkbox."""
|
||||
enabled = self._ground_enabled_cb.isChecked()
|
||||
for w in (
|
||||
self._ground_color_btn,
|
||||
self._ground_rough_slider,
|
||||
self._ground_dist_spin,
|
||||
):
|
||||
w.setEnabled(enabled)
|
||||
if enabled:
|
||||
self._schedule_auto_preview()
|
||||
|
||||
def _on_ground_color_pick(self):
|
||||
"""Open color dialog for ground plane color."""
|
||||
current = self._ground_color # type: ignore[attr-defined]
|
||||
color = QColorDialog.getColor(
|
||||
QColor(*[int(c * 255) for c in current]), self
|
||||
)
|
||||
if color.isValid():
|
||||
r, g, b = color.red() / 255.0, color.green() / 255.0, color.blue() / 255.0
|
||||
self._ground_color = (r, g, b) # type: ignore[attr-defined]
|
||||
self._ground_color_btn.setStyleSheet(
|
||||
f"background-color: rgb({color.red()}, {color.green()}, {color.blue()}); "
|
||||
"border: 1px solid #555;"
|
||||
)
|
||||
self._schedule_auto_preview()
|
||||
|
||||
def _on_lighting_preset_changed(self, preset_name: str):
|
||||
"""Apply a lighting preset by setting slider values."""
|
||||
presets = {
|
||||
"Studio (default)": {"ambient": 30, "key": 70, "fill": 30, "rim": 24},
|
||||
"Soft": {"ambient": 50, "key": 40, "fill": 40, "rim": 15},
|
||||
"Dramatic": {"ambient": 10, "key": 120, "fill": 15, "rim": 60},
|
||||
"Bright": {"ambient": 70, "key": 100, "fill": 70, "rim": 40},
|
||||
}
|
||||
p = presets.get(preset_name)
|
||||
if p:
|
||||
self._light_ambient_slider.setValue(p["ambient"])
|
||||
self._light_key_slider.setValue(p["key"])
|
||||
self._light_fill_slider.setValue(p["fill"])
|
||||
self._light_rim_slider.setValue(p["rim"])
|
||||
|
||||
def _reset_camera_to_viewport(self):
|
||||
"""Reset camera parameters to match the 3D viewport."""
|
||||
if self._camera is None:
|
||||
return
|
||||
o = self._camera.origin
|
||||
t = self._camera.target
|
||||
u = self._camera.up
|
||||
self._cam_origin_x.setValue(o[0])
|
||||
self._cam_origin_y.setValue(o[1])
|
||||
self._cam_origin_z.setValue(o[2])
|
||||
self._cam_target_x.setValue(t[0])
|
||||
self._cam_target_y.setValue(t[1])
|
||||
self._cam_target_z.setValue(t[2])
|
||||
self._cam_up_x.setValue(u[0])
|
||||
self._cam_up_y.setValue(u[1])
|
||||
self._cam_up_z.setValue(u[2])
|
||||
self._cam_fov_spin.setValue(self._camera.fov)
|
||||
self._schedule_auto_preview()
|
||||
|
||||
def _init_backend(self):
|
||||
"""Select the best available backend."""
|
||||
if self._backend is not None:
|
||||
return
|
||||
|
||||
from fluency.rendering.mitsuba_backend import MitsubaBackend
|
||||
|
||||
mitsuba = MitsubaBackend()
|
||||
if mitsuba.is_available():
|
||||
self._backend = mitsuba
|
||||
logger.info(f"Using render backend: {mitsuba.name()}")
|
||||
return
|
||||
|
||||
# No backend available — disable both buttons
|
||||
self._backend = None
|
||||
self._render_btn.setEnabled(False)
|
||||
self._render_btn.setToolTip(
|
||||
"No render backend installed (pip install mitsuba)"
|
||||
)
|
||||
self._preview_btn.setEnabled(False)
|
||||
self._preview_btn.setToolTip(
|
||||
"No render backend installed (pip install mitsuba)"
|
||||
)
|
||||
|
||||
def _prepare_mesh(self):
|
||||
"""Tessellate the OCC shape to a PLY file for the renderer."""
|
||||
if self._shape is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self._mesh_path = occ_shape_to_ply(
|
||||
self._shape, linear_deflection=0.1, angular_deflection=0.15
|
||||
)
|
||||
# Use externally-provided camera (from 3D viewport), or compute default
|
||||
if self._camera is None:
|
||||
mn, mx = occ_shape_bounds(self._shape)
|
||||
self._camera = self._backend.default_camera_from_bounds(mn, mx)
|
||||
logger.info(f"Prepared mesh: {self._mesh_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to prepare mesh: {e}")
|
||||
QMessageBox.warning(
|
||||
self, "Render Error", f"Failed to tessellate shape:\n{e}"
|
||||
)
|
||||
|
||||
# ── Auto-preview setup ────────────────────────────────────────────
|
||||
|
||||
def _setup_auto_preview(self):
|
||||
"""Set up debounced auto-preview on settings changes."""
|
||||
self._auto_preview_timer = QTimer(self)
|
||||
self._auto_preview_timer.setSingleShot(True)
|
||||
self._auto_preview_timer.timeout.connect(self._on_preview)
|
||||
# Wire material combo to trigger debounced preview
|
||||
self._material_combo.currentTextChanged.connect(
|
||||
lambda: self._schedule_auto_preview()
|
||||
)
|
||||
# Wire camera controls to trigger debounced preview
|
||||
for spin in (
|
||||
self._cam_origin_x, self._cam_origin_y, self._cam_origin_z,
|
||||
self._cam_target_x, self._cam_target_y, self._cam_target_z,
|
||||
self._cam_up_x, self._cam_up_y, self._cam_up_z,
|
||||
self._cam_fov_spin,
|
||||
):
|
||||
spin.valueChanged.connect(lambda: self._schedule_auto_preview())
|
||||
|
||||
def _populate_camera_controls(self):
|
||||
"""Fill camera spinboxes from the current RenderCamera."""
|
||||
if self._camera is None:
|
||||
return
|
||||
o = self._camera.origin
|
||||
t = self._camera.target
|
||||
u = self._camera.up
|
||||
self._cam_origin_x.setValue(o[0])
|
||||
self._cam_origin_y.setValue(o[1])
|
||||
self._cam_origin_z.setValue(o[2])
|
||||
self._cam_target_x.setValue(t[0])
|
||||
self._cam_target_y.setValue(t[1])
|
||||
self._cam_target_z.setValue(t[2])
|
||||
self._cam_up_x.setValue(u[0])
|
||||
self._cam_up_y.setValue(u[1])
|
||||
self._cam_up_z.setValue(u[2])
|
||||
self._cam_fov_spin.setValue(self._camera.fov)
|
||||
|
||||
def _schedule_auto_preview(self):
|
||||
"""Schedule a preview after debounce delay if auto-preview is enabled."""
|
||||
if not self._auto_preview_cb.isChecked():
|
||||
return
|
||||
# Don't schedule if something is already rendering
|
||||
if self._active_mode is not None:
|
||||
return
|
||||
if self._backend is None or self._mesh_path is None:
|
||||
return
|
||||
self._auto_preview_timer.start(500)
|
||||
|
||||
# ── Config builders from UI controls ───────────────────────────
|
||||
|
||||
def _build_camera_from_ui(self) -> RenderCamera:
|
||||
"""Build a RenderCamera from the camera control widgets."""
|
||||
return RenderCamera(
|
||||
origin=(
|
||||
self._cam_origin_x.value(),
|
||||
self._cam_origin_y.value(),
|
||||
self._cam_origin_z.value(),
|
||||
),
|
||||
target=(
|
||||
self._cam_target_x.value(),
|
||||
self._cam_target_y.value(),
|
||||
self._cam_target_z.value(),
|
||||
),
|
||||
up=(
|
||||
self._cam_up_x.value(),
|
||||
self._cam_up_y.value(),
|
||||
self._cam_up_z.value(),
|
||||
),
|
||||
fov=self._cam_fov_spin.value(),
|
||||
)
|
||||
|
||||
def _build_lighting_config(self) -> LightingConfig:
|
||||
"""Build a LightingConfig from the lighting control widgets."""
|
||||
return LightingConfig(
|
||||
ambient_intensity=self._light_ambient_slider.value() / 100.0,
|
||||
key_intensity=self._light_key_slider.value() / 50.0,
|
||||
fill_intensity=self._light_fill_slider.value() / 50.0,
|
||||
rim_intensity=self._light_rim_slider.value() / 50.0,
|
||||
)
|
||||
|
||||
def _build_ground_plane_config(self) -> GroundPlaneConfig:
|
||||
"""Build a GroundPlaneConfig from the ground plane widgets."""
|
||||
return GroundPlaneConfig(
|
||||
enabled=self._ground_enabled_cb.isChecked(),
|
||||
color=self._ground_color,
|
||||
roughness=self._ground_rough_slider.value() / 100.0,
|
||||
distance_below=self._ground_dist_spin.value(),
|
||||
)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _display_image(self, image: np.ndarray, mode: str) -> None:
|
||||
"""Display an image in the label with a status badge.
|
||||
|
||||
*mode* is 'preview' or 'render'.
|
||||
"""
|
||||
h, w = image.shape[:2]
|
||||
rgb = (np.clip(image, 0.0, 1.0) * 255).astype(np.uint8)
|
||||
qimg = QImage(rgb.data, w, h, 3 * w, QImage.Format_RGB888)
|
||||
pixmap = QPixmap.fromImage(qimg)
|
||||
|
||||
# Scale to fit label
|
||||
scaled = pixmap.scaled(
|
||||
self._image_label.size(),
|
||||
Qt.KeepAspectRatio,
|
||||
Qt.SmoothTransformation,
|
||||
)
|
||||
self._image_label.setPixmap(scaled)
|
||||
|
||||
if mode == "preview":
|
||||
self._status_badge.setText("⚡ Preview — low quality")
|
||||
self._status_badge.setStyleSheet(
|
||||
"color: #f9e2af; font-size: 11px; padding: 2px;"
|
||||
)
|
||||
else:
|
||||
self._status_badge.setText("✓ Full render complete")
|
||||
self._status_badge.setStyleSheet(
|
||||
"color: #a6e3a1; font-size: 11px; padding: 2px;"
|
||||
)
|
||||
|
||||
def _cancel_active_thread(self):
|
||||
"""Cancel whichever thread is currently running."""
|
||||
if self._active_mode == "preview" and self._preview_thread:
|
||||
self._preview_thread.cancel()
|
||||
self._preview_thread.terminate()
|
||||
self._preview_thread.wait(2000)
|
||||
elif self._active_mode == "render" and self._render_thread:
|
||||
self._render_thread.cancel()
|
||||
self._render_thread.terminate()
|
||||
self._render_thread.wait(2000)
|
||||
self._active_mode = None
|
||||
|
||||
def _set_buttons_rendering(self, mode: str):
|
||||
"""Disable buttons while rendering."""
|
||||
self._preview_btn.setEnabled(False)
|
||||
self._render_btn.setEnabled(False)
|
||||
self._cancel_btn.setEnabled(True)
|
||||
self._export_btn.setEnabled(False)
|
||||
self._progress.setValue(0)
|
||||
self._active_mode = mode
|
||||
if mode == "preview":
|
||||
self._image_label.setText("Previewing...")
|
||||
else:
|
||||
self._image_label.setText("Rendering...")
|
||||
self._image_label.setPixmap(QPixmap())
|
||||
|
||||
def _set_buttons_idle(self):
|
||||
"""Re-enable buttons after rendering."""
|
||||
self._preview_btn.setEnabled(True)
|
||||
self._render_btn.setEnabled(True)
|
||||
self._cancel_btn.setEnabled(False)
|
||||
self._active_mode = None
|
||||
|
||||
# ── Slots ───────────────────────────────────────────────────────
|
||||
|
||||
@Slot()
|
||||
def _on_preview(self):
|
||||
"""Start a fast low-quality preview."""
|
||||
if self._backend is None or self._mesh_path is None:
|
||||
return
|
||||
|
||||
# Cancel any running operation first
|
||||
self._cancel_active_thread()
|
||||
|
||||
material = get_preset(self._material_combo.currentText())
|
||||
res_text = self._res_combo.currentText()
|
||||
w, h = [int(x) for x in res_text.split("×")]
|
||||
settings = RenderSettings(
|
||||
width=w,
|
||||
height=h,
|
||||
spp=self._spp_spin.value(),
|
||||
lighting=self._build_lighting_config(),
|
||||
ground_plane=self._build_ground_plane_config(),
|
||||
)
|
||||
camera = self._build_camera_from_ui()
|
||||
|
||||
self._set_buttons_rendering("preview")
|
||||
|
||||
self._preview_thread = _RenderThread(
|
||||
self._backend,
|
||||
self._mesh_path,
|
||||
material,
|
||||
camera,
|
||||
settings,
|
||||
parent=self,
|
||||
is_preview=True,
|
||||
)
|
||||
self._preview_thread.finished.connect(self._on_preview_done)
|
||||
self._preview_thread.error.connect(self._on_render_error)
|
||||
self._preview_thread.start()
|
||||
|
||||
@Slot()
|
||||
def _on_render(self):
|
||||
"""Start a full-quality render."""
|
||||
if self._backend is None:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"No Backend",
|
||||
"No render backend available.\nInstall with: pip install mitsuba",
|
||||
)
|
||||
return
|
||||
if self._mesh_path is None:
|
||||
QMessageBox.warning(self, "No Shape", "No shape to render.")
|
||||
return
|
||||
|
||||
# Cancel any running preview first
|
||||
self._cancel_active_thread()
|
||||
|
||||
material = get_preset(self._material_combo.currentText())
|
||||
spp = self._spp_spin.value()
|
||||
|
||||
res_text = self._res_combo.currentText()
|
||||
w, h = [int(x) for x in res_text.split("×")]
|
||||
settings = RenderSettings(
|
||||
width=w,
|
||||
height=h,
|
||||
spp=spp,
|
||||
lighting=self._build_lighting_config(),
|
||||
ground_plane=self._build_ground_plane_config(),
|
||||
)
|
||||
camera = self._build_camera_from_ui()
|
||||
|
||||
self._set_buttons_rendering("render")
|
||||
|
||||
self._render_thread = _RenderThread(
|
||||
self._backend,
|
||||
self._mesh_path,
|
||||
material,
|
||||
camera,
|
||||
settings,
|
||||
parent=self,
|
||||
is_preview=False,
|
||||
)
|
||||
self._render_thread.finished.connect(self._on_render_done)
|
||||
self._render_thread.error.connect(self._on_render_error)
|
||||
self._render_thread.progress.connect(self._on_render_progress)
|
||||
self._render_thread.start()
|
||||
|
||||
@Slot()
|
||||
def _on_cancel(self):
|
||||
"""Cancel the running render/preview."""
|
||||
self._cancel_active_thread()
|
||||
self._set_buttons_idle()
|
||||
self._image_label.setText("Cancelled")
|
||||
self._status_badge.setText("")
|
||||
self._progress.setValue(0)
|
||||
|
||||
@Slot(np.ndarray)
|
||||
def _on_preview_done(self, image: np.ndarray):
|
||||
"""Display the preview result."""
|
||||
self._last_preview = image
|
||||
self._set_buttons_idle()
|
||||
self._export_btn.setEnabled(False) # export uses full render only
|
||||
self._progress.setValue(100)
|
||||
self._display_image(image, "preview")
|
||||
|
||||
@Slot(np.ndarray)
|
||||
def _on_render_done(self, image: np.ndarray):
|
||||
"""Display the full render result."""
|
||||
self._last_image = image
|
||||
self._set_buttons_idle()
|
||||
self._export_btn.setEnabled(True)
|
||||
self._progress.setValue(100)
|
||||
self._display_image(image, "render")
|
||||
|
||||
@Slot(str)
|
||||
def _on_render_error(self, msg: str):
|
||||
"""Handle render/preview error."""
|
||||
self._set_buttons_idle()
|
||||
self._export_btn.setEnabled(False)
|
||||
self._image_label.setText(f"Error: {msg}")
|
||||
self._status_badge.setText("")
|
||||
self._progress.setValue(0)
|
||||
QMessageBox.critical(self, "Render Error", msg)
|
||||
|
||||
@Slot(float)
|
||||
def _on_render_progress(self, fraction: float):
|
||||
self._progress.setValue(int(fraction * 100))
|
||||
|
||||
@Slot()
|
||||
def _on_export(self):
|
||||
"""Export the last full-rendered image to file."""
|
||||
if self._last_image is None:
|
||||
return
|
||||
|
||||
path, filt = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Export Render",
|
||||
"",
|
||||
"PNG Image (*.png);;EXR Image (*.exr);;JPEG Image (*.jpg)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
|
||||
try:
|
||||
self._backend.export_image(self._last_image, path)
|
||||
QMessageBox.information(self, "Exported", f"Saved to:\n{path}")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Export Error", str(e))
|
||||
|
||||
# ── Event handlers ────────────────────────────────────────────────
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Re-scale the image when the window resizes."""
|
||||
super().resizeEvent(event)
|
||||
current_image = self._last_image or self._last_preview
|
||||
if current_image is not None:
|
||||
mode = "render" if self._last_image is not None else "preview"
|
||||
self._display_image(current_image, mode)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Clean up render threads on close."""
|
||||
# Cancel auto-preview timer
|
||||
if self._auto_preview_timer and self._auto_preview_timer.isActive():
|
||||
self._auto_preview_timer.stop()
|
||||
|
||||
# Kill both possible threads
|
||||
for thread in (self._preview_thread, self._render_thread):
|
||||
if thread and thread.isRunning():
|
||||
thread.cancel()
|
||||
thread.terminate()
|
||||
thread.wait(2000)
|
||||
|
||||
# Clean up temp mesh file
|
||||
if self._mesh_path and os.path.exists(self._mesh_path):
|
||||
try:
|
||||
os.unlink(self._mesh_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
super().closeEvent(event)
|
||||
Reference in New Issue
Block a user