- added renderer

- Added undo
This commit is contained in:
bklronin
2026-07-12 22:21:43 +02:00
parent 210e3cfb5d
commit 9f1387fe68
8 changed files with 3419 additions and 0 deletions
+253
View File
@@ -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.01.0
roughness: float = 0.5 # 0.01.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
+156
View File
@@ -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())
+345
View File
@@ -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
+277
View File
@@ -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])))
+141
View File
@@ -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.01.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,
)
+372
View File
@@ -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"
)
+906
View File
@@ -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
+969
View File
@@ -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)