From 742d06d242ecc9e45c875aff2b1af4474215c343 Mon Sep 17 00:00:00 2001 From: bklronin Date: Mon, 13 Jul 2026 23:01:35 +0200 Subject: [PATCH] - Render improvements, camera plane, update --- .idea/workspace.xml | 17 +- src/fluency/rendering/mitsuba_backend.py | 99 ++- src/fluency/rendering/occ_renderer.py | 6 +- src/fluency/rendering/render_backend.py | 1 + src/fluency/ui/main_window.py | 590 ++++++++++------- src/fluency/ui/render_window.py | 789 +++++++++++++++++++++-- src/fluency/ui/viewer_widget.py | 70 +- 7 files changed, 1226 insertions(+), 346 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 523523d..e5d31d7 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -6,7 +6,12 @@ - + + + + + + diff --git a/src/fluency/rendering/mitsuba_backend.py b/src/fluency/rendering/mitsuba_backend.py index bd22afa..4bff801 100644 --- a/src/fluency/rendering/mitsuba_backend.py +++ b/src/fluency/rendering/mitsuba_backend.py @@ -7,7 +7,6 @@ from __future__ import annotations import logging import os -import tempfile from typing import Callable, Optional import numpy as np @@ -31,6 +30,7 @@ class MitsubaBackend(RenderBackend): sys.stderr = io.StringIO() try: import mitsuba # noqa: F401 + return True except ImportError: return False @@ -116,9 +116,7 @@ class MitsubaBackend(RenderBackend): } # ── 3-point lighting (colors and intensities from config) ─── - key_rgb = [ - c * lighting.key_intensity for c in lighting.key_color - ] + 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], @@ -130,9 +128,7 @@ class MitsubaBackend(RenderBackend): "irradiance": {"type": "rgb", "value": key_rgb}, } - fill_rgb = [ - c * lighting.fill_intensity for c in lighting.fill_color - ] + 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], @@ -144,9 +140,7 @@ class MitsubaBackend(RenderBackend): "irradiance": {"type": "rgb", "value": fill_rgb}, } - rim_rgb = [ - c * lighting.rim_intensity for c in lighting.rim_color - ] + 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], @@ -158,24 +152,63 @@ class MitsubaBackend(RenderBackend): "irradiance": {"type": "rgb", "value": rim_rgb}, } - # ── Ground plane (optional) ───────────────────────────────── + # ── Ground plane / backdrop (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), - }, - }, + try: + # Load mesh to compute bounds for ground/backdrop placement + mesh_shape = mi.load_dict({"type": shape_type, "filename": mesh_path}) + bbox = mesh_shape.bbox() + bbox_min, bbox_max = bbox[0], bbox[1] + # Ground at model's lowest Z with 0.1% offset + model_height = bbox_max[2] - bbox_min[2] + ground_z = bbox_min[2] - 0.001 * model_height + dx = bbox_max[0] - bbox_min[0] + dy = bbox_max[1] - bbox_min[1] + dz = bbox_max[2] - bbox_min[2] + diag = float((dx * dx + dy * dy + dz * dz) ** 0.5) + except Exception: + # Fallback: place at origin with large default size + ground_z = -ground.distance_below + diag = 1000.0 + + bsdf_ground = { + "type": "diffuse", + "reflectance": {"type": "rgb", "value": list(ground.color)}, } + if ground.curved_backdrop: + # Photo booth style curved leinwand: + # - Flat floor section in front of the model + # - Curved cylinder behind that sweeps up and over + half_size = diag * 50.0 # huge floor + radius = diag * 3.0 # curvature radius + cyl_height = diag * 20.0 # width of the cylinder (along its axis) + scene["ground_floor"] = { + "type": "rectangle", + "to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z]) + @ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]), + "bsdf": bsdf_ground, + } + # Cylinder: axis along Y, positioned behind model, radius sweeps up + scene["ground_backdrop"] = { + "type": "cylinder", + "radius": radius, + "p0": [-cyl_height / 2, -radius, ground_z], + "p1": [cyl_height / 2, -radius, ground_z], + "to_world": mi.ScalarTransform4f.rotate([1, 0, 0], 90) + @ mi.ScalarTransform4f.translate([0.0, 0.0, -radius]), + "bsdf": bsdf_ground, + } + else: + # Simple flat ground plane — very large so edges aren't visible + half_size = diag * 50.0 + scene["ground_plane"] = { + "type": "rectangle", + "to_world": mi.ScalarTransform4f.translate([0.0, 0.0, ground_z]) + @ mi.ScalarTransform4f.scale([half_size, half_size, 1.0]), + "bsdf": bsdf_ground, + } + return scene def _make_bsdf(self, material: RenderMaterial) -> dict: @@ -247,12 +280,14 @@ class MitsubaBackend(RenderBackend): 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" - ) + 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) + try: + image = mi.render(scene, spp=settings.spp, seed=int(settings.seed or 0)) # type: ignore[arg-type] # Mitsuba accepts int at runtime + except Exception as e: + logger.error(f"Mitsuba render failed: {e}") + raise if progress_callback: progress_callback(1.0) @@ -281,6 +316,8 @@ class MitsubaBackend(RenderBackend): spp=max(settings.spp // 4, 16), max_depth=min(settings.max_depth, 4), seed=settings.seed, + lighting=settings.lighting, + ground_plane=settings.ground_plane, ) return self.render(mesh_path, material, camera, preview_settings) @@ -295,8 +332,8 @@ class MitsubaBackend(RenderBackend): if ext == ".exr": # Save as EXR (HDR) — no tonemapping try: - import OpenEXR - import Imath + import OpenEXR # type: ignore[import-not-found] + import Imath # type: ignore[import-not-found] h, w = image.shape[:2] header = OpenEXR.Header(w, h) diff --git a/src/fluency/rendering/occ_renderer.py b/src/fluency/rendering/occ_renderer.py index 67f2e56..049566d 100644 --- a/src/fluency/rendering/occ_renderer.py +++ b/src/fluency/rendering/occ_renderer.py @@ -677,8 +677,10 @@ class OCCRenderer(Renderer): 2.0 * math.tan(math.radians(fov_y / 2.0)) ) - # Scale factor maps directly: smaller scale (zoomed in) → closer camera. - adjusted_distance = base_distance * view_scale + # Scale factor maps inversely: larger scale (zoomed in) → closer camera. + # Dividing by view_scale ensures that when the user zooms in (scale increases) + # the render camera moves closer, matching the viewport. + adjusted_distance = base_distance / view_scale # Direction from target toward the original eye position. direction = eye - at diff --git a/src/fluency/rendering/render_backend.py b/src/fluency/rendering/render_backend.py index c9e5197..00daeb8 100644 --- a/src/fluency/rendering/render_backend.py +++ b/src/fluency/rendering/render_backend.py @@ -59,6 +59,7 @@ class GroundPlaneConfig: color: tuple[float, float, float] = (0.5, 0.5, 0.5) # RGB diffuse color roughness: float = 0.8 # surface roughness [0..1] distance_below: float = 0.0 # mm below origin (positive = below) + curved_backdrop: bool = False # photo booth style curved leinwand @dataclass diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py index dc325b4..9bda0b1 100644 --- a/src/fluency/ui/main_window.py +++ b/src/fluency/ui/main_window.py @@ -5,69 +5,42 @@ from __future__ import annotations import math import logging import os -import sys -import tempfile -import uuid from datetime import datetime from typing import Any, Dict, List, Optional, Tuple -from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect, QSettings +from PySide6.QtCore import Qt, Slot, QSize, QSettings from PySide6.QtGui import ( QAction, QColor, QFont, - QIcon, QKeySequence, - QPainter, - QPainterPath, - QPen, ) + MAX_RECENT_PROJECTS = 10 from PySide6.QtWidgets import ( - QApplication, QButtonGroup, QCheckBox, - QComboBox, QDialog, - QDialogButtonBox, - QDockWidget, QDoubleSpinBox, QFileDialog, QFrame, - QGridLayout, - QGroupBox, QHBoxLayout, QInputDialog, QLabel, - QLineEdit, - QListWidget, QListWidgetItem, QMainWindow, QMenu, - QMenuBar, QMessageBox, QPushButton, - QRadioButton, - QSizePolicy, - QSplitter, - QSpinBox, - QStatusBar, - QTabWidget, - QTextEdit, - QToolBar, - QTreeWidget, - QTreeWidgetItem, QVBoxLayout, QWidget, ) -from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject +from fluency.geometry_occ.kernel import OCGeometryKernel from fluency.geometry_occ.sketch import OCCSketch -from fluency.geometry.base import Point2D, Point3D from fluency.io.project_io import load_project, project_zip_path, save_project from fluency.models.data_model import Project, Component, Sketch, Body, Workplane -from fluency.rendering.occ_renderer import OCCRenderer from fluency.ui.dialogs import ( ExtrudeDialog, @@ -82,9 +55,12 @@ from gui_ui import Ui_fluencyCAD # auto-generated Qt form (project root on sys. logger = logging.getLogger(__name__) + def _project_body_to_workplane( body_shape: Any, - workplane: Tuple[Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float]], + workplane: Tuple[ + Tuple[float, float, float], Tuple[float, float, float], Tuple[float, float, float] + ], ) -> List[List[Tuple[float, float]]]: """Project ALL edges of a 3D body onto a workplane, returning UV polylines. @@ -101,7 +77,7 @@ def _project_body_to_workplane( from OCP.TopExp import TopExp_Explorer from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_WIRE from OCP.TopoDS import TopoDS - from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface + from OCP.BRepAdaptor import BRepAdaptor_Curve from OCP.GeomAbs import GeomAbs_Line from OCP.gp import gp_Pnt @@ -150,7 +126,9 @@ def _project_body_to_workplane( return polylines -def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[Tuple[float, float]]: +def _offset_polygon( + points: List[Tuple[float, float]], distance: float +) -> List[Tuple[float, float]]: """Offset a closed polygon by *distance* (positive = outward). Uses the edge-normal method: each edge is offset along its outward @@ -161,7 +139,6 @@ def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[ Returns the offset polygon as a list of (x, y) tuples (same length as *points*, closed). """ - import math n = len(points) if n < 3: return list(points) @@ -199,7 +176,7 @@ def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[ for i in range(n): prev_i = (i - 1 + n) % n n_prev = normals[prev_i] # outward normal of edge (prev, i) - n_curr = normals[i] # outward normal of edge (i, next) + n_curr = normals[i] # outward normal of edge (i, next) # Intersect the two offset edge lines to find the new vertex. # Line 1: through points[prev_i] + d*n_prev, direction = edges[prev_i] @@ -215,8 +192,9 @@ def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[ det = d1x * d2y - d1y * d2x if abs(det) < 1e-9: # Parallel edges — fall back to normal offset. - result.append((points[i][0] + distance * n_curr[0], - points[i][1] + distance * n_curr[1])) + result.append( + (points[i][0] + distance * n_curr[0], points[i][1] + distance * n_curr[1]) + ) else: diff_x = p2x - p1x diff_y = p2y - p1y @@ -226,12 +204,6 @@ def _offset_polygon(points: List[Tuple[float, float]], distance: float) -> List[ return result - - - - - - class MainWindow(QMainWindow): """Main application window.""" @@ -256,9 +228,6 @@ class MainWindow(QMainWindow): self._assembly_view_active: bool = False self._selected_assembly_component_id: Optional[str] = None - # Render window reference (prevents GC) - self._render_window = None - # Connector two-click state self._connector_first_pick: Optional[Dict[str, Any]] = None self._connector_second_ac_id: Optional[str] = None @@ -346,7 +315,9 @@ class MainWindow(QMainWindow): file_menu.addSeparator() self._action_load_last = QAction("Load Last Project on Startup", self) self._action_load_last.setCheckable(True) - self._action_load_last.setChecked(self._settings.value("load_last_on_startup", False, type=bool)) + self._action_load_last.setChecked( + self._settings.value("load_last_on_startup", False, type=bool) + ) self._action_load_last.toggled.connect(self._toggle_load_last_project) file_menu.addAction(self._action_load_last) @@ -405,11 +376,17 @@ class MainWindow(QMainWindow): self._viewer_3d = Viewer3DWidget() self._ui.gl_box.layout().addWidget(self._viewer_3d) - # Code editor — use the UI’s textEdit with our custom font. + # Code editor — use the UI's textEdit with our custom font. self._code_edit = self._ui.textEdit self._code_edit.setFont(QFont("Monaco", 10)) self._code_edit.setPlaceholderText("# Enter Python code here...") + # ── Render tab (adds "Render" tab to InputTab) ───────────── + from fluency.ui.render_window import RenderTabContent + + self._render_tab = RenderTabContent() + self._ui.InputTab.addTab(self._render_tab, "Render") + # Component buttons (dynamically generated per component, not in UI). self._component_box = QWidget() self._component_box_layout = QHBoxLayout(self._component_box) @@ -443,7 +420,9 @@ class MainWindow(QMainWindow): self._btn_asm_move.setCheckable(True) self._btn_asm_move.setMinimumSize(QSize(50, 50)) self._btn_asm_move.setMaximumSize(QSize(50, 50)) - self._btn_asm_move.setToolTip("Toggle: click a body in 3D and drag to move the assembly component") + self._btn_asm_move.setToolTip( + "Toggle: click a body in 3D and drag to move the assembly component" + ) asm_tools_layout = self._ui.assembly_tools.layout() if asm_tools_layout is not None: asm_tools_layout.addWidget(self._btn_asm_move, 0, 2, 1, 1) @@ -624,9 +603,7 @@ class MainWindow(QMainWindow): self._btn_del_sketch.clicked.connect(self._delete_sketch) self._btn_wp_face.toggled.connect(self._on_face_sketch_toggled) self._viewer_3d.facePicked.connect(self._on_face_picked) - self._viewer_3d.pickFaceCancelled.connect( - lambda: self._btn_wp_face.setChecked(False) - ) + self._viewer_3d.pickFaceCancelled.connect(lambda: self._btn_wp_face.setChecked(False)) self._btn_new_compo.clicked.connect(self._new_component) self._btn_del_compo.clicked.connect(self._delete_component) @@ -642,15 +619,9 @@ class MainWindow(QMainWindow): self._viewer_3d.connectorPickCancelled.connect( lambda: self._btn_add_connector.setChecked(False) ) - self._viewer_3d.assemblyComponentActivated.connect( - self._on_assembly_move_activated - ) - self._viewer_3d.assemblyComponentDragged.connect( - self._on_assembly_move_dragged - ) - self._viewer_3d.assemblyMoveFinished.connect( - self._on_assembly_move_finished - ) + self._viewer_3d.assemblyComponentActivated.connect(self._on_assembly_move_activated) + self._viewer_3d.assemblyComponentDragged.connect(self._on_assembly_move_dragged) + self._viewer_3d.assemblyMoveFinished.connect(self._on_assembly_move_finished) self._btn_update_body.clicked.connect(self._update_and_redraw) self._btn_del_body.clicked.connect(self._delete_body) @@ -690,6 +661,12 @@ class MainWindow(QMainWindow): # Snap toggle self._btn_snap.clicked.connect(lambda c: self._sketch_widget.set_snap_mode("point", c)) + # Sync 3D viewport camera → render tab when rendering is active. + self._viewer_3d.cameraChanged.connect(self._on_camera_changed) + + # Auto-load selected body when switching to the Render tab. + self._ui.InputTab.currentChanged.connect(self._on_tab_changed) + def _create_initial_component(self): self._new_component() @@ -817,7 +794,7 @@ class MainWindow(QMainWindow): self._component_buttons[0].setChecked(True) self._refresh_lists() - logger.info(f"Deleted component") + logger.info("Deleted component") def _on_component_button_clicked(self): idx = self._get_active_component_index() @@ -849,9 +826,7 @@ class MainWindow(QMainWindow): item.setData(Qt.UserRole, body_id) # Qt.Checked = visible, Qt.Unchecked = hidden. Default # is whatever the body model says. - item.setCheckState( - Qt.Checked if body.visible else Qt.Unchecked - ) + item.setCheckState(Qt.Checked if body.visible else Qt.Unchecked) # Greying out a hidden body's name is a nice UX touch. if not body.visible: item.setForeground(QColor("#6c7086")) @@ -923,7 +898,10 @@ class MainWindow(QMainWindow): # Resolve the target body for cut / union. target = None if body.extrude_cut or body.extrude_union: - if body.extrude_target_body_id and body.extrude_target_body_id in self._current_component.bodies: + if ( + body.extrude_target_body_id + and body.extrude_target_body_id in self._current_component.bodies + ): target = self._current_component.bodies[body.extrude_target_body_id] else: # Fallback: first body that isn't this one. @@ -937,15 +915,20 @@ class MainWindow(QMainWindow): if body.extrude_through_all and target is not None: length = self._through_all_length(target, sketch) result = self._compute_extrude_result( - sketch, face_geom, - length, symmetric=True, invert=False, - cut=body.extrude_cut, union=body.extrude_union, + sketch, + face_geom, + length, + symmetric=True, + invert=False, + cut=body.extrude_cut, + union=body.extrude_union, through_all=True, ) else: length = body.extrude_length result = self._compute_extrude_result( - sketch, face_geom, + sketch, + face_geom, length, symmetric=body.extrude_symmetric, invert=body.extrude_invert, @@ -1013,15 +996,15 @@ class MainWindow(QMainWindow): world_pos = ac.position + ac.rotation @ local_pos # Project to screen and probe nearby geometry. - screen = self._viewer_3d._renderer._project_to_screen( - tuple(world_pos) - ) + screen = self._viewer_3d._renderer._project_to_screen(tuple(world_pos)) if screen is None: continue try: candidates = self._viewer_3d._renderer.probe_snap_candidates( - screen[0], screen[1], radius=20, + screen[0], + screen[1], + radius=20, ) except Exception: continue @@ -1074,10 +1057,7 @@ class MainWindow(QMainWindow): ) if updated or invalidated: - logger.info( - f"Connector recalc: {updated} updated, " - f"{invalidated} invalidated" - ) + logger.info(f"Connector recalc: {updated} updated, {invalidated} invalidated") def _redraw_bodies(self): self._viewer_3d.clear_scene() @@ -1180,7 +1160,7 @@ class MainWindow(QMainWindow): # Find the button index for this assembly component. idx = -1 for i, btn in enumerate(self._assembly_component_buttons): - if getattr(btn, '_assembly_component_id', None) == active_id: + if getattr(btn, "_assembly_component_id", None) == active_id: idx = i break @@ -1194,7 +1174,9 @@ class MainWindow(QMainWindow): # Select the first remaining button if any. if self._assembly_component_buttons: self._assembly_component_buttons[0].setChecked(True) - first_id = getattr(self._assembly_component_buttons[0], '_assembly_component_id', None) + first_id = getattr( + self._assembly_component_buttons[0], "_assembly_component_id", None + ) self._selected_assembly_component_id = first_id self._assembly_view_active = True else: @@ -1212,7 +1194,7 @@ class MainWindow(QMainWindow): """Get the assembly component id of the currently checked button.""" for btn in self._assembly_component_buttons: if btn.isChecked(): - return getattr(btn, '_assembly_component_id', None) + return getattr(btn, "_assembly_component_id", None) return None def _on_assembly_component_clicked(self): @@ -1255,17 +1237,29 @@ class MainWindow(QMainWindow): # [R31 R32 R33 Tz] trsf = gp_Trsf() trsf.SetValues( - float(rot[0, 0]), float(rot[0, 1]), float(rot[0, 2]), float(pos[0]), - float(rot[1, 0]), float(rot[1, 1]), float(rot[1, 2]), float(pos[1]), - float(rot[2, 0]), float(rot[2, 1]), float(rot[2, 2]), float(pos[2]), + float(rot[0, 0]), + float(rot[0, 1]), + float(rot[0, 2]), + float(pos[0]), + float(rot[1, 0]), + float(rot[1, 1]), + float(rot[1, 2]), + float(pos[1]), + float(rot[2, 0]), + float(rot[2, 1]), + float(rot[2, 2]), + float(pos[2]), ) transformer = BRepBuilderAPI_Transform(shape, trsf, False) transformer.Build() return transformer.Shape() - def _make_connector_marker(self, position: Tuple[float, float, float], - color: Tuple[float, float, float] = (1.0, 0.3, 0.0)) -> Optional[Any]: + def _make_connector_marker( + self, + position: Tuple[float, float, float], + color: Tuple[float, float, float] = (1.0, 0.3, 0.0), + ) -> Optional[Any]: """Create a sphere marker for a connector at *position*. Returns the TopoDS_Shape of a sphere, or None on failure. @@ -1273,6 +1267,7 @@ class MainWindow(QMainWindow): try: from OCP.gp import gp_Pnt from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere + sphere = BRepPrimAPI_MakeSphere(gp_Pnt(*position), 4.0).Shape() return sphere except Exception as exc: @@ -1302,16 +1297,18 @@ class MainWindow(QMainWindow): self._asm_render_objects.clear() highlight_color = (0.2, 0.6, 1.0) # Bright blue for selected - dim_color = (0.5, 0.5, 0.5) # Grey for non-selected + dim_color = (0.5, 0.5, 0.5) # Grey for non-selected shown_any = False for ac_id, ac in assembly.components.items(): comp = self._project.get_component_by_id(ac.component_id) if comp is None: - logger.debug(f"Assembly component {ac_id} references missing component {ac.component_id}") + logger.debug( + f"Assembly component {ac_id} references missing component {ac.component_id}" + ) continue - is_selected = (ac_id == self._selected_assembly_component_id) + is_selected = ac_id == self._selected_assembly_component_id color = highlight_color if is_selected else dim_color render_ids: List[str] = [] @@ -1320,9 +1317,7 @@ class MainWindow(QMainWindow): try: shape = self._kernel._get_shape(body.geometry) # Apply component instance transform. - transformed = self._apply_transform( - shape, ac.position, ac.rotation - ) + transformed = self._apply_transform(shape, ac.position, ac.rotation) obj_id = f"asm_{ac_id}_{body_id}" render_obj = self._viewer_3d.show_shape( transformed, @@ -1390,7 +1385,7 @@ class MainWindow(QMainWindow): except Exception: pass - is_selected = (ac_id == self._selected_assembly_component_id) + is_selected = ac_id == self._selected_assembly_component_id color = (0.2, 0.6, 1.0) if is_selected else (0.5, 0.5, 0.5) new_ids: List[str] = [] @@ -1398,9 +1393,7 @@ class MainWindow(QMainWindow): if body.geometry: try: shape = self._kernel._get_shape(body.geometry) - transformed = self._apply_transform( - shape, ac.position, ac.rotation - ) + transformed = self._apply_transform(shape, ac.position, ac.rotation) obj_id = f"asm_{ac_id}_{body_id}" self._viewer_3d.show_shape( transformed, @@ -1413,6 +1406,7 @@ class MainWindow(QMainWindow): # Re-add connector markers at updated world positions. import numpy as np + for conn_id, conn in ac.connectors.items(): try: local_pos = np.array(conn.position, dtype=float) @@ -1443,8 +1437,11 @@ class MainWindow(QMainWindow): """ if checked and not self._assembly_view_active: self._btn_asm_move.setChecked(False) - QMessageBox.warning(self, "Assembly View", - "Switch to assembly view first by clicking an assembly component button.") + QMessageBox.warning( + self, + "Assembly View", + "Switch to assembly view first by clicking an assembly component button.", + ) return self._viewer_3d.set_assembly_move_mode(checked) if checked: @@ -1507,6 +1504,7 @@ class MainWindow(QMainWindow): return import numpy as np + delta = np.array([dx, dy, dz], dtype=float) # Propagate the same delta to every rigid-group member. group_ids = self._asm_move_group_ids or [ac_id] @@ -1560,8 +1558,11 @@ class MainWindow(QMainWindow): """ if not self._assembly_view_active: self._btn_add_connector.setChecked(False) - QMessageBox.warning(self, "Assembly View", - "Switch to assembly view first by clicking an assembly component button.") + QMessageBox.warning( + self, + "Assembly View", + "Switch to assembly view first by clicking an assembly component button.", + ) return # Reset any in-progress two-click state. @@ -1620,15 +1621,17 @@ class MainWindow(QMainWindow): ac_id = self._parse_ac_id(owner_obj_id) if ac_id is None: - QMessageBox.warning(self, "Pick Error", - "Could not identify which assembly component was clicked.") + QMessageBox.warning( + self, "Pick Error", "Could not identify which assembly component was clicked." + ) return assembly = self._get_assembly() ac = assembly.components.get(ac_id) if ac is None: - QMessageBox.warning(self, "Pick Error", - "The clicked component was not found in the assembly.") + QMessageBox.warning( + self, "Pick Error", "The clicked component was not found in the assembly." + ) return # Convert world-space connector to component-local coordinates. @@ -1669,8 +1672,11 @@ class MainWindow(QMainWindow): # Don't allow picking the same component twice. if ac_id == first["ac_id"]: - QMessageBox.warning(self, "Same Component", - "Pick a different component for the second connection point.") + QMessageBox.warning( + self, + "Same Component", + "Pick a different component for the second connection point.", + ) return self._connector_second_ac_id = ac_id @@ -1717,8 +1723,7 @@ class MainWindow(QMainWindow): ) if solved is None: - QMessageBox.warning(self, "Solver Error", - "SolveSpace could not align the components.") + QMessageBox.warning(self, "Solver Error", "SolveSpace could not align the components.") self._connector_first_pick = None self._connector_second_ac_id = None self._show_assembly_in_viewer(fit=True) @@ -1752,8 +1757,12 @@ class MainWindow(QMainWindow): if rotation is None: # User cancelled — restore original position. if moved_comp_before_dialog is not None: - moved_comp_before_dialog.position = np.array(solved["original_position"], dtype=float) - moved_comp_before_dialog.rotation = np.array(solved["original_rotation"], dtype=float) + moved_comp_before_dialog.position = np.array( + solved["original_position"], dtype=float + ) + moved_comp_before_dialog.rotation = np.array( + solved["original_rotation"], dtype=float + ) self._connector_first_pick = None self._connector_second_ac_id = None self._show_assembly_in_viewer(fit=True) @@ -1761,6 +1770,7 @@ class MainWindow(QMainWindow): # Apply dialog adjustments (rotation + offset + flip). import numpy as np + # Build rotation matrix: rotate second connector normal around # the target normal axis by rotation degrees. angle_rad = np.radians(rotation) @@ -1815,8 +1825,10 @@ class MainWindow(QMainWindow): conn_m.partner_connector_id = conn_a.id assembly.add_connection(anchor_ac_id, moved_ac_id) - logger.info(f"Connected: anchor={anchor_ac_id} ↔ moved={moved_ac_id}, " - f"rotation={rotation}°, offset={offset}mm, flip={flip}") + logger.info( + f"Connected: anchor={anchor_ac_id} ↔ moved={moved_ac_id}, " + f"rotation={rotation}°, offset={offset}mm, flip={flip}" + ) self._connector_first_pick = None self._connector_second_ac_id = None self._mark_dirty() @@ -1838,12 +1850,15 @@ class MainWindow(QMainWindow): """ import numpy as _np import math as _math + a = _np.asarray(a, dtype=float) b = _np.asarray(b, dtype=float) - an = _np.linalg.norm(a); bn = _np.linalg.norm(b) + an = _np.linalg.norm(a) + bn = _np.linalg.norm(b) if an < 1e-12 or bn < 1e-12: return _np.eye(3) - a = a / an; b = b / bn + a = a / an + b = b / bn dot = float(_np.dot(a, b)) cross = _np.cross(a, b) cross_norm = _np.linalg.norm(cross) @@ -1885,12 +1900,18 @@ class MainWindow(QMainWindow): * ``original_position`` / ``original_rotation`` — for cancellation. """ import numpy as np + try: from python_solvespace import SolverSystem, ResultFlag, Entity except ImportError: logger.warning("python_solvespace not available, falling back to direct alignment") - return self._align_direct(first_ac, second_ac, first_pick, second_pick, - anchor_component_id=anchor_component_id) + return self._align_direct( + first_ac, + second_ac, + first_pick, + second_pick, + anchor_component_id=anchor_component_id, + ) # ── Determine anchor and mover ── # The anchor component stays locked. Prefer anchor_component_id @@ -1979,8 +2000,13 @@ class MainWindow(QMainWindow): result = sys.solve() if result != ResultFlag.OKAY: logger.warning(f"SolveSpace solve failed: {result}") - return self._align_direct(first_ac, second_ac, first_pick, second_pick, - anchor_component_id=anchor_component_id) + return self._align_direct( + first_ac, + second_ac, + first_pick, + second_pick, + anchor_component_id=anchor_component_id, + ) # Extract solved positions. p_solved = np.array(sys.params(pt_mover.params), dtype=float) @@ -2073,9 +2099,7 @@ class MainWindow(QMainWindow): Returns (rotation_degrees, offset_mm, flip) or (None, None, False) if cancelled. """ - from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, - QLabel, QDoubleSpinBox, QPushButton, - QFrame, QCheckBox) + from PySide6.QtWidgets import QHBoxLayout, QPushButton if second_ac is None: return (None, None, False) @@ -2097,7 +2121,9 @@ class MainWindow(QMainWindow): } t1 = entity_names.get(first_pick.get("entity_type", ""), "Entity") t2 = entity_names.get(second_pick.get("entity_type", ""), "Entity") - layout.addWidget(QLabel(f"{t1} on {first_ac.name} → {t2} on {second_ac.name}")) + layout.addWidget( + QLabel(f"{t1} on {first_ac.name} → {t2} on {second_ac.name}") + ) layout.addWidget(QLabel("Adjust the connection:")) # Rotation around normal axis. @@ -2138,6 +2164,7 @@ class MainWindow(QMainWindow): layout.addLayout(btn_layout) import numpy as np + target_normal = np.array(first_pick["normal_world"], dtype=float) target_normal = target_normal / max(np.linalg.norm(target_normal), 1e-12) @@ -2184,15 +2211,15 @@ class MainWindow(QMainWindow): """Delete the connector nearest to the selected assembly component.""" active_id = self._get_active_assembly_component_id() if active_id is None: - QMessageBox.warning(self, "No Selection", - "Select an assembly component first") + QMessageBox.warning(self, "No Selection", "Select an assembly component first") return assembly = self._get_assembly() ac = assembly.components.get(active_id) if ac is None or not ac.connectors: - QMessageBox.information(self, "No Connectors", - "This component has no connectors to remove.") + QMessageBox.information( + self, "No Connectors", "This component has no connectors to remove." + ) return # List connectors in a simple choice dialog. @@ -2200,6 +2227,7 @@ class MainWindow(QMainWindow): conn_labels = [f"{c.name} at {c.position}" for c in ac.connectors.values()] from PySide6.QtWidgets import QInputDialog + label, ok = QInputDialog.getItem( self, "Remove Connector", "Select connector:", conn_labels, 0, False ) @@ -2220,13 +2248,18 @@ class MainWindow(QMainWindow): pc.partner_connector_id = None pc.is_grounded = False # Remove the connection edge either side references this pair. - assembly.connections = [ - c for c in assembly.connections - if not ( - (c.first_ac_id == active_id and c.second_ac_id == partner_ac_id) - or (c.first_ac_id == partner_ac_id and c.second_ac_id == active_id) - ) - ] if partner_ac_id is not None else assembly.connections + assembly.connections = ( + [ + c + for c in assembly.connections + if not ( + (c.first_ac_id == active_id and c.second_ac_id == partner_ac_id) + or (c.first_ac_id == partner_ac_id and c.second_ac_id == active_id) + ) + ] + if partner_ac_id is not None + else assembly.connections + ) ac.remove_connector(conn_id) logger.info(f"Removed connector {conn_id}") self._show_assembly_in_viewer(fit=True) @@ -2273,8 +2306,7 @@ class MainWindow(QMainWindow): """ selected = self._connection_list.currentItem() if selected is None: - QMessageBox.information(self, "No Selection", - "Select a connection to delete") + QMessageBox.information(self, "No Selection", "Select a connection to delete") return conn_id = selected.data(Qt.UserRole) @@ -2362,13 +2394,13 @@ class MainWindow(QMainWindow): self._current_component = self._project.add_component() # Create the Workplane model. - from fluency.models.data_model import Workplane wp = self._current_component.add_workplane( Workplane( name=wp_name, origin=origin, normal=normal, - x_dir=x_dir, ) + x_dir=x_dir, + ) ) # The preview visual becomes the permanent workplane; just update @@ -2437,7 +2469,7 @@ class MainWindow(QMainWindow): return origin = wp[0] # (ox, oy, oz) normal = wp[1] # (nx, ny, nz) - x_dir = wp[2] # (xx, xy, xz) + x_dir = wp[2] # (xx, xy, xz) # Collect all body shapes from the current component. body_shapes = [] @@ -2559,7 +2591,7 @@ class MainWindow(QMainWindow): pick up user-drawn boundary points. """ matches: List[Optional[OCCSketchEntity]] = [] - for (tx, ty) in positions: + for tx, ty in positions: found: Optional[OCCSketchEntity] = None for eid, entity in sketch._entities.items(): if entity.entity_type != "point": @@ -2600,9 +2632,10 @@ class MainWindow(QMainWindow): selected_face = self._sketch_widget._selected_face if selected_face is None: QMessageBox.warning( - self, "No Face Selected", + self, + "No Face Selected", "Click inside a closed face (region) in the sketch to select it, " - "then press Offset." + "then press Offset.", ) return @@ -2619,14 +2652,18 @@ class MainWindow(QMainWindow): elif outer["type"] == "polygon": pts = list(outer["points"]) if len(pts) < 3: - QMessageBox.warning(self, "Invalid Polygon", "Face boundary has fewer than 3 points.") + QMessageBox.warning( + self, "Invalid Polygon", "Face boundary has fewer than 3 points." + ) return # Remove closing duplicate (last == first) if present. if len(pts) > 1 and pts[-1] == pts[0]: pts.pop() is_circle = False else: - QMessageBox.warning(self, "Unsupported Face", f"Face type '{outer.get('type')}' not supported.") + QMessageBox.warning( + self, "Unsupported Face", f"Face type '{outer.get('type')}' not supported." + ) return # ── Find the ORIGINAL point entities so we can constrain to them ── @@ -2639,7 +2676,9 @@ class MainWindow(QMainWindow): # ── Open dialog with live preview ── dialog = OffsetDialog(self) - def _compute_offset_preview(distance: float, inward: bool) -> Optional[List[Tuple[float, float]]]: + def _compute_offset_preview( + distance: float, inward: bool + ) -> Optional[List[Tuple[float, float]]]: """Return offset polygon points, or None for circles.""" d = -distance if inward else distance if is_circle: @@ -2675,13 +2714,21 @@ class MainWindow(QMainWindow): # ── Apply offset: create new entities in the sketch ── if is_circle: self._apply_circle_offset( - sketch, cx, cy, radius, d, selected_face, + sketch, + cx, + cy, + radius, + d, + selected_face, parent_center_entity=parent_center_entity, offset_distance=abs(d), ) else: self._apply_polygon_offset( - sketch, pts, d, selected_face, + sketch, + pts, + d, + selected_face, parent_entities=parent_entities, offset_distance=abs(d), ) @@ -2701,7 +2748,8 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "Error", f"Offset failed: {e}") def _apply_polygon_offset( - self, sketch: OCCSketch, + self, + sketch: OCCSketch, pts: List[Tuple[float, float]], distance: float, face: Dict[str, Any], @@ -2723,7 +2771,7 @@ class MainWindow(QMainWindow): # Create new point entities at the offset positions. new_points = [] - for (x, y) in offset_pts: + for x, y in offset_pts: pt = sketch.add_point(float(x), float(y)) new_points.append(pt) @@ -2746,7 +2794,8 @@ class MainWindow(QMainWindow): except Exception as exc: logger.debug( "distance constraint failed for parent id=%s: %s", - parent_ent.id, exc, + parent_ent.id, + exc, ) if constrained: logger.info("Added %d distance constraints to offset polygon", constrained) @@ -2768,7 +2817,7 @@ class MainWindow(QMainWindow): logger.debug("Hole offset collapsed — skipping") continue hole_points = [] - for (x, y) in offset_hole: + for x, y in offset_hole: pt = sketch.add_point(float(x), float(y)) hole_points.append(pt) for i in range(len(hole_points)): @@ -2777,13 +2826,17 @@ class MainWindow(QMainWindow): logger.info( "Created %d offset points and %d offset lines for polygon boundary + %d holes", - len(offset_pts), len(offset_pts), + len(offset_pts), + len(offset_pts), len([h for h in holes if h.get("type") == "polygon"]), ) def _apply_circle_offset( - self, sketch: OCCSketch, - cx: float, cy: float, radius: float, + self, + sketch: OCCSketch, + cx: float, + cy: float, + radius: float, distance: float, face: Dict[str, Any], parent_center_entity: Optional[OCCSketchEntity] = None, @@ -2833,7 +2886,9 @@ class MainWindow(QMainWindow): logger.info( "Created offset circle: center=(%.2f, %.2f), radius=%.2f", - new_cx, new_cy, new_radius, + new_cx, + new_cy, + new_radius, ) # ─── Sketch-on-surface (face pick) ──────────────────────────────────── @@ -2866,9 +2921,7 @@ class MainWindow(QMainWindow): # against tracked AIS objects). We extract that owner so the cut # can target the right body. source_body = None - logger.info( - f"Face picked: origin={origin}, normal={normal}, x_dir={x_dir}" - ) + logger.info(f"Face picked: origin={origin}, normal={normal}, x_dir={x_dir}") # Pull the owning obj_id the renderer stashed on this pick pass. owner_obj_id = getattr(self._viewer_3d, "_last_pick_owner_obj_id", None) if owner_obj_id and self._current_component is not None: @@ -2922,9 +2975,7 @@ class MainWindow(QMainWindow): break # Switch focus to the sketch panel so the user can draw immediately. self._set_panel_focus("sketch") - self.statusBar().showMessage( - f"Sketch placed on face — drawing in 2D on that plane", 6000 - ) + self.statusBar().showMessage("Sketch placed on face — drawing in 2D on that plane", 6000) # The face is now the source for the underlay construction lines: # enable the show/hide toggle, ClrFace, and ToSketch buttons. self._btn_underlay.setEnabled(True) @@ -2941,9 +2992,7 @@ class MainWindow(QMainWindow): paint + hover + hit-test while the toggle is off. """ self._sketch_widget.set_underlay_visible(checked) - self.statusBar().showMessage( - f"Underlay {'visible' if checked else 'hidden'}", 2000 - ) + self.statusBar().showMessage(f"Underlay {'visible' if checked else 'hidden'}", 2000) def _on_clear_source_face(self) -> None: """Forget the source face: remove underlay entities, keep the workplane. @@ -3012,6 +3061,7 @@ class MainWindow(QMainWindow): if sketch.occ_sketch is not None and hasattr(sketch.occ_sketch, "get_workplane"): wp = sketch.occ_sketch.get_workplane() import numpy as _np + sketch.workplane_origin = _np.asarray(wp[0], dtype=float) sketch.workplane_normal = _np.asarray(wp[1], dtype=float) sketch.workplane_x_dir = _np.asarray(wp[2], dtype=float) @@ -3042,12 +3092,13 @@ class MainWindow(QMainWindow): # surface), re-bind it so the underlay construction lines # come back. set_source_face rebuilds the external # entities and re-orients the 2D view. - if getattr(sketch, "_source_face", None) is not None and sketch.occ_sketch is not None: + if ( + getattr(sketch, "_source_face", None) is not None + and sketch.occ_sketch is not None + ): wp = sketch.occ_sketch.get_workplane() origin, normal, x_dir = wp[0], wp[1], wp[2] - self._sketch_widget.set_source_face( - sketch._source_face, origin, normal, x_dir - ) + self._sketch_widget.set_source_face(sketch._source_face, origin, normal, x_dir) self._btn_underlay.setEnabled(True) self._btn_underlay.setChecked(True) self._btn_clr_face.setEnabled(True) @@ -3078,7 +3129,11 @@ class MainWindow(QMainWindow): for sketch_id, sketch in self._current_component.sketches.items(): if sketch.name == name: self._current_sketch = sketch - if sketch.occ_sketch and hasattr(sketch.occ_sketch, 'get_entity_count') and sketch.occ_sketch.get_entity_count() > 0: + if ( + sketch.occ_sketch + and hasattr(sketch.occ_sketch, "get_entity_count") + and sketch.occ_sketch.get_entity_count() > 0 + ): self._sketch_widget.set_sketch(sketch.occ_sketch) break @@ -3147,11 +3202,10 @@ class MainWindow(QMainWindow): if not ok: logger.debug( "set_visibility failed for body %s (render_object=%r)", - body.name, body.render_object, + body.name, + body.render_object, ) - logger.info( - f"{'Visible' if new_visible else 'Hidden'}: {body.name}" - ) + logger.info(f"{'Visible' if new_visible else 'Hidden'}: {body.name}") # ─── Extrude / cut helpers (shared by live preview + apply) ──────── @@ -3195,6 +3249,7 @@ class MainWindow(QMainWindow): boolean reliably removes the through volume. """ import numpy as _np + try: p_min, p_max = self._kernel.get_bounding_box(target.geometry) except Exception: @@ -3260,9 +3315,7 @@ class MainWindow(QMainWindow): invert = True extrude_length = -length if invert else length try: - tool_geom = self._kernel.extrude( - face_geom, extrude_length, symmetric=symmetric - ) + tool_geom = self._kernel.extrude(face_geom, extrude_length, symmetric=symmetric) except Exception as exc: logger.debug("preview extrude failed: %s", exc) return None @@ -3272,13 +3325,9 @@ class MainWindow(QMainWindow): if target is not None: try: if cut: - result_geom = self._kernel.boolean_difference( - target.geometry, tool_geom - ) + result_geom = self._kernel.boolean_difference(target.geometry, tool_geom) else: # union - result_geom = self._kernel.boolean_union( - target.geometry, tool_geom - ) + result_geom = self._kernel.boolean_union(target.geometry, tool_geom) except Exception as exc: logger.debug("preview boolean failed: %s", exc) return None @@ -3337,8 +3386,13 @@ class MainWindow(QMainWindow): return length, symmetric, invert, cut, union, through_all, _rounded = values result = self._compute_extrude_result( - sketch, face_geom, - length, symmetric, invert, bool(cut), bool(union), + sketch, + face_geom, + length, + symmetric, + invert, + bool(cut), + bool(union), bool(through_all), ) if result is None or result["result_shape"] is None: @@ -3406,8 +3460,13 @@ class MainWindow(QMainWindow): try: result = self._compute_extrude_result( - sketch, face_geom, - length, symmetric, invert, bool(cut), bool(union), + sketch, + face_geom, + length, + symmetric, + invert, + bool(cut), + bool(union), bool(through_all), ) if result is None or result["result_geom"] is None: @@ -3437,13 +3496,9 @@ class MainWindow(QMainWindow): if target.render_object is not None: self._viewer_3d.remove_mesh(target.render_object) shape = self._kernel._get_shape(target.geometry) - target.render_object = self._viewer_3d.show_shape( - shape, target.color, target.name - ) + target.render_object = self._viewer_3d.show_shape(shape, target.color, target.name) op = "cut" if cut else "union" - logger.info( - f"{op.capitalize()} applied: {target.name} now holds the result" - ) + logger.info(f"{op.capitalize()} applied: {target.name} now holds the result") body_name = target.name else: # Plain extrude: create a new body for the extrusion. @@ -3466,9 +3521,7 @@ class MainWindow(QMainWindow): logger.info(f"Created body: {body.name}") logger.debug("Adding shape to OCC viewer") shape = self._kernel._get_shape(body.geometry) - body.render_object = self._viewer_3d.show_shape( - shape, body.color, body.name - ) + body.render_object = self._viewer_3d.show_shape(shape, body.color, body.name) logger.info(f"Render object: {body.render_object}") body_name = body.name @@ -3539,7 +3592,11 @@ class MainWindow(QMainWindow): def _boolean_cut(self): logger.info("=== BOOLEAN CUT ===") if not self._current_component or len(self._current_component.bodies) < 2: - QMessageBox.warning(self, "Need Bodies", "Need at least 2 bodies to perform cut.\nCreate multiple bodies first.") + QMessageBox.warning( + self, + "Need Bodies", + "Need at least 2 bodies to perform cut.\nCreate multiple bodies first.", + ) return # Use the first body in the list as base, last as tool @@ -3550,7 +3607,12 @@ class MainWindow(QMainWindow): # Let user pick which body to use as tool body_names = [self._current_component.bodies[bid].name for bid in body_ids] tool_name, ok = QInputDialog.getItem( - self, "Select Tool Body", "Body to subtract (tool):", body_names, len(body_names) - 1, False + self, + "Select Tool Body", + "Body to subtract (tool):", + body_names, + len(body_names) - 1, + False, ) if not ok: return @@ -3588,7 +3650,9 @@ class MainWindow(QMainWindow): logger.debug("Adding shape to OCC viewer") shape = self._kernel._get_shape(result_geom) - new_body.render_object = self._viewer_3d.show_shape(shape, new_body.color, new_body.name) + new_body.render_object = self._viewer_3d.show_shape( + shape, new_body.color, new_body.name + ) logger.info(f"Render object: {new_body.render_object}") self._refresh_lists() @@ -3626,7 +3690,9 @@ class MainWindow(QMainWindow): logger.debug("Adding shape to OCC viewer") shape = self._kernel._get_shape(result_geom) - new_body.render_object = self._viewer_3d.show_shape(shape, new_body.color, new_body.name) + new_body.render_object = self._viewer_3d.show_shape( + shape, new_body.color, new_body.name + ) logger.info(f"Render object: {new_body.render_object}") self._refresh_lists() @@ -3817,6 +3883,7 @@ class MainWindow(QMainWindow): eye = [1.0, 1.0, 1.0] at_ = [0.0, 0.0, 0.0] up = [0.0, 0.0, 1.0] + # eye / at / up may be tuples, lists, or numpy arrays depending on # the renderer — coerce to plain 3-lists. def _flat3(v): @@ -3828,6 +3895,7 @@ class MainWindow(QMainWindow): if len(seq) < 3: seq = seq + [0.0] * (3 - len(seq)) return [float(seq[0]), float(seq[1]), float(seq[2])] + return { "active_tab": self._input_tabs.currentIndex() if hasattr(self, "_input_tabs") else 0, "active_component_id": self._current_component.id if self._current_component else None, @@ -3887,9 +3955,7 @@ class MainWindow(QMainWindow): box.setWindowTitle("Unsaved Changes") box.setText("This project has unsaved changes.") box.setInformativeText("Save before continuing?") - box.setStandardButtons( - QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel - ) + box.setStandardButtons(QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel) box.setDefaultButton(QMessageBox.Save) choice = box.exec() if choice == QMessageBox.Cancel: @@ -3908,9 +3974,7 @@ class MainWindow(QMainWindow): """Prompt for a path and save. Returns True on success.""" # Default to the current file name so Save-As is one click away # from a normal Save. - default = self._project_path or os.path.join( - os.path.expanduser("~"), "untitled.fluency" - ) + default = self._project_path or os.path.join(os.path.expanduser("~"), "untitled.fluency") path, _ = QFileDialog.getSaveFileName( self, "Save Project", @@ -3941,9 +4005,7 @@ class MainWindow(QMainWindow): logger.info("Saved project: %s", path) return True except Exception as exc: - QMessageBox.critical( - self, "Save Failed", f"Could not save the project:\n{exc}" - ) + QMessageBox.critical(self, "Save Failed", f"Could not save the project:\n{exc}") return False def _open_project(self) -> bool: @@ -3965,9 +4027,7 @@ class MainWindow(QMainWindow): try: project, view_state = load_project(path) except Exception as exc: - QMessageBox.critical( - self, "Open Failed", f"Could not open the project:\n{exc}" - ) + QMessageBox.critical(self, "Open Failed", f"Could not open the project:\n{exc}") return False # Suppress dirty tracking while we replace the in-memory project @@ -4045,11 +4105,19 @@ class MainWindow(QMainWindow): self._assembly_component_group.addButton(btn) self._assembly_box_layout.addWidget(btn) # Restore the active assembly component selection. - if assembly.active_assembly_component and assembly.active_assembly_component in assembly.components: + if ( + assembly.active_assembly_component + and assembly.active_assembly_component in assembly.components + ): for b in self._assembly_component_buttons: - if getattr(b, '_assembly_component_id', None) == assembly.active_assembly_component: + if ( + getattr(b, "_assembly_component_id", None) + == assembly.active_assembly_component + ): b.setChecked(True) - self._selected_assembly_component_id = assembly.active_assembly_component + self._selected_assembly_component_id = ( + assembly.active_assembly_component + ) break # If the saved view says we're in assembly view, switch over. @@ -4172,9 +4240,7 @@ class MainWindow(QMainWindow): self._viewer_3d.set_camera_position((100, 100, 100), (0, 0, 0)) def _open_render_window(self): - """Open the render window for the selected body or assembly.""" - from fluency.ui.render_window import RenderWindow - + """Populate the render tab with the selected body or assembly and switch to it.""" shape = None # Try selected body first if self._selected_body and self._selected_body.geometry: @@ -4219,6 +4285,7 @@ class MainWindow(QMainWindow): else: # Fallback for pygfx or other renderers without the method. from fluency.rendering.render_backend import RenderCamera + eye, at_, up = self._viewer_3d.get_camera_position() fov = self._viewer_3d.get_camera_fov() viewport_camera = RenderCamera( @@ -4230,16 +4297,68 @@ class MainWindow(QMainWindow): except Exception: viewport_camera = None - win = RenderWindow(shape=shape, camera=viewport_camera, parent=self) - win.show() - self._render_window = win # prevent GC + # Populate the render tab and switch to it. + self._render_tab.set_shape(shape, camera=viewport_camera) + self._ui.InputTab.setCurrentWidget(self._render_tab) + + def _load_render_tab_shape(self) -> None: + """Auto-load the selected body or assembly component into the render tab.""" + shape = None + # Try selected body first. + if self._selected_body and self._selected_body.geometry: + try: + shape = self._kernel._get_shape(self._selected_body.geometry) + except Exception as e: + logger.warning(f"Failed to get shape for render: {e}") + # Try selected assembly component. + if shape is None and self._selected_assembly_component_id: + ac_id = self._selected_assembly_component_id + for assembly in self._project.assemblies.values(): + ac = assembly.components.get(ac_id) + if ac and ac.component_id: + src_comp = self._project.components.get(ac.component_id) + if src_comp: + for body in src_comp.bodies.values(): + if body.geometry: + try: + shape = self._kernel._get_shape(body.geometry) + break + except Exception: + pass + break + if shape is None: + return + # Capture the viewport camera so the render matches what the user sees. + try: + renderer = self._viewer_3d.get_renderer() + if hasattr(renderer, "get_render_camera"): + viewport_camera = renderer.get_render_camera() + else: + from fluency.rendering.render_backend import RenderCamera + + eye, at_, up = self._viewer_3d.get_camera_position() + fov = self._viewer_3d.get_camera_fov() + viewport_camera = RenderCamera( + origin=tuple(float(v) for v in eye), + target=tuple(float(v) for v in at_), + up=tuple(float(v) for v in up), + fov=fov, + ) + except Exception: + viewport_camera = None + self._render_tab.set_shape(shape, camera=viewport_camera) + + def _on_tab_changed(self, index: int) -> None: + """When the user switches to the Render tab, auto-load the selected body.""" + if self._ui.InputTab.widget(index) is self._render_tab: + self._load_render_tab_shape() # ─── Sketch Undo/Redo ───────────────────────────────────────────────── def _get_sketch_widget(self): """Return the active sketch widget, or None if not in sketch mode.""" # The sketch widget is in the sketch tab - if hasattr(self, '_sketch_widget'): + if hasattr(self, "_sketch_widget"): return self._sketch_widget return None @@ -4297,4 +4416,35 @@ class MainWindow(QMainWindow): "- Component timeline", ) + # ── Camera sync: 3D view → render tab ──────────────────────── + def _on_camera_changed(self, eye: tuple, at_: tuple, up: tuple) -> None: + """Push the viewport camera into the render tab when it's active. + + Fires whenever the 3D viewport camera changes (orbit, pan, zoom) + while the Render tab is selected. ``set_camera`` decides whether + to restart a running preview or just schedule a new one. + """ + # Is the Render tab currently visible? + if self._ui.InputTab.currentWidget() is not self._render_tab: + return + # Nothing to render yet. + if self._render_tab._mesh_path is None: + return + try: + renderer = self._viewer_3d.get_renderer() + if hasattr(renderer, "get_render_camera"): + viewport_camera = renderer.get_render_camera() + else: + from fluency.rendering.render_backend import RenderCamera + + fov = self._viewer_3d.get_camera_fov() + viewport_camera = RenderCamera( + origin=eye, + target=at_, + up=up, + fov=fov, + ) + except Exception: + return + self._render_tab.set_camera(viewport_camera) diff --git a/src/fluency/ui/render_window.py b/src/fluency/ui/render_window.py index b38f5e5..cee6c4f 100644 --- a/src/fluency/ui/render_window.py +++ b/src/fluency/ui/render_window.py @@ -12,14 +12,13 @@ from typing import Optional import numpy as np from PySide6.QtCore import Qt, Signal, Slot, QThread, QTimer -from PySide6.QtGui import QImage, QPixmap +from PySide6.QtGui import QColor, QImage, QPixmap from PySide6.QtWidgets import ( QColorDialog, QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, - QFrame, QGridLayout, QGroupBox, QHBoxLayout, @@ -30,7 +29,6 @@ from PySide6.QtWidgets import ( QPushButton, QSizePolicy, QSlider, - QSplitter, QSpinBox, QVBoxLayout, QWidget, @@ -134,8 +132,8 @@ class RenderWindow(QMainWindow): # Rendering threads & images self._render_thread: Optional[_RenderThread] = None self._preview_thread: Optional[_RenderThread] = None - self._last_image: Optional[np.ndarray] = None # full render result - self._last_preview: Optional[np.ndarray] = None # preview result + self._last_image: Optional[np.ndarray] = None # full render result + self._last_preview: Optional[np.ndarray] = None # preview result self._camera: Optional[RenderCamera] = camera self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5) @@ -179,21 +177,15 @@ class RenderWindow(QMainWindow): self._image_label = QLabel("Click Preview or Render to start") self._image_label.setAlignment(Qt.AlignCenter) - self._image_label.setSizePolicy( - QSizePolicy.Expanding, QSizePolicy.Expanding - ) + self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self._image_label.setMinimumSize(400, 300) - self._image_label.setStyleSheet( - "background: #1e1e2e; color: #6c7086; font-size: 14px;" - ) + self._image_label.setStyleSheet("background: #1e1e2e; color: #6c7086; font-size: 14px;") self._image_container.addWidget(self._image_label, stretch=1) # Status badge ("Preview" / "Full Render") — shown below image self._status_badge = QLabel("") self._status_badge.setAlignment(Qt.AlignCenter) - self._status_badge.setStyleSheet( - "color: #a6adc8; font-size: 11px; padding: 2px;" - ) + self._status_badge.setStyleSheet("color: #a6adc8; font-size: 11px; padding: 2px;") self._image_container.addWidget(self._status_badge) right.addLayout(self._image_container, stretch=1) @@ -214,9 +206,7 @@ class RenderWindow(QMainWindow): # Auto-preview checkbox self._auto_preview_cb = QCheckBox("Auto-preview") self._auto_preview_cb.setChecked(True) - self._auto_preview_cb.setToolTip( - "Automatically preview when settings change" - ) + self._auto_preview_cb.setToolTip("Automatically preview when settings change") controls.addWidget(self._auto_preview_cb) # Samples per pixel @@ -232,9 +222,7 @@ class RenderWindow(QMainWindow): # Resolution controls.addWidget(QLabel("Resolution:")) self._res_combo = QComboBox() - self._res_combo.addItems( - ["1280×720", "1920×1080", "2560×1440", "3840×2160"] - ) + self._res_combo.addItems(["1280×720", "1920×1080", "2560×1440", "3840×2160"]) self._res_combo.setCurrentText("1920×1080") controls.addWidget(self._res_combo) @@ -425,29 +413,24 @@ class RenderWindow(QMainWindow): 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 + 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 - ) + 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) @@ -459,9 +442,7 @@ class RenderWindow(QMainWindow): # 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() - ) + self._ground_enabled_cb.toggled.connect(lambda: self._toggle_ground_plane_widgets()) ground_layout.addWidget(self._ground_enabled_cb) # Color button @@ -493,7 +474,7 @@ class RenderWindow(QMainWindow): 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.setRange(-500, 500) self._ground_dist_spin.setValue(0.0) self._ground_dist_spin.setSuffix(" mm") self._ground_dist_spin.setFixedWidth(80) @@ -509,9 +490,7 @@ class RenderWindow(QMainWindow): ) ) # Wire distance spinbox to auto-preview - self._ground_dist_spin.valueChanged.connect( - lambda: self._schedule_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() @@ -545,9 +524,7 @@ class RenderWindow(QMainWindow): 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 - ) + 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] @@ -607,13 +584,9 @@ class RenderWindow(QMainWindow): # 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._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)" - ) + 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.""" @@ -631,9 +604,7 @@ class RenderWindow(QMainWindow): 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}" - ) + QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{e}") # ── Auto-preview setup ──────────────────────────────────────────── @@ -643,14 +614,18 @@ class RenderWindow(QMainWindow): 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() - ) + 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_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()) @@ -747,14 +722,10 @@ class RenderWindow(QMainWindow): if mode == "preview": self._status_badge.setText("⚡ Preview — low quality") - self._status_badge.setStyleSheet( - "color: #f9e2af; font-size: 11px; padding: 2px;" - ) + 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;" - ) + self._status_badge.setStyleSheet("color: #a6e3a1; font-size: 11px; padding: 2px;") def _cancel_active_thread(self): """Cancel whichever thread is currently running.""" @@ -967,3 +938,683 @@ class RenderWindow(QMainWindow): pass super().closeEvent(event) + + +# ── Render tab content (embeddable in QTabWidget) ────────────────────── + + +class RenderTabContent(QWidget): + """Render controls and image display as a QWidget, designed to sit in a tab. + + Mirrors RenderWindow\'s controls but as a plain widget so it can be + embedded in MainWindow\'s InputTab (alongside Sketch and Code). + + Call ``set_shape(shape, camera)`` to load a new shape and optionally reset + the camera to the current viewport. ``cleanup()`` stops threads and + deletes temp files when the tab is hidden. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._shape = None + self._backend = None + self._mesh_path: Optional[str] = None + + # Rendering threads & images + self._render_thread: Optional[_RenderThread] = None + self._preview_thread: Optional[_RenderThread] = None + self._last_image: Optional[np.ndarray] = None + self._last_preview: Optional[np.ndarray] = None + self._camera: Optional[RenderCamera] = None + self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5) + self._active_mode: Optional[str] = None + self._auto_preview_timer: Optional[QTimer] = None + + self._init_ui() + self._init_backend() + self._setup_auto_preview() + + # ── Public API ───────────────────────────────────────────────── + + def set_shape(self, shape, camera: Optional[RenderCamera] = None) -> None: + """Load a new OCC TopoDS_Shape for rendering. + + *camera* — if provided, overrides the stored camera. Pass the + viewport\'s render camera to match the 3D view framing. + """ + self._shape = shape + if camera is not None: + self._camera = camera + self._last_image = None + self._last_preview = None + self._image_label.setPixmap(QPixmap()) + self._image_label.setText("Click Preview or Render to start") + self._status_badge.setText("") + self._export_btn.setEnabled(False) + self._prepare_mesh() + self._populate_camera_controls() + # Trigger auto-preview when a new shape is loaded + self._schedule_auto_preview() + + def get_camera(self) -> Optional[RenderCamera]: + """Return the current camera (from UI controls or initial).""" + return self._build_camera_from_ui() + + def set_camera(self, camera: RenderCamera) -> None: + """Update the render camera from an external source (e.g. 3D viewport). + + Stores the camera and, if a preview is active, cancels it and + schedules a new one with the updated framing. For full renders the + camera is stored so the next manual render uses the new framing. + """ + if camera is None: + return + self._camera = camera + self._cam_fov_spin.blockSignals(True) + try: + self._cam_fov_spin.setValue(camera.fov) + finally: + self._cam_fov_spin.blockSignals(False) + # ── If a preview is active, cancel and restart with the new camera ── + if self._active_mode == "preview" and self._preview_thread is not None: + self._cancel_active_thread() + self._schedule_auto_preview() + return + # For full renders or idle: schedule a preview if auto-preview is on. + self._schedule_auto_preview() + + def cleanup(self) -> None: + """Stop threads and delete temp files. Call when the tab is hidden/closed.""" + if self._auto_preview_timer and self._auto_preview_timer.isActive(): + self._auto_preview_timer.stop() + for thread in (self._preview_thread, self._render_thread): + if thread and thread.isRunning(): + thread.cancel() + thread.terminate() + thread.wait(2000) + if self._mesh_path and os.path.exists(self._mesh_path): + try: + os.unlink(self._mesh_path) + except OSError: + pass + self._mesh_path = None + + # ── UI Setup ─────────────────────────────────────────────────── + + def _init_ui(self): + main_layout = QHBoxLayout(self) + main_layout.setContentsMargins(8, 8, 8, 8) + main_layout.setSpacing(8) + + # ── Left sidebar: Render Settings ─────────────────────────── + self._settings_panel = self._build_settings_panel() + main_layout.addWidget(self._settings_panel) + + # ── Right side: Image + Controls ──────────────────────────── + right = QVBoxLayout() + right.setSpacing(6) + + self._image_container = QVBoxLayout() + self._image_container.setContentsMargins(0, 0, 0, 0) + + self._image_label = QLabel("Click Preview or Render to start") + self._image_label.setAlignment(Qt.AlignCenter) + self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._image_label.setMinimumSize(400, 300) + self._image_label.setStyleSheet("background: #1e1e2e; color: #6c7086; font-size: 14px;") + self._image_container.addWidget(self._image_label, stretch=1) + + self._status_badge = QLabel("") + self._status_badge.setAlignment(Qt.AlignCenter) + self._status_badge.setStyleSheet("color: #a6adc8; font-size: 11px; padding: 2px;") + self._image_container.addWidget(self._status_badge) + + right.addLayout(self._image_container, stretch=1) + + # Controls row + controls = QHBoxLayout() + controls.setSpacing(8) + + controls.addWidget(QLabel("Material:")) + self._material_combo = QComboBox() + for name in preset_names(): + self._material_combo.addItem(name) + self._material_combo.setCurrentText("Brushed Steel") + self._material_combo.setMinimumWidth(140) + controls.addWidget(self._material_combo) + + self._auto_preview_cb = QCheckBox("Auto-preview") + self._auto_preview_cb.setChecked(True) + controls.addWidget(self._auto_preview_cb) + + controls.addWidget(QLabel("Quality:")) + self._spp_spin = QSpinBox() + self._spp_spin.setRange(16, 4096) + self._spp_spin.setValue(256) + self._spp_spin.setSingleStep(64) + self._spp_spin.setSuffix(" spp") + self._spp_spin.setMinimumWidth(100) + controls.addWidget(self._spp_spin) + + controls.addWidget(QLabel("Resolution:")) + self._res_combo = QComboBox() + self._res_combo.addItems(["1280×720", "1920×1080", "2560×1440", "3840×2160"]) + self._res_combo.setCurrentText("1920×1080") + controls.addWidget(self._res_combo) + + controls.addStretch() + + self._preview_btn = QPushButton("⚡ Preview") + self._preview_btn.setMinimumWidth(90) + self._preview_btn.clicked.connect(self._on_preview) + controls.addWidget(self._preview_btn) + + self._render_btn = QPushButton("▶ Render") + self._render_btn.setMinimumWidth(100) + self._render_btn.clicked.connect(self._on_render) + controls.addWidget(self._render_btn) + + self._cancel_btn = QPushButton("⏹ Cancel") + self._cancel_btn.setMinimumWidth(80) + self._cancel_btn.setEnabled(False) + self._cancel_btn.clicked.connect(self._on_cancel) + controls.addWidget(self._cancel_btn) + + self._export_btn = QPushButton("💾 Export") + self._export_btn.setMinimumWidth(80) + self._export_btn.setEnabled(False) + self._export_btn.clicked.connect(self._on_export) + controls.addWidget(self._export_btn) + + right.addLayout(controls) + + self._progress = QProgressBar() + self._progress.setRange(0, 100) + self._progress.setValue(0) + self._progress.setTextVisible(True) + self._progress.setFormat("%p%") + right.addWidget(self._progress) + + main_layout.addLayout(right, stretch=1) + + # ── Settings panel builder ───────────────────────────────────── + + def _build_settings_panel(self) -> QWidget: + """Build the left sidebar with camera, lighting, and ground plane controls.""" + panel = QWidget() + panel.setFixedWidth(240) + layout = QVBoxLayout(panel) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + + # ── Camera Parameters ─────────────────────────────────────── + camera_gb = QGroupBox("Camera") + camera_layout = QVBoxLayout(camera_gb) + camera_layout.setSpacing(4) + + # Origin, target, and up are controlled via the 3D viewport. + # Only FOV is editable here. + fov_row = QHBoxLayout() + fov_row.addWidget(QLabel("FOV:")) + self._cam_fov_spin = QDoubleSpinBox() + 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_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_row = QHBoxLayout() + ambient_row.addWidget(QLabel("Ambient:")) + self._light_ambient_slider = QSlider(Qt.Horizontal) + 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_row = QHBoxLayout() + key_row.addWidget(QLabel("Key:")) + self._light_key_slider = QSlider(Qt.Horizontal) + 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_row = QHBoxLayout() + fill_row.addWidget(QLabel("Fill:")) + self._light_fill_slider = QSlider(Qt.Horizontal) + 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_row = QHBoxLayout() + rim_row.addWidget(QLabel("Rim:")) + self._light_rim_slider = QSlider(Qt.Horizontal) + 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) + + 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) + + 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) + + 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) + + self._ground_curved_cb = QCheckBox("Curved Backdrop") + self._ground_curved_cb.setChecked(False) + self._ground_curved_cb.toggled.connect(lambda: self._schedule_auto_preview()) + ground_layout.addWidget(self._ground_curved_cb) + + 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) + + rough_row = QHBoxLayout() + rough_row.addWidget(QLabel("Roughness:")) + self._ground_rough_slider = QSlider(Qt.Horizontal) + 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) + + dist_row = QHBoxLayout() + dist_row.addWidget(QLabel("Offset:")) + self._ground_dist_spin = QDoubleSpinBox() + self._ground_dist_spin.setRange(-500, 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) + + self._ground_rough_slider.valueChanged.connect( + lambda v: ( + self._ground_rough_label.setText(f"{v / 100:.2f}"), + self._schedule_auto_preview(), + ) + ) + self._ground_dist_spin.valueChanged.connect(lambda: self._schedule_auto_preview()) + + self._toggle_ground_plane_widgets() + + layout.addWidget(ground_gb) + layout.addStretch() + + return panel + + def _wire_light_slider(self, slider, label, divisor): + slider.valueChanged.connect( + lambda v: ( + label.setText(f"{v / divisor:.1f}"), + self._schedule_auto_preview(), + ) + ) + + def _toggle_ground_plane_widgets(self): + enabled = self._ground_enabled_cb.isChecked() + for w in ( + self._ground_color_btn, + self._ground_rough_slider, + self._ground_dist_spin, + self._ground_curved_cb, + ): + w.setEnabled(enabled) + if enabled: + self._schedule_auto_preview() + + def _on_ground_color_pick(self): + current = self._ground_color + 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) + 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): + 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): + if self._camera is None: + return + self._cam_fov_spin.setValue(self._camera.fov) + self._schedule_auto_preview() + + def _init_backend(self): + 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 + 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): + if self._shape is None: + return + try: + self._mesh_path = occ_shape_to_ply( + self._shape, linear_deflection=0.1, angular_deflection=0.15 + ) + 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}") + + def _setup_auto_preview(self): + self._auto_preview_timer = QTimer(self) + self._auto_preview_timer.setSingleShot(True) + self._auto_preview_timer.timeout.connect(self._on_preview) + self._material_combo.currentTextChanged.connect(lambda: self._schedule_auto_preview()) + self._cam_fov_spin.valueChanged.connect(lambda: self._schedule_auto_preview()) + + def _populate_camera_controls(self): + if self._camera is None: + return + self._cam_fov_spin.setValue(self._camera.fov) + + def _schedule_auto_preview(self): + if not self._auto_preview_cb.isChecked(): + return + 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) + + def _build_camera_from_ui(self) -> RenderCamera: + # Origin, target, and up are controlled via the 3D viewport; + # only FOV is editable in the sidebar. + _fallback = RenderCamera() + return RenderCamera( + origin=self._camera.origin if self._camera else _fallback.origin, + target=self._camera.target if self._camera else _fallback.target, + up=self._camera.up if self._camera else _fallback.up, + fov=self._cam_fov_spin.value(), + ) + + def _build_lighting_config(self) -> LightingConfig: + 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: + 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(), + curved_backdrop=self._ground_curved_cb.isChecked(), + ) + + def _display_image(self, image: np.ndarray, mode: str) -> None: + 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) + 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): + 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): + 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): + self._preview_btn.setEnabled(True) + self._render_btn.setEnabled(True) + self._cancel_btn.setEnabled(False) + self._active_mode = None + + @Slot() + def _on_preview(self): + if self._backend is None or self._mesh_path is None: + return + 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): + 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 + 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): + 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): + self._last_preview = image + self._set_buttons_idle() + self._export_btn.setEnabled(False) + self._progress.setValue(100) + self._display_image(image, "preview") + + @Slot(np.ndarray) + def _on_render_done(self, image: np.ndarray): + 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): + 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): + 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)) + + def resizeEvent(self, event): + 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) diff --git a/src/fluency/ui/viewer_widget.py b/src/fluency/ui/viewer_widget.py index 6fda07a..14ddf3d 100644 --- a/src/fluency/ui/viewer_widget.py +++ b/src/fluency/ui/viewer_widget.py @@ -3,15 +3,14 @@ from __future__ import annotations import logging -import sys from typing import Any, Dict, List, Optional, Tuple -from PySide6.QtCore import Qt, Signal, Slot, QPoint, QPointF, QSize, QRect -from PySide6.QtGui import QCursor, QFont, QPainter, QPen, QColor, QBrush, QPolygonF +from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import QWidget logger = logging.getLogger(__name__) + class Viewer3DWidget(QWidget): """3D viewer widget using OCC's native AIS display.""" @@ -38,6 +37,10 @@ class Viewer3DWidget(QWidget): # Emitted when a drag move finishes. assemblyMoveFinished = Signal(str) + # Emitted whenever the camera changes (orbit, pan, zoom). + # Payload: (eye, at, up) — each is a tuple of 3 floats. + cameraChanged = Signal(tuple, tuple, tuple) + def __init__(self, parent=None): super().__init__(parent) # For OCC's direct OpenGL rendering we need Qt to not paint over it. @@ -90,10 +93,12 @@ class Viewer3DWidget(QWidget): if self._renderer is not None: return import sys as _sys + _sys.stdout.flush() logger.info("Renderer: starting import...") from fluency.rendering.occ_renderer import OCCRenderer from fluency.rendering.pygfx_renderer import PygfxRenderer + logger.info("Renderer: imports done, creating OCCRenderer...") occ = OCCRenderer() logger.info("Renderer: calling occ.initialize...") @@ -142,16 +147,18 @@ class Viewer3DWidget(QWidget): """ self._ensure_initialized() from fluency.rendering.occ_renderer import OCCRenderer + if isinstance(self._renderer, OCCRenderer): oid = self._renderer.add_shape(shape, color, name) self._renderer.render() return oid # Fallback: tessellate and use the mesh pipeline. from fluency.geometry_occ.kernel import OCGeometryKernel + k = OCGeometryKernel() - from fluency.geometry_occ.sketch import OCCSketch # Build a temporary OCCGeometryObject to use the kernel's mesh helpers. from fluency.geometry_occ.kernel import OCCGeometryObject + obj = OCCGeometryObject(shape) verts, faces = k.get_mesh(obj) oid = self._renderer.add_mesh(verts, faces, color, name) @@ -159,7 +166,9 @@ class Viewer3DWidget(QWidget): try: e_verts, e_edges = k.get_edges(obj) if len(e_verts) > 0: - self._renderer.add_wireframe(e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges") + self._renderer.add_wireframe( + e_verts, e_edges, (0.9, 0.9, 0.9), line_width=1.5, name=f"{name}_edges" + ) except Exception: pass self._renderer.render() @@ -189,7 +198,9 @@ class Viewer3DWidget(QWidget): def add_wireframe(self, vertices, edges, color=None, line_width=1.0, name=None) -> str: self._ensure_initialized() - wid = self._renderer.add_wireframe(vertices, edges, color or (0.9, 0.9, 0.9), line_width, name) + wid = self._renderer.add_wireframe( + vertices, edges, color or (0.9, 0.9, 0.9), line_width, name + ) self._renderer.render() return wid @@ -326,7 +337,7 @@ class Viewer3DWidget(QWidget): return # If connector mode was just exited (gizmo persists after pick), # clear any lingering gizmo on first mouse move. - gizmo_objs = getattr(self._renderer, '_gizmo_objects', None) + gizmo_objs = getattr(self._renderer, "_gizmo_objects", None) if self._connector_snap_id is not None or (gizmo_objs and len(gizmo_objs) > 0): self._clear_connector_snap() # In face-pick mode, keep dynamic highlighting. @@ -359,11 +370,31 @@ class Viewer3DWidget(QWidget): return self._renderer.handle_mouse_release(event) super().mouseReleaseEvent(event) + # Camera may have changed during orbit/pan — emit signal. + try: + eye, at_, up = self.get_camera_position() + self.cameraChanged.emit( + tuple(float(v) for v in eye), + tuple(float(v) for v in at_), + tuple(float(v) for v in up), + ) + except Exception: + pass def wheelEvent(self, event): self._ensure_initialized() self._renderer.handle_wheel(event) super().wheelEvent(event) + # Zoom changed the camera — emit signal. + try: + eye, at_, up = self.get_camera_position() + self.cameraChanged.emit( + tuple(float(v) for v in eye), + tuple(float(v) for v in at_), + tuple(float(v) for v in up), + ) + except Exception: + pass def resizeEvent(self, event): super().resizeEvent(event) @@ -389,6 +420,7 @@ class Viewer3DWidget(QWidget): if hasattr(self._renderer, "get_camera_position"): return self._renderer.get_camera_position() import numpy as np + return ( np.zeros(3, dtype=float), np.zeros(3, dtype=float), @@ -498,6 +530,7 @@ class Viewer3DWidget(QWidget): on fast mouse moves. """ import time + now = time.monotonic() if now - self._connector_last_hover_time < self._connector_hover_interval: return # throttled — skip this mouse move @@ -569,10 +602,10 @@ class Viewer3DWidget(QWidget): fn = getattr(self._renderer, "highlight_snap", None) if fn is not None: colors = { - "planar_face": (0.0, 0.8, 1.0), # cyan + "planar_face": (0.0, 0.8, 1.0), # cyan "cylindrical_face": (1.0, 0.4, 0.0), # orange (hole) - "edge": (0.0, 1.0, 0.4), # green - "vertex": (1.0, 1.0, 0.0), # yellow + "edge": (0.0, 1.0, 0.4), # green + "vertex": (1.0, 1.0, 0.0), # yellow } c = colors.get(entity_type, (1.0, 0.6, 0.0)) self._connector_snap_id = fn(origin, color=c, size=3.0) @@ -737,7 +770,7 @@ class Viewer3DWidget(QWidget): # Get camera vectors for proper view-plane projection. import numpy as np - from OCP.V3d import V3d_TypeOfOrientation + try: # Get camera direction and up from the OCC view. camera = self._renderer._view.Camera() @@ -762,16 +795,11 @@ class Viewer3DWidget(QWidget): dy_world = 0.0 else: # Normal drag: move in view plane. - dx_world = float(cam_right[0] * dx * world_per_pixel + - cam_up[0] * dy * world_per_pixel) - dy_world = float(cam_right[1] * dx * world_per_pixel + - cam_up[1] * dy * world_per_pixel) - dz_world = float(cam_right[2] * dx * world_per_pixel + - cam_up[2] * dy * world_per_pixel) + dx_world = float(cam_right[0] * dx * world_per_pixel + cam_up[0] * dy * world_per_pixel) + dy_world = float(cam_right[1] * dx * world_per_pixel + cam_up[1] * dy * world_per_pixel) + dz_world = float(cam_right[2] * dx * world_per_pixel + cam_up[2] * dy * world_per_pixel) - self.assemblyComponentDragged.emit( - self._move_owner_obj_id, dx_world, dy_world, dz_world - ) + self.assemblyComponentDragged.emit(self._move_owner_obj_id, dx_world, dy_world, dz_world) def _handle_assembly_move_release(self, event) -> None: """Finish the drag, emit final position.""" @@ -888,5 +916,3 @@ class Viewer3DWidget(QWidget): self._renderer.render() return super().keyPressEvent(event) - -