diff --git a/README.md b/README.md index 4686bdf8..b007f260 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ Additional screenshots are available in [the wiki](https://github.com/CadQuery/C * Automatic code reloading - you can use your favourite editor * OCCT based +* Navigation cube with rotation arrows + * Always shows the current camera orientation + * Click faces, edges or corners for one-click animated reorientation * Graphical debugger for CadQuery scripts * Step through script and watch how your model changes * CadQuery object stack inspector diff --git a/cq_editor/widgets/navigation_cube.py b/cq_editor/widgets/navigation_cube.py new file mode 100644 index 00000000..09cf0a4d --- /dev/null +++ b/cq_editor/widgets/navigation_cube.py @@ -0,0 +1,163 @@ +from math import cos, radians, sin + +from PyQt5.QtCore import QRect + +from OCP.AIS import AIS_Shape, AIS_ViewCube +from OCP.Aspect import Aspect_TOTP_RIGHT_UPPER +from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon +from OCP.gp import gp_Pnt +from OCP.Graphic3d import ( + Graphic3d_TransformPers, + Graphic3d_TMF_2d, + Graphic3d_TMF_TriedronPers, + Graphic3d_TypeOfShadingModel, + Graphic3d_Vec2i, + Graphic3d_ZLayerId_Topmost, +) +from OCP.Font import Font_FontAspect +from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB +from OCP.TCollection import TCollection_AsciiString +from OCP.V3d import V3d_TypeOfOrientation + +CORNER_OFFSET = 85 +SIZE = 55 +FACET_EXTENSION = 13 +EDGE_MIN_SIZE = 6 +CORNER_MIN_SIZE = 6 +FONT_HEIGHT = 14 +ANIMATION_DURATION = 0.25 + +ARROW_COLOR = Quantity_Color(0.72, 0.72, 0.75, Quantity_TOC_RGB) +ARROW_HILIGHT_COLOR = Quantity_Color(0.0, 1.0, 1.0, Quantity_TOC_RGB) +ARROW_INNER_RADIUS = 58 +ARROW_OUTER_RADIUS = 69 +ARROW_TAIL_ANGLE = 74 # degrees from screen-x, where the arc starts +ARROW_HEAD_ANGLE = 50 # degrees, where the head base sits +ARROW_TIP_SWEEP = 13 # degrees swept by the head triangle +ARROW_HEAD_OVERHANG = 6 # px the head base juts beyond the band +ARC_SEGMENTS = 12 + + +class NavigationCube(AIS_ViewCube): + """FreeCAD-style navigation cube. + + Transform-persistent overlay in the upper-right corner of the view: it + is rendered by the main camera, so it always reflects the current + orientation, keeps a constant screen size and is unaffected by zoom. + Faces, edges and corners are pickable; a click starts the built-in + eased camera animation (projection preserved; the owning widget + restores zoom and center, and drives frames via UpdateAnimation()). + """ + + def __init__(self): + + super().__init__() + + self.SetTransformPersistence( + Graphic3d_TransformPers( + Graphic3d_TMF_TriedronPers, + Aspect_TOTP_RIGHT_UPPER, + Graphic3d_Vec2i(CORNER_OFFSET, CORNER_OFFSET), + ) + ) + # SetSize(..., True) auto-derives facet/font metrics, so the + # explicit overrides must come after it + self.SetSize(SIZE, True) + self.SetBoxFacetExtension(FACET_EXTENSION) + self.SetBoxEdgeMinSize(EDGE_MIN_SIZE) + self.SetBoxCornerMinSize(CORNER_MIN_SIZE) + self.SetFontHeight(FONT_HEIGHT) + self.Attributes().TextAspect().Aspect().SetTextFontAspect( + Font_FontAspect.Font_FontAspect_Bold + ) + self.SetBoxColor(Quantity_Color(0.72, 0.72, 0.75, Quantity_TOC_RGB)) + self.SetTextColor(Quantity_Color(0.0, 0.0, 0.0, Quantity_TOC_RGB)) + self.SetDrawAxes(False) + + # OCCT labels FRONT on the -Y face; CQ-editor's toolbar Front action + # looks from +Y, so swap the two labels to keep them consistent + self.SetBoxSideLabel( + V3d_TypeOfOrientation.V3d_Ypos, TCollection_AsciiString("FRONT") + ) + self.SetBoxSideLabel( + V3d_TypeOfOrientation.V3d_Yneg, TCollection_AsciiString("BACK") + ) + + self.SetDuration(ANIMATION_DURATION) + self.SetAutoStartAnimation(True) + self.SetFixedAnimationLoop(False) + + def hit_region(self, widget_width): + """Screen-space rectangle (widget coordinates) covering the cube.""" + + extent = 2 * CORNER_OFFSET + return QRect(widget_width - extent, 0, extent, extent) + + +class RotationArrow(AIS_Shape): + """Curved roll-arrow button drawn above the navigation cube. + + Screen-fixed 2D overlay (it does not rotate with the camera) anchored + at the cube's corner offset. Clicking it rolls the view by 45 degrees + around the screen-normal axis; `sense` (+1 left arrow, -1 right arrow) + selects the roll direction and mirrors the geometry. + """ + + def __init__(self, sense): + + self.sense = sense + super().__init__(self._make_face(sense)) + + self.SetTransformPersistence( + Graphic3d_TransformPers( + Graphic3d_TMF_2d, + Aspect_TOTP_RIGHT_UPPER, + Graphic3d_Vec2i(CORNER_OFFSET, CORNER_OFFSET), + ) + ) + self.SetColor(ARROW_COLOR) + self.SetZLayer(Graphic3d_ZLayerId_Topmost) + # unlit: a flat overlay button facing the camera head-on saturates + # to white under the headlight's specular otherwise + self.Attributes().ShadingAspect().Aspect().SetShadingModel( + Graphic3d_TypeOfShadingModel.Graphic3d_TypeOfShadingModel_Unlit + ) + + def set_hovered(self, hovered): + """Fill with the highlight color, like the cube's face hover. + + OCCT's dynamic-highlight presentation is coplanar with the arrow + face and loses the depth test (only its boundary shows), so the + hover feedback swaps the fill color instead. + """ + + self.SetColor(ARROW_HILIGHT_COLOR if hovered else ARROW_COLOR) + + @staticmethod + def _make_face(sense): + """Flat curved-arrow face in pixel units around the local origin.""" + + def polar(radius, angle): + # sense +1 mirrors the right-side arrow across the vertical + a = radians(180 - angle if sense > 0 else angle) + return gp_Pnt(radius * cos(a), radius * sin(a), 0) + + mid = (ARROW_INNER_RADIUS + ARROW_OUTER_RADIUS) / 2 + polygon = BRepBuilderAPI_MakePolygon() + + for i in range(ARC_SEGMENTS + 1): + step = (ARROW_HEAD_ANGLE - ARROW_TAIL_ANGLE) * i / ARC_SEGMENTS + polygon.Add(polar(ARROW_OUTER_RADIUS, ARROW_TAIL_ANGLE + step)) + polygon.Add( + polar(ARROW_OUTER_RADIUS + ARROW_HEAD_OVERHANG, ARROW_HEAD_ANGLE) + ) + polygon.Add(polar(mid, ARROW_HEAD_ANGLE - ARROW_TIP_SWEEP)) + polygon.Add( + polar(ARROW_INNER_RADIUS - ARROW_HEAD_OVERHANG, ARROW_HEAD_ANGLE) + ) + for i in range(ARC_SEGMENTS + 1): + step = (ARROW_TAIL_ANGLE - ARROW_HEAD_ANGLE) * i / ARC_SEGMENTS + polygon.Add(polar(ARROW_INNER_RADIUS, ARROW_HEAD_ANGLE + step)) + polygon.Close() + + return BRepBuilderAPI_MakeFace(polygon.Wire()).Face() diff --git a/cq_editor/widgets/occt_widget.py b/cq_editor/widgets/occt_widget.py index 195a5a51..971d503f 100755 --- a/cq_editor/widgets/occt_widget.py +++ b/cq_editor/widgets/occt_widget.py @@ -1,17 +1,27 @@ +from math import pi from sys import platform from PyQt5.QtWidgets import QWidget, QApplication -from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QPoint +from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QPoint, QTimer import OCP from OCP.Aspect import Aspect_DisplayConnection, Aspect_TypeOfTriedronPosition from OCP.OpenGl import OpenGl_GraphicDriver from OCP.V3d import V3d_Viewer -from OCP.gp import gp_Trsf, gp_Ax1, gp_Dir -from OCP.AIS import AIS_InteractiveContext, AIS_DisplayMode +from OCP.gp import gp_Trsf, gp_Ax1, gp_Dir, gp_Pnt +from OCP.AIS import ( + AIS_AnimationCamera, + AIS_InteractiveContext, + AIS_DisplayMode, + AIS_ViewCubeOwner, +) +from OCP.Graphic3d import Graphic3d_Camera from OCP.Quantity import Quantity_Color +from OCP.TCollection import TCollection_AsciiString + +from .navigation_cube import ANIMATION_DURATION, NavigationCube, RotationArrow ZOOM_STEP = 0.9 @@ -40,6 +50,17 @@ def __init__(self, parent=None): # Orbit method settings self._orbit_method = "Turntable" + # Drives the view cube and roll camera animations; runs only while + # animating + self._cube_timer = QTimer(self) + self._cube_timer.setInterval(16) + self._cube_timer.timeout.connect(self._animate_view_cube) + self._roll_animation = None + + self._cube_hover = False + self._hovered_arrow = None + self.setMouseTracking(True) + # OCCT secific things self.display_connection = Aspect_DisplayConnection() self.graphics_driver = OpenGl_GraphicDriver(self.display_connection) @@ -73,6 +94,14 @@ def prepare_display(self): ctx.SetDisplayMode(AIS_DisplayMode.AIS_Shaded, True) ctx.DefaultDrawer().SetFaceBoundaryDraw(True) + self.view_cube = NavigationCube() + self.view_cube.ViewAnimation().SetView(view) + ctx.Display(self.view_cube, False) + + self.rotate_arrows = (RotationArrow(1), RotationArrow(-1)) + for arrow in self.rotate_arrows: + ctx.Display(arrow, AIS_DisplayMode.AIS_Shaded, 0, False) + def set_orbit_method(self, method): """ Set the orbit method for the OCCT view. @@ -116,6 +145,32 @@ def mouseMoveEvent(self, event): pos = event.pos() x, y = pos.x(), pos.y() + # Hover highlight is confined to the cube's corner region so the + # rest of the viewport keeps its existing no-hover behavior + if not event.buttons(): + if self.view_cube.hit_region(self.width()).contains(pos): + self.context.MoveTo(x, y, self.view, True) + owner = ( + self.context.DetectedOwner() + if self.context.HasDetected() + else None + ) + detected = self._detected_interactive() + if not isinstance(owner, AIS_ViewCubeOwner) and not isinstance( + detected, RotationArrow + ): + self.context.ClearDetected(True) + self._set_hovered_arrow( + detected if isinstance(detected, RotationArrow) else None + ) + self._cube_hover = True + elif self._cube_hover: + self.context.ClearDetected(True) + self._set_hovered_arrow(None) + self._cube_hover = False + self._previous_pos = pos + return + # Check for mouse drag rotation if event.buttons() == Qt.LeftButton and event.modifiers() not in ( Qt.ShiftModifier, @@ -166,7 +221,40 @@ def mouseReleaseEvent(self, event): # Only make the selection if the user has not moved the mouse if self.pending_select: self.context.MoveTo(x, y, self.view, True) - self._handle_selection() + + owner = ( + self.context.DetectedOwner() + if self.context.HasDetected() + else None + ) + detected = self._detected_interactive() + if isinstance(owner, AIS_ViewCubeOwner): + self._handle_cube_click(owner) + elif isinstance(detected, RotationArrow): + self._handle_rotate_click(detected.sense) + else: + self._handle_selection() + + def _detected_interactive(self): + + if not self.context.HasDetected(): + return None + return self.context.DetectedInteractive() + + def _set_hovered_arrow(self, arrow): + + if arrow is self._hovered_arrow: + return + + if self._hovered_arrow is not None: + self._hovered_arrow.set_hovered(False) + self.context.Redisplay(self._hovered_arrow, False) + self._hovered_arrow = arrow + if arrow is not None: + arrow.set_hovered(True) + self.context.Redisplay(arrow, False) + + self.context.UpdateCurrentViewer() def _handle_selection(self): @@ -179,6 +267,117 @@ def _handle_selection(self): self.sigObjectSelected.emit(selected) + def _settle_animations(self): + """Snap any running camera animation to its end state. + + A click during a running animation must start from the settled end: + OCCT treats a repeated click on the current orientation specially + (snaps up to the canonical value), and that detection compares + against a partially-rotated camera otherwise. + """ + + self._cube_timer.stop() + + cube_animation = self.view_cube.ViewAnimation() + if not cube_animation.IsStopped(): + cube_animation.Stop() + self.view.Camera().Copy(cube_animation.CameraEnd()) + + if self._roll_animation is not None: + if not self._roll_animation.IsStopped(): + self._roll_animation.Stop() + self.view.Camera().Copy(self._roll_animation.CameraEnd()) + self._roll_animation = None + + def _handle_rotate_click(self, sense): + """Roll the view by 45 degrees around the screen-normal axis.""" + + self._settle_animations() + + camera = self.view.Camera() + end_camera = Graphic3d_Camera(camera) + roll = gp_Trsf() + roll.SetRotation( + gp_Ax1(end_camera.Center(), end_camera.Direction()), sense * pi / 4 + ) + end_camera.SetUp(end_camera.Up().Transformed(roll)) + + animation = AIS_AnimationCamera( + TCollection_AsciiString("navigation_roll"), self.view + ) + animation.SetCameraStart(Graphic3d_Camera(camera)) + animation.SetCameraEnd(end_camera) + animation.SetOwnDuration(ANIMATION_DURATION) + animation.StartTimer(0.0, 1.0, True) + + self._roll_animation = animation + self._cube_timer.start() + + def _handle_cube_click(self, owner): + """Start the animated camera reorientation for a picked cube part.""" + + self._settle_animations() + + camera = self.view.Camera() + center, distance, scale = camera.Center(), camera.Distance(), camera.Scale() + + self.view_cube.HandleClick(owner) + + # HandleClick fits the scene into the animation's target camera; + # restore the current framing so the click is a pure rotation that + # preserves zoom, center and distance. The eye is placed explicitly + # together with the old center: SetCenter alone would tilt the + # target direction after a pan, and MoveEyeTo alone would keep the + # fitted distance and drag the center along the view axis (visible + # as a zoom in perspective projection). + animation = self.view_cube.ViewAnimation() + self._restore_framing(animation.CameraEnd(), center, distance, scale) + + # with a zero duration the animation completes inside HandleClick, + # so the fitted end camera is already applied; restore the view + # camera directly in that case + if animation.IsStopped(): + self._restore_framing(self.view.Camera(), center, distance, scale) + self.view.Redraw() + + self._cube_timer.start() + + @staticmethod + def _restore_framing(camera, center, distance, scale): + """Re-frame a camera on center/distance/scale, keeping its direction.""" + + direction = camera.Direction() + camera.SetEyeAndCenter( + gp_Pnt( + center.X() - direction.X() * distance, + center.Y() - direction.Y() * distance, + center.Z() - direction.Z() * distance, + ), + center, + ) + camera.SetScale(scale) + + def _animate_view_cube(self): + + active = self.view_cube.UpdateAnimation(True) + + if self._roll_animation is not None: + self._roll_animation.UpdateTimer() + if self._roll_animation.ElapsedTime() >= ANIMATION_DURATION: + self._roll_animation.Stop() + self.view.Camera().Copy(self._roll_animation.CameraEnd()) + self._roll_animation = None + else: + active = True + self.view.Redraw() + + if not active: + self._cube_timer.stop() + # the finishing UpdateAnimation applies the end camera but only + # invalidates the view; without an explicit redraw the screen + # keeps the previous frame until an unrelated repaint + self.view.Redraw() + def paintEngine(self): return None @@ -200,6 +399,13 @@ def resizeEvent(self, event): self.view.MustBeResized() + def leaveEvent(self, event): + + if self._cube_hover: + self.context.ClearDetected(True) + self._set_hovered_arrow(None) + self._cube_hover = False + def _initialize(self): wins = { diff --git a/cq_editor/widgets/viewer.py b/cq_editor/widgets/viewer.py index 05cdad12..a50ecc19 100644 --- a/cq_editor/widgets/viewer.py +++ b/cq_editor/widgets/viewer.py @@ -16,6 +16,7 @@ AIS_Axis, AIS_Line, AIS_ListOfInteractive, + AIS_ViewCube, ) from OCP.Aspect import Aspect_GDM_Lines, Aspect_GT_Rectangular from OCP.Quantity import ( @@ -32,6 +33,7 @@ from ..icons import icon from ..cq_utils import to_occ_color, make_AIS, DEFAULT_FACE_COLOR +from .navigation_cube import RotationArrow from .occt_widget import OCCTWidget from pyqtgraph.parametertree import Parameter @@ -49,6 +51,7 @@ class OCCViewer(QWidget, ComponentMixin): name="Pref", children=[ {"name": "Fit automatically", "type": "bool", "value": True}, + {"name": "Show navigation cube", "type": "bool", "value": True}, {"name": "Use gradient", "type": "bool", "value": False}, {"name": "Background color", "type": "color", "value": (95, 95, 95)}, {"name": "Background color (aux)", "type": "color", "value": (30, 30, 30)}, @@ -158,9 +161,22 @@ def updatePreferences(self, *args): orbit_method = "Trackball" self.canvas.set_orbit_method(orbit_method) + ctx = self.canvas.context + cube = self.canvas.view_cube + if self.preferences["Show navigation cube"]: + if not ctx.IsDisplayed(cube): + ctx.Display(cube, False) + for arrow in self.canvas.rotate_arrows: + ctx.Display(arrow, AIS_Shaded, 0, False) + ctx.UpdateCurrentViewer() + elif ctx.IsDisplayed(cube): + ctx.Erase(cube, False) + for arrow in self.canvas.rotate_arrows: + ctx.Erase(arrow, False) + ctx.UpdateCurrentViewer() + self.canvas.update() - ctx = self.canvas.context ctx.SetDeviationCoefficient(self.preferences["Deviation"]) ctx.SetDeviationAngle(self.preferences["Angular deviation"]) @@ -355,7 +371,7 @@ def fit(self): # Accumulate the bounding box for displayed objects bbox = Bnd_Box() for ais in displayed: - if not isinstance(ais, AIS_Line): + if not isinstance(ais, (AIS_Line, AIS_ViewCube, RotationArrow)): bbox.Add(ais.BoundingBox()) # If nothing but the axis helpers are visible, fit to default diff --git a/screenshots/navigation_cube.png b/screenshots/navigation_cube.png new file mode 100644 index 00000000..43a6a7b6 Binary files /dev/null and b/screenshots/navigation_cube.png differ diff --git a/tests/test_app.py b/tests/test_app.py index 76deda64..f70b96c2 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -352,7 +352,10 @@ def number_visible_items(viewer): viewer_ctx = viewer._get_context() viewer_ctx.DisplayedObjects(l) - return l.Extent() + # exclude the navigation cube and its rotation arrows - they are always + # displayed furniture, not rendered CQ objects + furniture = [viewer.canvas.view_cube, *viewer.canvas.rotate_arrows] + return sum(1 for o in l if all(o != f for f in furniture)) def test_inspect(main): diff --git a/tests/test_navigation_cube.py b/tests/test_navigation_cube.py new file mode 100644 index 00000000..5356c169 --- /dev/null +++ b/tests/test_navigation_cube.py @@ -0,0 +1,328 @@ +import pytest + +from PyQt5.QtCore import QPoint, Qt, QEvent, QPointF +from PyQt5.QtGui import QMouseEvent +from PyQt5.QtWidgets import QDialog + +from OCP.AIS import AIS_ViewCube, AIS_ViewCubeOwner, AIS_ColoredShape +from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox +from OCP.V3d import V3d_TypeOfOrientation + +from cq_editor.utils import layout +from cq_editor.widgets.navigation_cube import ( + NavigationCube, + RotationArrow, + CORNER_OFFSET, +) +from cq_editor.widgets.viewer import OCCViewer + + +def test_hit_region(): + + cube = NavigationCube() + region = cube.hit_region(600) + + assert region.contains(QPoint(600 - CORNER_OFFSET, CORNER_OFFSET)) + assert region.contains(QPoint(600 - 1, 0)) + assert not region.contains(QPoint(600 - 2 * CORNER_OFFSET - 1, 10)) + assert not region.contains(QPoint(300, 200)) + assert not region.contains(QPoint(600 - CORNER_OFFSET, 2 * CORNER_OFFSET + 1)) + + +def test_cube_front_matches_toolbar_front(): + + # the toolbar's Front action looks from +Y (OCCViewer.front_view uses + # SetProj(0, 1, 0)), so the cube face seen from +Y must be labeled FRONT + cube = NavigationCube() + + assert cube.BoxSideLabel(V3d_TypeOfOrientation.V3d_Ypos).ToCString() == "FRONT" + assert cube.BoxSideLabel(V3d_TypeOfOrientation.V3d_Yneg).ToCString() == "BACK" + + +@pytest.fixture +def viewer(qtbot): + + win = QDialog() + win.setFixedSize(600, 400) + + v = OCCViewer() + layout(win, (v,), win) + + qtbot.addWidget(win) + with qtbot.waitExposed(win): + win.show() + + v.canvas.repaint() + + # generator fixture: keeps `win` referenced for the test's duration + # (qtbot.addWidget holds only a weakref) + yield qtbot, v + + +def test_cube_displayed(viewer): + + qtbot, v = viewer + + cube = v.canvas.view_cube + assert isinstance(cube, AIS_ViewCube) + assert v.canvas.context.IsDisplayed(cube) + assert cube.ViewAnimation().View() is not None + + +def test_cube_click_reorients_camera(viewer): + + qtbot, v = viewer + canvas = v.canvas + + # a displayed shape is required for the click's scene-fit to be + # observable (empty scenes have nothing to re-fit to) + box = AIS_ColoredShape(BRepPrimAPI_MakeBox(20, 20, 30).Shape()) + v.display(box) + + canvas.view_cube.SetDuration(0) + owner = AIS_ViewCubeOwner(canvas.view_cube, V3d_TypeOfOrientation.V3d_Xpos) + + # register the view as the context's last active view, as the MoveTo in + # mouseReleaseEvent does before any real cube click (StartAnimation + # silently no-ops when LastActiveView is unset) + canvas.context.MoveTo(0, 0, canvas.view, True) + + canvas.view.SetZoom(3.0) + scale_before = canvas.view.Scale() + camera = canvas.view.Camera() + distance_before = camera.Distance() + center_before = camera.Center() + + canvas._handle_cube_click(owner) + qtbot.waitUntil(lambda: not canvas._cube_timer.isActive(), timeout=2000) + + camera = canvas.view.Camera() + assert canvas.view.Proj() == pytest.approx((1, 0, 0), abs=1e-6) + assert canvas.view.Scale() == pytest.approx(scale_before, rel=1e-6) + assert camera.Distance() == pytest.approx(distance_before, rel=1e-6) + assert camera.Center().Distance(center_before) == pytest.approx(0, abs=1e-6) + + +def test_cube_click_reorients_camera_after_pan(viewer): + + qtbot, v = viewer + canvas = v.canvas + + box = AIS_ColoredShape(BRepPrimAPI_MakeBox(20, 20, 30).Shape()) + v.display(box) + + canvas.view_cube.SetDuration(0) + owner = AIS_ViewCubeOwner(canvas.view_cube, V3d_TypeOfOrientation.V3d_Xpos) + + canvas.context.MoveTo(0, 0, canvas.view, True) + + canvas.view.Pan(80, 50) + scale_before = canvas.view.Scale() + camera = canvas.view.Camera() + distance_before = camera.Distance() + center_before = camera.Center() + + canvas._handle_cube_click(owner) + qtbot.waitUntil(lambda: not canvas._cube_timer.isActive(), timeout=2000) + + camera = canvas.view.Camera() + assert canvas.view.Proj() == pytest.approx((1, 0, 0), abs=1e-6) + assert canvas.view.Scale() == pytest.approx(scale_before, rel=1e-6) + assert camera.Distance() == pytest.approx(distance_before, rel=1e-6) + assert camera.Center().Distance(center_before) == pytest.approx(0, abs=1e-6) + + +def test_cube_click_final_frame_redrawn(viewer): + + qtbot, v = viewer + canvas = v.canvas + + box = AIS_ColoredShape(BRepPrimAPI_MakeBox(20, 20, 30).Shape()) + v.display(box) + + canvas.view_cube.SetDuration(0.15) + owner = AIS_ViewCubeOwner(canvas.view_cube, V3d_TypeOfOrientation.V3d_Xpos) + canvas.context.MoveTo(0, 0, canvas.view, True) + + canvas._handle_cube_click(owner) + qtbot.waitUntil(lambda: not canvas._cube_timer.isActive(), timeout=2000) + + # the last animation frame must actually reach the screen; a view left + # invalidated shows the previous frame until an unrelated repaint + assert not canvas.view.IsInvalidated() + + +def test_cube_click_during_animation_settles_previous(viewer): + + qtbot, v = viewer + canvas = v.canvas + + box = AIS_ColoredShape(BRepPrimAPI_MakeBox(20, 20, 30).Shape()) + v.display(box) + + canvas.view_cube.SetDuration(0.15) + corner = AIS_ViewCubeOwner( + canvas.view_cube, V3d_TypeOfOrientation.V3d_XposYposZpos + ) + canvas.context.MoveTo(0, 0, canvas.view, True) + + v.top_view() + + # second click lands mid-animation; it must behave as a repeated click + # on the settled orientation, which snaps up to the canonical value + canvas._handle_cube_click(corner) + qtbot.wait(50) + canvas._handle_cube_click(corner) + qtbot.waitUntil(lambda: not canvas._cube_timer.isActive(), timeout=2000) + + up = canvas.view.Camera().Up() + assert (up.X(), up.Y(), up.Z()) == pytest.approx( + (-0.408248, -0.408248, 0.816497), abs=1e-5 + ) + + +def _move(canvas, x, y): + + event = QMouseEvent( + QEvent.MouseMove, + QPointF(x, y), + Qt.NoButton, + Qt.MouseButtons(Qt.NoButton), + Qt.KeyboardModifiers(Qt.NoModifier), + ) + canvas.mouseMoveEvent(event) + + +def test_cube_hover_highlight(viewer): + + qtbot, v = viewer + canvas = v.canvas + + assert canvas.hasMouseTracking() + + _move(canvas, canvas.width() - 85, 85) + assert canvas.context.HasDetected() + + _move(canvas, canvas.width() // 2, canvas.height() // 2) + assert not canvas.context.HasDetected() + + +def test_arrow_hover_fill(viewer): + + from math import cos, radians, sin + + from OCP.Quantity import Quantity_Color + + from cq_editor.widgets.navigation_cube import ( + ARROW_INNER_RADIUS, + ARROW_OUTER_RADIUS, + ARROW_TAIL_ANGLE, + ARROW_HEAD_ANGLE, + ) + + qtbot, v = viewer + canvas = v.canvas + ccw, cw = canvas.rotate_arrows + + # middle of the right (cw) arrow's band + r = (ARROW_INNER_RADIUS + ARROW_OUTER_RADIUS) / 2 + a = radians((ARROW_TAIL_ANGLE + ARROW_HEAD_ANGLE) / 2) + x = canvas.width() - 85 + r * cos(a) + y = 85 - r * sin(a) + + _move(canvas, x, y) + assert canvas._hovered_arrow is cw + color = Quantity_Color() + cw.Color(color) + assert (color.Red(), color.Green(), color.Blue()) == pytest.approx((0, 1, 1)) + + _move(canvas, canvas.width() // 2, canvas.height() // 2) + assert canvas._hovered_arrow is None + cw.Color(color) + assert color.Blue() == pytest.approx(0.75, abs=0.01) + + +def test_cube_highlight_cleared_on_leave(viewer): + + qtbot, v = viewer + canvas = v.canvas + + _move(canvas, canvas.width() - 85, 85) + assert canvas.context.HasDetected() + + canvas.leaveEvent(None) + assert not canvas.context.HasDetected() + + +def test_cube_visibility_preference(viewer): + + qtbot, v = viewer + ctx = v.canvas.context + cube = v.canvas.view_cube + overlays = (cube,) + v.canvas.rotate_arrows + + v.preferences["Show navigation cube"] = False + assert not any(ctx.IsDisplayed(obj) for obj in overlays) + + v.preferences["Show navigation cube"] = True + assert all(ctx.IsDisplayed(obj) for obj in overlays) + + +def test_rotate_arrows_displayed(viewer): + + qtbot, v = viewer + ctx = v.canvas.context + + ccw, cw = v.canvas.rotate_arrows + assert isinstance(ccw, RotationArrow) and isinstance(cw, RotationArrow) + assert ctx.IsDisplayed(ccw) and ctx.IsDisplayed(cw) + assert (ccw.sense, cw.sense) == (1, -1) + + +def test_rotate_click_rolls_in_45_degree_steps(viewer): + + qtbot, v = viewer + canvas = v.canvas + + v.top_view() + proj_before = canvas.view.Proj() + scale_before = canvas.view.Scale() + + # top view: direction is (0, 0, -1), up starts at (0, 1, 0); + # sense +1 rotates up by +45 deg around the view direction + canvas._handle_rotate_click(1) + qtbot.waitUntil(lambda: not canvas._cube_timer.isActive(), timeout=2000) + up = canvas.view.Camera().Up() + s = 2**-0.5 + assert (up.X(), up.Y(), up.Z()) == pytest.approx((s, s, 0), abs=1e-6) + + canvas._handle_rotate_click(1) + qtbot.waitUntil(lambda: not canvas._cube_timer.isActive(), timeout=2000) + up = canvas.view.Camera().Up() + assert (up.X(), up.Y(), up.Z()) == pytest.approx((1, 0, 0), abs=1e-6) + + canvas._handle_rotate_click(-1) + qtbot.waitUntil(lambda: not canvas._cube_timer.isActive(), timeout=2000) + up = canvas.view.Camera().Up() + assert (up.X(), up.Y(), up.Z()) == pytest.approx((s, s, 0), abs=1e-6) + + assert canvas.view.Proj() == pytest.approx(proj_before, abs=1e-6) + assert canvas.view.Scale() == pytest.approx(scale_before, rel=1e-6) + + +def test_fit_ignores_cube(viewer): + + qtbot, v = viewer + view = v.canvas.view + + box = AIS_ColoredShape(BRepPrimAPI_MakeBox(20, 20, 30).Shape()) + v.display(box) + + v.preferences["Show navigation cube"] = False + v.fit() + scale_without_cube = view.Scale() + + v.preferences["Show navigation cube"] = True + v.fit() + + assert view.Scale() == pytest.approx(scale_without_cube, rel=1e-6)