diff --git a/src/osdag_gui/ui/components/custom_3dviewer.py b/src/osdag_gui/ui/components/custom_3dviewer.py index 54c5b226e..81832cbbc 100644 --- a/src/osdag_gui/ui/components/custom_3dviewer.py +++ b/src/osdag_gui/ui/components/custom_3dviewer.py @@ -1,9 +1,9 @@ """ -Custom 3D CAD Viewer with stable hover highlighting for models and ViewCube. +Custom 3D CAD Viewer with stable hover highlighting for models and Python-based ViewCube. """ -from PySide6.QtCore import QTimer, QTime, Qt +from PySide6.QtCore import QTimer, QTime, Qt, QPoint from PySide6.QtWidgets import QToolTip, QApplication - +from typing import Dict, List, Optional, Any, Tuple from osdag_gui.__config__ import CAD_BACKEND from OCC.Display import backend @@ -22,6 +22,8 @@ from OCC.Core.V3d import V3d_Zpos from OCC.Core.Aspect import Aspect_GT_Rectangular, Aspect_GDM_Lines +from osdag_gui.ui.components.view_cube_widget import ViewCubeWidget + class CustomViewer3d(qtViewer3d): def __init__(self, parent=None): @@ -30,51 +32,106 @@ def __init__(self, parent=None): self.context = None self.view = None - self.model_ais_objects = {} - self.model_hover_labels = {} + self.model_ais_objects: Dict[str, List[Any]] = {} + self.model_hover_labels: Dict[str, str] = {} - self.current_hovered_model = None - self.current_highlighted_ais_list = [] + self.current_hovered_model: Optional[str] = None + self.current_highlighted_ais_list: List[Any] = [] self.hover_timer = QTimer(self) self.hover_timer.setSingleShot(True) self.hover_timer.timeout.connect(self.show_tooltip) - self.hover_position = None + self.hover_position: Optional[QPoint] = None - # ViewCube interaction state + # OCC ViewCube state (disabled) self.view_cube = None self.view_cube_active = False self.is_interacting_with_cube = False self.mouse_press_pos = None self.mouse_press_time = 0 - # ---------------- Navigation state ---------------- - self.active_nav_mode = None # NavMode.ROTATE / PAN + self.python_view_cube_widget: Optional[ViewCubeWidget] = None + self._view_cube_enabled = True + + self.active_nav_mode: Optional[str] = None self.is_dragging_nav = False - self.last_mouse_pos = None + self.last_mouse_pos: Optional[QPoint] = None + + QTimer.singleShot(100, self._init_python_view_cube) + def _init_python_view_cube(self) -> None: + try: + if self._view_cube_enabled: + self.python_view_cube_widget = ViewCubeWidget( + parent=self, + cube_size=100, + position="top_right", + margin=70 + ) + self.python_view_cube_widget.view_changed.connect( + self._on_view_cube_changed + ) + self.python_view_cube_widget.show() + print("Python View Cube initialized successfully") + except Exception as e: + print(f"Failed to initialize Python View Cube: {e}") + + def _on_view_cube_changed(self, view_name: str) -> None: + if not self.view: + return + + try: + from osdag_gui.ui.components.view_cube import ChamferedViewCube + + if view_name in ChamferedViewCube.VIEWS: + view_info = ChamferedViewCube.VIEWS[view_name] + direction = view_info.direction + camera_distance = 500 + + self.view.SetProj( + direction.X() * camera_distance, + direction.Y() * camera_distance, + direction.Z() * camera_distance, + 0, 0, 0 + ) + + self.view.Redraw() + print(f"View changed to: {view_name}") + + except Exception as e: + print(f"Error changing view: {e}") # ------------------------------------------------------------------ - # Mouse Move Event (FIXED) + # Mouse Event Handling # ------------------------------------------------------------------ def mouseMoveEvent(self, event): + if self.python_view_cube_widget and self.python_view_cube_widget.isVisible(): + global_pos = event.globalPosition().toPoint() + local_pos = self.python_view_cube_widget.mapFromGlobal(global_pos) + + if self.python_view_cube_widget.rect().contains(local_pos): + self.python_view_cube_widget.mouseMoveEvent(event) + + if self.current_highlighted_ais_list: + self._clear_highlights() + if self.view_cube_active: + self._reset_view_cube_state() + + event.accept() + return # ---------------- NAVIGATION MOVE ---------------- - if self.is_dragging_nav and self.active_nav_mode: + if self.is_dragging_nav and self.active_nav_mode and self.view: pixel_ratio = self.devicePixelRatioF() - x = int(event.position().x() * pixel_ratio) y = int(event.position().y() * pixel_ratio) - - last_x = int(self.last_mouse_pos.x() * pixel_ratio) - last_y = int(self.last_mouse_pos.y() * pixel_ratio) - + last_x = int(self.last_mouse_pos.x() * pixel_ratio) if self.last_mouse_pos else x + last_y = int(self.last_mouse_pos.y() * pixel_ratio) if self.last_mouse_pos else y dx = x - last_x dy = y - last_y if self.active_nav_mode == NavMode.ROTATE: self.view.Rotation(x, y) - elif self.active_nav_mode == NavMode.PAN: self.view.Pan(dx, -dy) @@ -82,6 +139,7 @@ def mouseMoveEvent(self, event): event.accept() return + # ---------------- HOVER HIGHLIGHTING ---------------- if not self.context or not self.view: super().mouseMoveEvent(event) return @@ -102,29 +160,15 @@ def mouseMoveEvent(self, event): if self.context.HasDetected(): detected = self.context.DetectedInteractive() - # ------------------------------------------------------ - # VIEW CUBE HOVER (STABLE – NO FLICKER) - # ------------------------------------------------------ if self.view_cube and detected == self.view_cube: if not self.view_cube_active: self.context.SetAutomaticHilight(True) self.view_cube_active = True return - # ------------------------------------------------------ - # LEFT VIEW CUBE → CLEANUP - # ------------------------------------------------------ if self.view_cube_active: - self.context.SetAutomaticHilight(False) - self.view_cube_active = False - try: - self.context.Unhilight(self.view_cube, True) - except: - pass - - # ------------------------------------------------------ - # STANDARD MODEL HIGHLIGHTING - # ------------------------------------------------------ + self._reset_view_cube_state() + for model_name, ais_list in self.model_ais_objects.items(): for ais in ais_list: if detected == ais: @@ -142,42 +186,13 @@ def mouseMoveEvent(self, event): objects_to_highlight.append(detected) if set(objects_to_highlight) != set(self.current_highlighted_ais_list): - for obj in self.current_highlighted_ais_list: - try: - self.context.Unhilight(obj, False) - except: - pass - - self.current_highlighted_ais_list = objects_to_highlight - - for obj in self.current_highlighted_ais_list: - try: - self.context.HilightWithColor( - obj, self.context.HighlightStyle(), False - ) - except: - pass - - self.view.Redraw() + self._update_highlights(objects_to_highlight) else: - # Nothing detected → cleanup if self.view_cube_active: - self.context.SetAutomaticHilight(False) - self.view_cube_active = False - try: - self.context.Unhilight(self.view_cube, True) - except: - pass - + self._reset_view_cube_state() if self.current_highlighted_ais_list: - for obj in self.current_highlighted_ais_list: - try: - self.context.Unhilight(obj, False) - except: - pass - self.current_highlighted_ais_list = [] - self.view.Redraw() + self._clear_highlights() self.hover_position = event.globalPosition().toPoint() if hovered_model != self.current_hovered_model: @@ -192,124 +207,227 @@ def mouseMoveEvent(self, event): super().mouseMoveEvent(event) - # ------------------------------------------------------------------ - # Tooltip - # ------------------------------------------------------------------ - def show_tooltip(self): - if ( - self.current_hovered_model - and self.current_hovered_model in self.model_hover_labels - and self.hover_position - ): - QToolTip.showText( - self.hover_position, - self.model_hover_labels[self.current_hovered_model], - self, - ) + def mousePressEvent(self, event): + if self.python_view_cube_widget and self.python_view_cube_widget.isVisible(): + global_pos = event.globalPosition().toPoint() + local_pos = self.python_view_cube_widget.mapFromGlobal(global_pos) + + if self.python_view_cube_widget.rect().contains(local_pos): + self.python_view_cube_widget.mousePressEvent(event) + event.accept() + return + + if not self.context or not self.view: + super().mousePressEvent(event) + return + + pixel_ratio = self.devicePixelRatioF() + x = int(event.position().x() * pixel_ratio) + y = int(event.position().y() * pixel_ratio) + + self.context.MoveTo(x, y, self.view, True) + + if self.context.HasDetected(): + if self.view_cube and self.context.DetectedInteractive() == self.view_cube: + self.is_interacting_with_cube = True + self.mouse_press_pos = event.position() + self.mouse_press_time = QTime.currentTime().msecsSinceStartOfDay() + + if (event.button() == Qt.LeftButton and + self.active_nav_mode and + not self.is_interacting_with_cube and + self._can_start_navigation()): + + self.is_dragging_nav = True + self.last_mouse_pos = event.position() + pixel_ratio = self.devicePixelRatioF() + x = int(event.position().x() * pixel_ratio) + y = int(event.position().y() * pixel_ratio) + + if self.active_nav_mode == NavMode.ROTATE: + self.view.StartRotation(x, y) + + event.accept() + return + + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event): + if self.python_view_cube_widget and self.python_view_cube_widget.isVisible(): + global_pos = event.globalPosition().toPoint() + local_pos = self.python_view_cube_widget.mapFromGlobal(global_pos) + + if self.python_view_cube_widget.rect().contains(local_pos): + self.python_view_cube_widget.mouseReleaseEvent(event) + event.accept() + return + + if self.is_dragging_nav and event.button() == Qt.LeftButton: + self.is_dragging_nav = False + self.last_mouse_pos = None + event.accept() + return + + if self.is_interacting_with_cube: + current_time = QTime.currentTime().msecsSinceStartOfDay() + dt = current_time - self.mouse_press_time + dist = (event.position() - self.mouse_press_pos).manhattanLength() if self.mouse_press_pos else 0 + + if dt < 500 and dist < 10: + super().mouseReleaseEvent(event) + else: + self.context.MoveTo(-1, -1, self.view, True) + super().mouseReleaseEvent(event) + + self.is_interacting_with_cube = False + self.mouse_press_pos = None + return + + self.unsetCursor() + QApplication.restoreOverrideCursor() + self.releaseMouse() + super().mouseReleaseEvent(event) - # ------------------------------------------------------------------ - # Leave Event - # ------------------------------------------------------------------ def leaveEvent(self, event): self.hover_timer.stop() self.current_hovered_model = None if self.view_cube_active: - self.context.SetAutomaticHilight(False) - self.view_cube_active = False - try: - self.context.Unhilight(self.view_cube, True) - except: - pass - + self._reset_view_cube_state() if self.current_highlighted_ais_list: - for obj in self.current_highlighted_ais_list: - try: - self.context.Unhilight(obj, False) - except: - pass - self.current_highlighted_ais_list = [] - self.view.Redraw() + self._clear_highlights() QToolTip.hideText() - - # restore holding cursor so cursor can update self.unsetCursor() QApplication.restoreOverrideCursor() self.releaseMouse() super().leaveEvent(event) - def cleanup_for_new_model(self): - """ - Clean up all internal state before displaying a new model. - This prevents memory corruption from stale OCC object references. - - Uses IsDisplayed/IsHilighted checks for OS-independent safety: - - Windows requires explicit Remove before EraseAll for AIS_ViewCube - - Linux crashes with double-free if Remove is called on already-freed objects - - Checking first avoids both issues. - """ - # Reset view cube state - use IsDisplayed check for OS-independent safety + # ------------------------------------------------------------------ + # Helper Methods + # ------------------------------------------------------------------ + def _clear_highlights(self) -> None: + for obj in self.current_highlighted_ais_list: + try: + self.context.Unhilight(obj, False) + except Exception: + pass + self.current_highlighted_ais_list = [] + if self.view: + self.view.Redraw() + + def _update_highlights(self, objects_to_highlight: List[Any]) -> None: + for obj in self.current_highlighted_ais_list: + try: + self.context.Unhilight(obj, False) + except Exception: + pass + + self.current_highlighted_ais_list = objects_to_highlight + + for obj in self.current_highlighted_ais_list: + try: + self.context.HilightWithColor( + obj, self.context.HighlightStyle(), False + ) + except Exception: + pass + + if self.view: + self.view.Redraw() + + def _reset_view_cube_state(self) -> None: + self.context.SetAutomaticHilight(False) + self.view_cube_active = False + try: + self.context.Unhilight(self.view_cube, True) + except Exception: + pass + + def show_tooltip(self) -> None: + if (self.current_hovered_model and + self.current_hovered_model in self.model_hover_labels and + self.hover_position): + QToolTip.showText( + self.hover_position, + self.model_hover_labels[self.current_hovered_model], + self, + ) + + def _can_start_navigation(self) -> bool: + if not self.context or not self.context.HasDetected(): + return True + if self.context.DetectedInteractive() == self.view_cube: + return False + return True + + # ------------------------------------------------------------------ + # View Cube Control + # ------------------------------------------------------------------ + def display_view_cube(self) -> None: + if self.python_view_cube_widget: + self.python_view_cube_widget.show() + + def hide_view_cube(self) -> None: + if self.python_view_cube_widget: + self.python_view_cube_widget.hide() + + def set_view_cube_enabled(self, enabled: bool) -> None: + self._view_cube_enabled = enabled + if enabled: + self.display_view_cube() + else: + self.hide_view_cube() + + # ------------------------------------------------------------------ + # Model Management + # ------------------------------------------------------------------ + def cleanup_for_new_model(self) -> None: if hasattr(self, 'view_cube') and self.view_cube and self.context: try: - # Only remove if confirmed still displayed - prevents double-free if self.context.IsDisplayed(self.view_cube): self.context.Remove(self.view_cube, False) except Exception: - pass # Object may already be removed or context invalid + pass finally: self.view_cube = None elif hasattr(self, 'view_cube'): self.view_cube = None - # Reset View Cube interaction state self.view_cube_active = False self.is_interacting_with_cube = False - # Clear highlighted objects list - use IsHilighted check for OS-independent safety if self.current_highlighted_ais_list and self.context: for obj in self.current_highlighted_ais_list: try: - # Only unhilight if confirmed still highlighted if self.context.IsHilighted(obj): self.context.Unhilight(obj, False) except Exception: - pass # Object may already be unhighlighted or invalid + pass self.current_highlighted_ais_list = [] elif self.current_highlighted_ais_list: - # Context not available, just clear the list self.current_highlighted_ais_list = [] - self.current_highlighted_owner = None self.current_hovered_model = None - - # Clear the model AIS objects dictionary self.model_ais_objects.clear() - - # Clear hover labels self.model_hover_labels.clear() - - # NOTE: Do NOT call gc.collect() here! - # The gdb backtrace shows the crash happens during GC when trying to clean up - # Shiboken MetaObjectBuilder objects. Let Python handle GC naturally. # ------------------------------------------------------------------ - # View Cube Display + # Navigation Control # ------------------------------------------------------------------ + def set_navigation_mode(self, mode: Optional[str]) -> None: + self.active_nav_mode = mode - def display_view_cube(self): - return # TEMPORARILY DISABLED + # ------------------------------------------------------------------ + # OCC View Cube (Disabled - kept for reference) + # ------------------------------------------------------------------ + def _display_occ_view_cube(self) -> None: try: - # NOTE: Do NOT call gc.collect() here - it causes Shiboken wrapper corruption - - # Remove existing view cube if it exists using safe method if hasattr(self, 'view_cube') and self.view_cube: try: self.context.Remove(self.view_cube, False) - except Exception as remove_error: - # Object may have been displayed in a different context or already removed - # Just log and continue - we'll create a fresh one - print(f"Note: Could not remove old ViewCube (may already be removed): {remove_error}") + except Exception: + pass self.view_cube = None self.view_cube = AIS_ViewCube() @@ -317,20 +435,15 @@ def display_view_cube(self): self.view_cube.SetFontHeight(12) self.view_cube.SetAxesLabels("", "", "") self.view_cube.SetDrawAxes(False) - - # Make corner and edge pieces larger for better interaction self.view_cube.SetBoxFacetExtension(12) - # Configure Highlight Attributes highlight_drawer = Prs3d_Drawer() highlight_drawer.SetColor(Quantity_Color(Quantity_NOC_CYAN)) self.view_cube.SetHilightAttributes(highlight_drawer) - # Style drawer = self.view_cube.Attributes() drawer.SetDatumAspect(Prs3d_DatumAspect()) - # Colors color_white = Quantity_Color(Quantity_NOC_WHITE) color_gray = Quantity_Color(Quantity_NOC_GRAY50) color_black = Quantity_Color(Quantity_NOC_BLACK) @@ -339,25 +452,20 @@ def display_view_cube(self): self.view_cube.SetBoxColor(color_gray) self.view_cube.SetTextColor(color_black) - # Display self.context.Display(self.view_cube, False) try: from OCC.Core.Graphic3d import Graphic3d_TransformPers, Graphic3d_TMF_TriedronPers, Graphic3d_Vec2i from OCC.Core.Aspect import Aspect_TOTP_RIGHT_UPPER - # Create transform persistence anchored to top-right corner offset = Graphic3d_Vec2i(60, 70) transform_pers = Graphic3d_TransformPers(Graphic3d_TMF_TriedronPers, Aspect_TOTP_RIGHT_UPPER, offset) self.view_cube.SetTransformPersistence(transform_pers) except Exception as e: - # Fallback to old method if Graphic3d classes not available print(f"Using fallback positioning: {e}") try: - # Try 2D persistence as fallback from OCC.Core.Graphic3d import Graphic3d_TransformPers, Graphic3d_TMF_2d from OCC.Core.gp import gp_Pnt2d - # Try explicit coordinates if corner persistence fails offset = gp_Pnt2d(850, 40) transform_pers = Graphic3d_TransformPers(Graphic3d_TMF_2d, offset) self.view_cube.SetTransformPersistence(transform_pers) @@ -372,97 +480,7 @@ def display_view_cube(self): except Exception as e: print(f"Error displaying View Cube: {e}") - # ------------------------------------------------------------------ - # Mouse Press - # ------------------------------------------------------------------ - def mousePressEvent(self, event): - if not self.context or not self.view: - super().mousePressEvent(event) - return - - pixel_ratio = self.devicePixelRatioF() - x = int(event.position().x() * pixel_ratio) - y = int(event.position().y() * pixel_ratio) - - self.context.MoveTo(x, y, self.view, True) - - if self.context.HasDetected(): - if self.context.DetectedInteractive() == self.view_cube: - self.is_interacting_with_cube = True - self.mouse_press_pos = event.position() - self.mouse_press_time = QTime.currentTime().msecsSinceStartOfDay() - - # ---------------- NAVIGATION START ---------------- - if ( - event.button() == Qt.LeftButton - and self.active_nav_mode - and not self.is_interacting_with_cube - and self._can_start_navigation() - ): - self.is_dragging_nav = True - self.last_mouse_pos = event.position() - - pixel_ratio = self.devicePixelRatioF() - x = int(event.position().x() * pixel_ratio) - y = int(event.position().y() * pixel_ratio) - - if self.active_nav_mode == NavMode.ROTATE: - self.view.StartRotation(x, y) - - event.accept() - return - - - - super().mousePressEvent(event) - - # ------------------------------------------------------------------ - # Mouse Release - # ------------------------------------------------------------------ - def mouseReleaseEvent(self, event): - # ---------------- NAVIGATION END ---------------- - if self.is_dragging_nav and event.button() == Qt.LeftButton: - self.is_dragging_nav = False - self.last_mouse_pos = None - event.accept() - return - - if self.is_interacting_with_cube: - current_time = QTime.currentTime().msecsSinceStartOfDay() - dt = current_time - self.mouse_press_time - dist = (event.position() - self.mouse_press_pos).manhattanLength() - - if dt < 500 and dist < 10: - super().mouseReleaseEvent(event) - else: - self.context.MoveTo(-1, -1, self.view, True) - super().mouseReleaseEvent(event) - - self.is_interacting_with_cube = False - self.mouse_press_pos = None - return - - # restore holding cursor so cursor can update - self.unsetCursor() - QApplication.restoreOverrideCursor() - self.releaseMouse() - super().mouseReleaseEvent(event) - - def set_navigation_mode(self, mode): - """ - mode: NavMode.ROTATE | NavMode.PAN | None - """ - self.active_nav_mode = mode - - def _can_start_navigation(self): - if not self.context.HasDetected(): - return False - if self.context.DetectedInteractive() == self.view_cube: - return False - return True - - class NavMode: ROTATE = "ROTATE" - PAN = "PAN" + PAN = "PAN" \ No newline at end of file diff --git a/src/osdag_gui/ui/components/view_cube.py b/src/osdag_gui/ui/components/view_cube.py new file mode 100644 index 000000000..39529ab3a --- /dev/null +++ b/src/osdag_gui/ui/components/view_cube.py @@ -0,0 +1,299 @@ +""" +Chamfered View Cube - Pure Python implementation using OCC geometry. +Provides 26 interactive regions: 6 faces, 8 corners, 12 edges. +""" +from typing import Dict, Optional +from dataclasses import dataclass +import math + +from PySide6.QtCore import QObject, Signal + +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox +from OCC.Core.gp import ( + gp_Pnt, + gp_Dir, + gp_Ax3, + gp_Trsf, +) +from OCC.Core.TopoDS import TopoDS_Shape, TopoDS_Solid +from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE +from OCC.Core.AIS import AIS_Shape +from OCC.Core.Quantity import ( + Quantity_Color, + Quantity_NOC_WHITE, + Quantity_NOC_CYAN, + Quantity_NOC_MAGENTA, +) +from OCC.Core.Prs3d import Prs3d_Drawer + + +@dataclass +class ViewDirection: + """Represents a view direction with name and transformation.""" + name: str + direction: gp_Dir + rotation: Optional[gp_Trsf] = None + + +class ChamferedViewCube(QObject): + """ + Creates a chamfered (beveled) cube geometry for view navigation. + + The cube has: + - 6 main faces (Top, Bottom, Front, Back, Left, Right) + - 8 corner regions + - 12 edge regions + + Each region can be clicked to set the corresponding view. + """ + + # Standard view directions + VIEWS = { + # Face views + "front": ViewDirection("Front", gp_Dir(0, 0, 1)), + "back": ViewDirection("Back", gp_Dir(0, 0, -1)), + "top": ViewDirection("Top", gp_Dir(0, 1, 0)), + "bottom": ViewDirection("Bottom", gp_Dir(0, -1, 0)), + "right": ViewDirection("Right", gp_Dir(1, 0, 0)), + "left": ViewDirection("Left", gp_Dir(-1, 0, 0)), + + # Corner views (isometric-style) + "front_top_right": ViewDirection("Front Top Right", gp_Dir(1, 1, 1)), + "front_top_left": ViewDirection("Front Top Left", gp_Dir(-1, 1, 1)), + "front_bottom_right": ViewDirection("Front Bottom Right", gp_Dir(1, -1, 1)), + "front_bottom_left": ViewDirection("Front Bottom Left", gp_Dir(-1, -1, 1)), + "back_top_right": ViewDirection("Back Top Right", gp_Dir(1, 1, -1)), + "back_top_left": ViewDirection("Back Top Left", gp_Dir(-1, 1, -1)), + "back_bottom_right": ViewDirection("Back Bottom Right", gp_Dir(1, -1, -1)), + "back_bottom_left": ViewDirection("Back Bottom Left", gp_Dir(-1, -1, -1)), + + # Edge views + "front_top": ViewDirection("Front Top", gp_Dir(0, 1, 1)), + "front_bottom": ViewDirection("Front Bottom", gp_Dir(0, -1, 1)), + "front_right": ViewDirection("Front Right", gp_Dir(1, 0, 1)), + "front_left": ViewDirection("Front Left", gp_Dir(-1, 0, 1)), + "back_top": ViewDirection("Back Top", gp_Dir(0, 1, -1)), + "back_bottom": ViewDirection("Back Bottom", gp_Dir(0, -1, -1)), + "back_right": ViewDirection("Back Right", gp_Dir(1, 0, -1)), + "back_left": ViewDirection("Back Left", gp_Dir(-1, 0, -1)), + "top_right": ViewDirection("Top Right", gp_Dir(1, 1, 0)), + "top_left": ViewDirection("Top Left", gp_Dir(-1, 1, 0)), + "bottom_right": ViewDirection("Bottom Right", gp_Dir(1, -1, 0)), + "bottom_left": ViewDirection("Bottom Left", gp_Dir(-1, -1, 0)), + } + + view_selected = Signal(str) # Emits view name when clicked + + def __init__( + self, + size: float = 45.0, + chamfer_radius: float = 8.0, + parent: Optional[QObject] = None + ): + super().__init__(parent) + self.size = size + self.chamfer_radius = chamfer_radius + self._half_size = size / 2.0 + + # Cache for AIS shapes + self._main_cube_shape: Optional[TopoDS_Solid] = None + self._chamfered_cube_shape: Optional[TopoDS_Solid] = None + self._ais_shapes: Dict[str, AIS_Shape] = {} + + # Colors + self._default_color = Quantity_Color(Quantity_NOC_WHITE) + self._highlight_color = Quantity_Color(Quantity_NOC_CYAN) + self._hover_color = Quantity_Color(Quantity_NOC_MAGENTA) + + self._build_geometry() + + def _build_geometry(self) -> None: + """Build the chamfered cube geometry.""" + # Create basic box + box_maker = BRepPrimAPI_MakeBox( + gp_Pnt(-self._half_size, -self._half_size, -self._half_size), + self.size, self.size, self.size + ) + self._main_cube_shape = box_maker.Solid() + + # Apply chamfer (bevel) to all edges + self._chamfered_cube_shape = self._apply_chamfer( + self._main_cube_shape, + self.chamfer_radius + ) + + def _apply_chamfer( + self, + shape: TopoDS_Solid, + radius: float + ) -> TopoDS_Solid: + """ + Apply chamfer (bevel) to all edges of the solid. + This creates the characteristic beveled edges of the view cube. + """ + try: + # Get all edges from the solid + from OCC.Core.TopExp import TopExp_Explorer + from OCC.Core.TopAbs import TopAbs_EDGE + + edges = [] + exp = TopExp_Explorer(shape, TopAbs_EDGE) + while exp.More(): + edges.append(exp.Current()) + exp.Next() + + if not edges: + return shape + + # Create chamfer operation + fillet = BRepFilletAPI_MakeChamfer(shape) + + # Add all edges with the same chamfer distance + for edge in edges: + fillet.Add(radius, edge) + + return fillet.Solid() + + except Exception as e: + print(f"Chamfer failed, using basic cube: {e}") + return shape + + def get_shape(self) -> TopoDS_Solid: + """Get the chamfered cube shape.""" + if self._chamfered_cube_shape is None: + self._build_geometry() + return self._chamfered_cube_shape + + def get_ais_shape(self, name: str) -> AIS_Shape: + """ + Get or create an AIS_Shape for a specific view region. + + Args: + name: View name (e.g., 'front', 'front_top_right', 'front_top') + + Returns: + AIS_Shape that can be displayed in the viewer + """ + if name in self._ais_shapes: + return self._ais_shapes[name] + + # Get the view direction + if name not in self.VIEWS: + raise ValueError(f"Unknown view: {name}") + + view_info = self.VIEWS[name] + + # Create transformed shape for this view + shape = self._create_view_shape(name, view_info) + + # Create AIS object + ais_shape = AIS_Shape(shape) + + # Set appearance + drawer = Prs3d_Drawer() + drawer.SetColor(self._default_color) + ais_shape.SetAttributes(drawer) + + # Store in cache + self._ais_shapes[name] = ais_shape + + return ais_shape + + def _create_view_shape( + self, + name: str, + view_info: ViewDirection + ) -> TopoDS_Shape: + """ + Create a transformed shape for a specific view. + + The shape is oriented to face the camera in the corresponding view. + """ + # Get base shape + base_shape = self.get_shape() + + # Calculate rotation to align with view direction + # Default view is +Z (front) + default_dir = gp_Dir(0, 0, 1) + target_dir = view_info.direction + + # Create rotation transformation + trsf = gp_Trsf() + + # Calculate rotation axis and angle using quaternion-like approach + axis = default_dir.Crossed(target_dir) + + if axis.Magnitude() < 1e-10: + # Directions are parallel or opposite + if default_dir.IsOpposite(target_dir, 1e-10): + # 180 degree rotation around X axis + trsf.SetRotation(gp_Ax3(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), math.pi) + else: + # General case - rotate around cross product axis + axis.Normalize() + angle = default_dir.Angle(target_dir) + trsf.SetRotation(gp_Ax3(gp_Pnt(0, 0, 0), axis), angle) + + # Apply transformation + builder = BRepBuilderAPI_Transform(base_shape, trsf, True) + return builder.Shape() + + def get_all_ais_shapes(self) -> Dict[str, AIS_Shape]: + """Get all AIS shapes for the view cube.""" + for name in self.VIEWS: + if name not in self._ais_shapes: + self.get_ais_shape(name) + return self._ais_shapes + + def set_highlight(self, name: str, highlight: bool = True) -> None: + """Set highlight state for a specific view.""" + if name in self._ais_shapes: + ais_shape = self._ais_shapes[name] + if highlight: + drawer = Prs3d_Drawer() + drawer.SetColor(self._highlight_color) + ais_shape.SetHilightAttributes(drawer) + else: + ais_shape.ClearHilight() + + def get_view_from_click( + self, + x: int, + y: int, + viewer + ) -> Optional[str]: + """ + Determine which view was clicked based on screen coordinates. + + Args: + x, y: Screen coordinates + viewer: The OCC 3D viewer + + Returns: + View name or None if no valid view clicked + """ + if not viewer or not viewer.context: + return None + + try: + viewer.context.MoveTo(x, y, viewer.view, True) + + if viewer.context.HasDetected(): + detected = viewer.context.DetectedInteractive() + + # Check if any of our shapes was detected + for name, ais_shape in self._ais_shapes.items(): + if detected == ais_shape: + return name + + return None + + except Exception as e: + print(f"Error detecting view click: {e}") + return None + + def reset(self) -> None: + """Clear cached shapes.""" + self._ais_shapes.clear() diff --git a/src/osdag_gui/ui/components/view_cube_widget.py b/src/osdag_gui/ui/components/view_cube_widget.py new file mode 100644 index 000000000..a8c40aa02 --- /dev/null +++ b/src/osdag_gui/ui/components/view_cube_widget.py @@ -0,0 +1,445 @@ +""" +View Cube Overlay Widget - PySide6-based navigation cube overlay. +Renders the view cube as a 2D overlay that interacts with the 3D viewer. +""" +from typing import Dict, Optional, Tuple +from enum import Enum + +from PySide6.QtCore import QPoint, Qt, Signal +from PySide6.QtGui import ( + QPainter, + QColor, + QPen, + QBrush, + QPainterPath +) +from PySide6.QtWidgets import QWidget + +from osdag_gui.ui.components.view_cube import ChamferedViewCube + +class ViewCubeWidget(QWidget): + """ + A PySide6 widget that renders an interactive view cube overlay. + + Features: + - Chamfered (beveled) cube rendering + - Hover highlights + - Click to set view + - Drag to rotate cube + - Smooth animations + """ + + # Signal emitted when a view is selected + view_changed = Signal(str) + + class State(Enum): + IDLE = "idle" + HOVERING = "hovering" + DRAGGING = "dragging" + + def __init__( + self, + parent: Optional[QWidget] = None, + cube_size: int = 120, + position: str = "top_right", + margin: int = 60 + ): + super().__init__(parent) + + self.cube_size = cube_size + self.position = position + self.margin = margin + + # State + self._state = self.State.IDLE + self._hovered_region: Optional[str] = None + self._is_dragging = False + self._drag_start_pos = QPoint() + self._current_rotation = 0.0 # Yaw + self._current_pitch = 0.0 # Pitch + + # Cube data + self._view_cube = ChamferedViewCube(size=cube_size * 0.4) + self._region_map: Dict[str, QPainterPath] = {} + + # Colors + self._face_color = QColor(240, 240, 240) + self._edge_color = QColor(100, 100, 100) + self._highlight_color = QColor(0, 200, 255, 180) + self._text_color = QColor(50, 50, 50) + + # Labels for faces + self._face_labels = { + "front": "F", + "back": "K", + "top": "T", + "bottom": "Bo", + "right": "R", + "left": "L" + } + + self._corner_labels = { + "front_top_right": "FTR", + "front_top_left": "FTL", + "front_bottom_right": "FBR", + "front_bottom_left": "FBL", + "back_top_right": "BTR", + "back_top_left": "BTL", + "back_bottom_right": "BBR", + "back_bottom_left": "BBL" + } + + self._edge_labels = { + "front_top": "FT", + "front_bottom": "FB", + "front_right": "FR", + "front_left": "FL", + "back_top": "BT", + "back_bottom": "BB", + "back_right": "BR", + "back_left": "BL", + "top_right": "TR", + "top_left": "TL", + "bottom_right": "BR", + "bottom_left": "BL" + } + + self._setup_widget() + + def _setup_widget(self) -> None: + """Setup widget properties.""" + # Make widget transparent and always on top + self.setAttribute(Qt.WA_TransparentForMouseEvents, False) + self.setAttribute(Qt.WA_TranslucentBackground, True) + self.setWindowFlags( + Qt.Window | + Qt.FramelessWindowHint | + Qt.WindowStaysOnTopHint | + Qt.Tool + ) + + # Set initial size + self.setFixedSize(self.cube_size + 40, self.cube_size + 40) + + # Position the widget + self._position_widget() + + def _position_widget(self) -> None: + """Position the widget in the specified corner.""" + if not self.parentWidget(): + return + + parent_size = self.parentWidget().size() + + if self.position == "top_right": + x = parent_size.width() - self.width() - self.margin + y = self.margin + elif self.position == "top_left": + x = self.margin + y = self.margin + elif self.position == "bottom_right": + x = parent_size.width() - self.width() - self.margin + y = parent_size.height() - self.height() - self.margin + elif self.position == "bottom_left": + x = self.margin + y = parent_size.height() - self.height() - self.margin + else: # default top_right + x = parent_size.width() - self.width() - self.margin + y = self.margin + + self.move(x, y) + + def showEvent(self, event) -> None: + """Reposition on show.""" + super().showEvent(event) + self._position_widget() + + def resizeEvent(self, event) -> None: + """Handle parent resize.""" + super().resizeEvent(event) + self._position_widget() + + def paintEvent(self, event) -> None: + """Paint the view cube.""" + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + painter.setRenderHint(QPainter.TextAntialiasing) + + # Calculate cube center + center_x = self.width() // 2 + center_y = self.height() // 2 + half = self.cube_size // 2 + + # Apply rotation transformation + painter.save() + painter.translate(center_x, center_y) + painter.rotate(self._current_rotation) + painter.rotate(self._current_pitch) + painter.translate(-center_x, -center_y) + + # Draw the chamfered cube (simplified 2D projection) + self._draw_cube(painter, center_x, center_y, half) + + painter.restore() + + # Draw view labels + self._draw_labels(painter, center_x, center_y, half) + + def _draw_cube( + self, + painter: QPainter, + cx: int, + cy: int, + half: int + ) -> None: + """ + Draw a 2D projection of a chamfered cube. + Uses a pseudo-3D isometric-like projection. + """ + # Chamfer size + chamfer = half // 4 + + # Define cube vertices (front face) + # With chamfer, we have additional vertices + fc = chamfer # front-chamfer offset + + # Face vertices (simplified: draw as 2D projected cube) + # Front face (square) + front_points = [ + (cx - half + fc, cy - half + fc), # top-left + (cx + half - fc, cy - half + fc), # top-right + (cx + half - fc, cy + half - fc), # bottom-right + (cx - half + fc, cy + half - fc), # bottom-left + ] + + # Back face offset + offset = half // 3 + back_points = [ + (cx - half + fc + offset, cy - half + fc - offset), + (cx + half - fc + offset, cy - half + fc - offset), + (cx + half - fc + offset, cy + half - fc - offset), + (cx - half + fc + offset, cy + half - fc - offset), + ] + + # Colors + face_brush = QBrush(self._face_color) + edge_pen = QPen(self._edge_color, 2) + highlight_brush = QBrush(self._highlight_color) + + # Determine which faces to highlight based on rotation + highlight_front = abs(self._current_pitch) < 30 and abs(self._current_rotation % 360) < 30 + highlight_back = abs(self._current_pitch) < 30 and abs((self._current_rotation % 360) - 180) < 30 + highlight_top = abs(self._current_pitch) > 60 + highlight_right = 30 <= (self._current_rotation % 360) < 150 + highlight_left = 150 <= (self._current_rotation % 360) < 330 + + # Draw back face + if not highlight_back: + painter.setBrush(face_brush) + else: + painter.setBrush(highlight_brush) + painter.setPen(edge_pen) + self._draw_quad(painter, back_points) + + # Draw connecting edges (sides) + side_points = [ + [back_points[0], front_points[0]], + [back_points[1], front_points[1]], + [back_points[2], front_points[2]], + [back_points[3], front_points[3]], + ] + + for pts in side_points: + painter.drawLine(pts[0], pts[1]) + + # Draw front face + if not highlight_front: + painter.setBrush(face_brush) + else: + painter.setBrush(highlight_brush) + painter.setPen(edge_pen) + self._draw_quad(painter, front_points) + + # Draw chamfered edges (as lines at corners) + chamfer_pen = QPen(self._edge_color, 1) + painter.setPen(chamfer_pen) + + # Top chamfers + if highlight_top: + painter.setPen(QPen(self._highlight_color, 2)) + + # Draw horizontal top edge + painter.drawLine(front_points[0], front_points[1]) + + # Draw vertical edges with chamfer indication + for i in [0, 1]: + painter.drawLine(front_points[i], back_points[i]) + + # Reset pen + painter.setPen(edge_pen) + + def _draw_quad( + self, + painter: QPainter, + points: list + ) -> None: + """Draw a quadrilateral from 4 points.""" + path = QPainterPath() + path.moveTo(points[0][0], points[0][1]) + for i in range(1, 4): + path.lineTo(points[i][0], points[i][1]) + path.closeSubpath() + painter.drawPath(path) + + def _draw_labels( + self, + painter: QPainter, + cx: int, + cy: int, + half: int + ) -> None: + """Draw view labels on the cube faces.""" + font = painter.font() + font.setPointSize(8) + font.setBold(True) + painter.setFont(font) + painter.setPen(self._text_color) + + # Draw face labels based on rotation + label_offset = half + 15 + + # Front + if abs(self._current_rotation % 360) < 45: + painter.drawText(cx - 5, cy + 3, "F") + + # Back + if abs((self._current_rotation % 360) - 180) < 45: + painter.drawText(cx - 5, cy + 3, "K") + + # Top + if abs(self._current_pitch) > 60: + painter.drawText(cx - 5, cy + 3, "T") + + def mousePressEvent(self, event) -> None: + """Handle mouse press.""" + if event.button() == Qt.LeftButton: + self._is_dragging = True + self._drag_start_pos = event.pos() + self._state = self.State.DRAGGING + event.accept() + + def mouseMoveEvent(self, event) -> None: + """Handle mouse move.""" + if self._is_dragging: + # Update rotation based on drag + delta = event.pos() - self._drag_start_pos + + self._current_rotation += delta.x() * 0.5 + self._current_pitch = max(-90, min(90, + self._current_pitch + delta.y() * 0.5 + )) + + self._drag_start_pos = event.pos() + self.update() + event.accept() + else: + # Check for hover + self._check_hover(event.pos()) + + def mouseReleaseEvent(self, event) -> None: + """Handle mouse release.""" + if event.button() == Qt.LeftButton: + if self._is_dragging: + # Check if it was a click (minimal drag) + delta = (event.pos() - self._drag_start_pos).manhattanLength() + + if delta < 10: + # It was a click - emit view changed + view = self._get_view_from_position(event.pos()) + if view: + self.view_changed.emit(view) + + self._is_dragging = False + self._state = self.State.IDLE + event.accept() + + def wheelEvent(self, event) -> None: + """Handle mouse wheel for quick rotation.""" + delta = event.angleDelta().y() + self._current_rotation += delta * 0.1 + self.update() + event.accept() + + def _check_hover(self, pos: QPoint) -> None: + """Check if mouse is hovering over the cube.""" + cx = self.width() // 2 + cy = self.height() // 2 + half = self.cube_size // 2 + + # Simple bounding box check + if (abs(pos.x() - cx) < half and abs(pos.y() - cy) < half): + if self._state != self.State.HOVERING: + self._state = self.State.HOVERING + self.setCursor(Qt.PointingHandCursor) + self.update() + else: + if self._state == self.State.HOVERING: + self._state = self.State.IDLE + self.unsetCursor() + self.update() + + def _get_view_from_position(self, pos: QPoint) -> Optional[str]: + """Determine which view was clicked based on position.""" + cx = self.width() // 2 + cy = self.height() // 2 + + dx = pos.x() - cx + dy = pos.y() - cy + + # Determine quadrant/octant + half = self.cube_size // 3 + + # Simplified view determination based on position and rotation + rotation = self._current_rotation % 360 + pitch = self._current_pitch + + # Front view + if abs(dx) < half and dy < -half//2: + return "front" + # Back view + if abs(dx) < half and dy > half//2: + return "back" + # Top view + if dy < -half: + return "top" + # Bottom view + if dy > half: + return "bottom" + # Right view + if dx > half: + return "right" + # Left view + if dx < -half: + return "left" + + return "front" # Default + + def set_rotation(self, yaw: float, pitch: float) -> None: + """Set the cube rotation.""" + self._current_rotation = yaw + self._current_pitch = pitch + self.update() + + def get_rotation(self) -> Tuple[float, float]: + """Get current rotation (yaw, pitch).""" + return (self._current_rotation, self._current_pitch) + + def highlight_view(self, view_name: str) -> None: + """Highlight a specific view.""" + self._hovered_region = view_name + self.update() + + def clear_highlight(self) -> None: + """Clear highlight.""" + self._hovered_region = None + self.update()