From fae81ff0793d0f95640d8903203b92dc4ac2c9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Thu, 9 Jul 2026 18:51:15 +0400 Subject: [PATCH 1/5] Add a navigation cube to the 3D viewer Renders OCCT's AIS_ViewCube as a transform-persistent overlay in the upper-right corner of the viewport: it always reflects the current camera orientation, keeps a constant screen size and allows one-click reorientation. Clicking a face, edge or corner starts an eased camera animation that preserves zoom, center, distance and projection type. Cube parts highlight on hover; hovering is confined to the cube's corner region so the rest of the viewport keeps its no-hover behavior. The cube can be toggled with the new 'Show navigation cube' preference and is excluded from fit(). FRONT/BACK labels follow the toolbar's Front/Back view actions. --- cq_editor/widgets/navigation_cube.py | 78 +++++++++ cq_editor/widgets/occt_widget.py | 125 +++++++++++++- cq_editor/widgets/viewer.py | 13 +- tests/test_navigation_cube.py | 246 +++++++++++++++++++++++++++ 4 files changed, 456 insertions(+), 6 deletions(-) create mode 100644 cq_editor/widgets/navigation_cube.py create mode 100644 tests/test_navigation_cube.py diff --git a/cq_editor/widgets/navigation_cube.py b/cq_editor/widgets/navigation_cube.py new file mode 100644 index 00000000..ea8ad852 --- /dev/null +++ b/cq_editor/widgets/navigation_cube.py @@ -0,0 +1,78 @@ +from PyQt5.QtCore import QRect + +from OCP.AIS import AIS_ViewCube +from OCP.Aspect import Aspect_TOTP_RIGHT_UPPER +from OCP.Graphic3d import ( + Graphic3d_TransformPers, + Graphic3d_TMF_TriedronPers, + Graphic3d_Vec2i, +) +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 + + +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) + diff --git a/cq_editor/widgets/occt_widget.py b/cq_editor/widgets/occt_widget.py index 195a5a51..c5510007 100755 --- a/cq_editor/widgets/occt_widget.py +++ b/cq_editor/widgets/occt_widget.py @@ -2,17 +2,19 @@ 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_InteractiveContext, AIS_DisplayMode, AIS_ViewCubeOwner from OCP.Quantity import Quantity_Color +from .navigation_cube import NavigationCube + ZOOM_STEP = 0.9 @@ -40,6 +42,14 @@ def __init__(self, parent=None): # Orbit method settings self._orbit_method = "Turntable" + # Drives the view cube camera animation; runs only while animating + self._cube_timer = QTimer(self) + self._cube_timer.setInterval(16) + self._cube_timer.timeout.connect(self._animate_view_cube) + + self._cube_hover = False + self.setMouseTracking(True) + # OCCT secific things self.display_connection = Aspect_DisplayConnection() self.graphics_driver = OpenGl_GraphicDriver(self.display_connection) @@ -73,6 +83,10 @@ 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) + def set_orbit_method(self, method): """ Set the orbit method for the OCCT view. @@ -116,6 +130,25 @@ 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 + ) + if not isinstance(owner, AIS_ViewCubeOwner): + self.context.ClearDetected(True) + self._cube_hover = True + elif self._cube_hover: + self.context.ClearDetected(True) + 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 +199,16 @@ 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 + ) + if isinstance(owner, AIS_ViewCubeOwner): + self._handle_cube_click(owner) + else: + self._handle_selection() def _handle_selection(self): @@ -179,6 +221,75 @@ 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()) + + 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): + + if not self.view_cube.UpdateAnimation(True): + 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 +311,12 @@ def resizeEvent(self, event): self.view.MustBeResized() + def leaveEvent(self, event): + + if self._cube_hover: + self.context.ClearDetected(True) + self._cube_hover = False + def _initialize(self): wins = { diff --git a/cq_editor/widgets/viewer.py b/cq_editor/widgets/viewer.py index 05cdad12..1be528f0 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 ( @@ -49,6 +50,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 +160,16 @@ 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, True) + elif ctx.IsDisplayed(cube): + ctx.Erase(cube, True) + self.canvas.update() - ctx = self.canvas.context ctx.SetDeviationCoefficient(self.preferences["Deviation"]) ctx.SetDeviationAngle(self.preferences["Angular deviation"]) @@ -355,7 +364,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)): bbox.Add(ais.BoundingBox()) # If nothing but the axis helpers are visible, fit to default diff --git a/tests/test_navigation_cube.py b/tests/test_navigation_cube.py new file mode 100644 index 00000000..48e9f1c1 --- /dev/null +++ b/tests/test_navigation_cube.py @@ -0,0 +1,246 @@ +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, 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_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 + + v.preferences["Show navigation cube"] = False + assert not ctx.IsDisplayed(cube) + + v.preferences["Show navigation cube"] = True + assert ctx.IsDisplayed(cube) + + +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) From ac132df6d6397bb363c3cc8ab54cfb7674caa3af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Thu, 9 Jul 2026 18:51:34 +0400 Subject: [PATCH 2/5] Add rotation arrows that roll the view in 45 degree steps Two curved arrow buttons above the navigation cube, similar to FreeCAD's, roll the camera around the screen-normal axis in animated 45 degree steps (pure roll: projection, zoom, center and distance are preserved). The arrows are screen-fixed 2D overlays that do not rotate with the camera, fill with the highlight color on hover, share the 'Show navigation cube' preference and are excluded from fit(). --- cq_editor/widgets/navigation_cube.py | 87 +++++++++++++++++++++++- cq_editor/widgets/occt_widget.py | 99 ++++++++++++++++++++++++++-- cq_editor/widgets/viewer.py | 13 +++- tests/test_navigation_cube.py | 88 ++++++++++++++++++++++++- 4 files changed, 275 insertions(+), 12 deletions(-) diff --git a/cq_editor/widgets/navigation_cube.py b/cq_editor/widgets/navigation_cube.py index ea8ad852..09cf0a4d 100644 --- a/cq_editor/widgets/navigation_cube.py +++ b/cq_editor/widgets/navigation_cube.py @@ -1,11 +1,18 @@ +from math import cos, radians, sin + from PyQt5.QtCore import QRect -from OCP.AIS import AIS_ViewCube +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 @@ -20,6 +27,16 @@ 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. @@ -76,3 +93,71 @@ def hit_region(self, widget_width): 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 c5510007..971d503f 100755 --- a/cq_editor/widgets/occt_widget.py +++ b/cq_editor/widgets/occt_widget.py @@ -1,3 +1,4 @@ +from math import pi from sys import platform @@ -10,10 +11,17 @@ from OCP.OpenGl import OpenGl_GraphicDriver from OCP.V3d import V3d_Viewer from OCP.gp import gp_Trsf, gp_Ax1, gp_Dir, gp_Pnt -from OCP.AIS import AIS_InteractiveContext, AIS_DisplayMode, AIS_ViewCubeOwner +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 NavigationCube +from .navigation_cube import ANIMATION_DURATION, NavigationCube, RotationArrow ZOOM_STEP = 0.9 @@ -42,12 +50,15 @@ def __init__(self, parent=None): # Orbit method settings self._orbit_method = "Turntable" - # Drives the view cube camera animation; runs only while animating + # 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 @@ -87,6 +98,10 @@ def prepare_display(self): 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. @@ -140,11 +155,18 @@ def mouseMoveEvent(self, event): if self.context.HasDetected() else None ) - if not isinstance(owner, AIS_ViewCubeOwner): + 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 @@ -205,11 +227,35 @@ def mouseReleaseEvent(self, event): 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): self.context.Select(True) @@ -237,6 +283,36 @@ def _settle_animations(self): 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.""" @@ -283,7 +359,19 @@ def _restore_framing(camera, center, distance, scale): def _animate_view_cube(self): - if not self.view_cube.UpdateAnimation(True): + 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 @@ -315,6 +403,7 @@ def leaveEvent(self, event): if self._cube_hover: self.context.ClearDetected(True) + self._set_hovered_arrow(None) self._cube_hover = False def _initialize(self): diff --git a/cq_editor/widgets/viewer.py b/cq_editor/widgets/viewer.py index 1be528f0..a50ecc19 100644 --- a/cq_editor/widgets/viewer.py +++ b/cq_editor/widgets/viewer.py @@ -33,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 @@ -164,9 +165,15 @@ def updatePreferences(self, *args): cube = self.canvas.view_cube if self.preferences["Show navigation cube"]: if not ctx.IsDisplayed(cube): - ctx.Display(cube, True) + 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, True) + ctx.Erase(cube, False) + for arrow in self.canvas.rotate_arrows: + ctx.Erase(arrow, False) + ctx.UpdateCurrentViewer() self.canvas.update() @@ -364,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, AIS_ViewCube)): + 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/tests/test_navigation_cube.py b/tests/test_navigation_cube.py index 48e9f1c1..5356c169 100644 --- a/tests/test_navigation_cube.py +++ b/tests/test_navigation_cube.py @@ -9,7 +9,11 @@ from OCP.V3d import V3d_TypeOfOrientation from cq_editor.utils import layout -from cq_editor.widgets.navigation_cube import NavigationCube, CORNER_OFFSET +from cq_editor.widgets.navigation_cube import ( + NavigationCube, + RotationArrow, + CORNER_OFFSET, +) from cq_editor.widgets.viewer import OCCViewer @@ -203,6 +207,41 @@ def test_cube_hover_highlight(viewer): 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 @@ -220,12 +259,55 @@ 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 ctx.IsDisplayed(cube) + assert not any(ctx.IsDisplayed(obj) for obj in overlays) v.preferences["Show navigation cube"] = True - assert ctx.IsDisplayed(cube) + 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): From 1e4e456db39be81642ed82ddc19e98552f44f999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Thu, 9 Jul 2026 20:14:02 +0400 Subject: [PATCH 3/5] Mention the navigation cube in the README --- README.md | 3 +++ 1 file changed, 3 insertions(+) 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 From b2250d8fe3effdae3596cc8dfce1b46f11aafb2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Thu, 9 Jul 2026 20:21:15 +0400 Subject: [PATCH 4/5] Add navigation cube screenshot --- screenshots/navigation_cube.png | Bin 0 -> 79892 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 screenshots/navigation_cube.png diff --git a/screenshots/navigation_cube.png b/screenshots/navigation_cube.png new file mode 100644 index 0000000000000000000000000000000000000000..43a6a7b6bab2e4655b0cc9229e94e68d228605a5 GIT binary patch literal 79892 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