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