From 55e85b250b471fd90529f6654f054103486da7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Sat, 11 Jul 2026 00:19:46 +0400 Subject: [PATCH 1/7] Stop PRINT_REDIRECTOR from outliving the LogViewer PRINT_REDIRECTOR is a module-level singleton, so the lambda connected to sigStdoutWrite in prepare_panes accumulated one connection per MainWindow and was never disconnected. Each lambda also closed over self and resolved self.components["log"] late through a dict that MainMixin holds as a class attribute, so it reached whichever LogViewer registered last. Once a LogViewer was destroyed, the next print() called into a deleted C++ object and raised RuntimeError. Connecting the bound method instead lets PyQt drop the connection when the receiver is destroyed. --- cq_editor/main_window.py | 8 +++++--- tests/test_main_window.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 tests/test_main_window.py diff --git a/cq_editor/main_window.py b/cq_editor/main_window.py index bece4094..986e8f44 100644 --- a/cq_editor/main_window.py +++ b/cq_editor/main_window.py @@ -291,9 +291,11 @@ def prepare_panes(self): for d in self.docks.values(): d.show() - PRINT_REDIRECTOR.sigStdoutWrite.connect( - lambda text: self.components["log"].append(text) - ) + # Connect the bound method rather than a lambda: PRINT_REDIRECTOR is a + # module-level singleton, and PyQt drops a connection to a QObject's + # bound method when that QObject is destroyed. A lambda would outlive + # the LogViewer and call into a deleted C++ object. + PRINT_REDIRECTOR.sigStdoutWrite.connect(self.components["log"].append) def prepare_menubar(self): diff --git a/tests/test_main_window.py b/tests/test_main_window.py new file mode 100644 index 00000000..fef7fe1e --- /dev/null +++ b/tests/test_main_window.py @@ -0,0 +1,37 @@ +import sys + +from PyQt5.QtWidgets import QMessageBox +from PyQt5 import sip + +from cq_editor.__main__ import MainWindow +from cq_editor.main_window import PRINT_REDIRECTOR + + +def test_print_redirector_released_with_window(qtbot, mocker): + """ + PRINT_REDIRECTOR is a module-level singleton that outlives any MainWindow. + Once a window's LogViewer is destroyed, a write must be dropped rather than + delivered to the now-deleted C++ object (which raises "wrapped C/C++ object + of type LogViewer has been deleted"). + """ + + mocker.patch.object(QMessageBox, "question", return_value=QMessageBox.Yes) + mocker.patch.object(QMessageBox, "warning", return_value=QMessageBox.Discard) + + win = MainWindow() + qtbot.addWidget(win) + + # destroy this window's LogViewer, as tearing the window down would + sip.delete(win.components["log"]) + + # PyQt routes an exception raised inside a slot to sys.excepthook rather + # than propagating it, so capture it there while emitting + errors = [] + original_hook = sys.excepthook + sys.excepthook = lambda exc_type, *rest: errors.append(exc_type) + try: + PRINT_REDIRECTOR.sigStdoutWrite.emit("stray output after close") + finally: + sys.excepthook = original_hook + + assert errors == [] From f8e4f6ab19380019ff16b8a231f17bca1f1d84b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Fri, 10 Jul 2026 23:00:21 +0400 Subject: [PATCH 2/7] Add a DisplayMode/GlobalMode state model Introduces the display module: a per-object DisplayMode (Hidden, Wireframe, Transparent, Shaded), a panel-wide GlobalMode override, and effective_mode() resolving the two. HIDDEN always wins so an override never unhides an object. --- cq_editor/display.py | 31 +++++++++++++++++++++++++++++++ tests/test_display.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 cq_editor/display.py create mode 100644 tests/test_display.py diff --git a/cq_editor/display.py b/cq_editor/display.py new file mode 100644 index 00000000..9402dccc --- /dev/null +++ b/cq_editor/display.py @@ -0,0 +1,31 @@ +from enum import Enum + + +class DisplayMode(Enum): + """How a single object is drawn. HIDDEN means it is erased from the view.""" + + HIDDEN = "Hidden" + WIREFRAME = "Wireframe" + TRANSPARENT = "Transparent" + SHADED = "Shaded" + + +class GlobalMode(Enum): + """Panel-wide override. AS_SET means every object keeps its own DisplayMode.""" + + AS_SET = "As set below" + WIREFRAME = "Wireframe" + TRANSPARENT = "Transparent" + SHADED = "Shaded" + + +def effective_mode(item: DisplayMode, glob: GlobalMode) -> DisplayMode: + + # An override never unhides an object. + if item is DisplayMode.HIDDEN: + return DisplayMode.HIDDEN + + if glob is GlobalMode.AS_SET: + return item + + return DisplayMode[glob.name] diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 00000000..cc04edef --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,36 @@ +import pytest + +from cq_editor.display import DisplayMode, GlobalMode, effective_mode + + +def test_hidden_survives_every_global_mode(): + for glob in GlobalMode: + assert effective_mode(DisplayMode.HIDDEN, glob) is DisplayMode.HIDDEN + + +def test_as_set_passes_the_item_mode_through(): + for item in DisplayMode: + assert effective_mode(item, GlobalMode.AS_SET) is item + + +@pytest.mark.parametrize( + "glob, expected", + [ + (GlobalMode.WIREFRAME, DisplayMode.WIREFRAME), + (GlobalMode.TRANSPARENT, DisplayMode.TRANSPARENT), + (GlobalMode.SHADED, DisplayMode.SHADED), + ], +) +def test_override_replaces_the_mode_of_every_visible_item(glob, expected): + for item in (DisplayMode.WIREFRAME, DisplayMode.TRANSPARENT, DisplayMode.SHADED): + assert effective_mode(item, glob) is expected + + +def test_values_are_the_ui_strings(): + assert [m.value for m in DisplayMode] == [ + "Hidden", + "Wireframe", + "Transparent", + "Shaded", + ] + assert GlobalMode.AS_SET.value == "As set below" From 6b0b94ac007b18ac287370dcdc7472166aba8fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Fri, 10 Jul 2026 23:03:33 +0400 Subject: [PATCH 3/7] Track per-object display mode in the object tree Adds a per-object Display mode property (Hidden/Wireframe/Transparent/Shaded) to ObjectTreeItem and a panel-wide global mode on ObjectTree, emitting sigDisplayModesChanged/sigGlobalModeChanged with the resolved effective modes so the viewer can apply them. --- cq_editor/widgets/object_tree.py | 119 +++++++++++++++++++++++++++---- tests/test_app.py | 70 ++++++++++++++++-- 2 files changed, 171 insertions(+), 18 deletions(-) diff --git a/cq_editor/widgets/object_tree.py b/cq_editor/widgets/object_tree.py index 1e216e73..a401ec55 100644 --- a/cq_editor/widgets/object_tree.py +++ b/cq_editor/widgets/object_tree.py @@ -18,6 +18,7 @@ from ..mixins import ComponentMixin from ..icons import icon +from ..display import DisplayMode, GlobalMode, effective_mode from ..cq_utils import ( make_AIS, export, @@ -48,7 +49,12 @@ class ObjectTreeItem(QTreeWidgetItem): {"name": "Name", "type": "str", "value": "", "readonly": True}, # {"name": "Color", "type": "color", "value": "#f4a824"}, # {"name": "Alpha", "type": "float", "value": 0, "limits": (0, 1), "step": 1e-1}, - {"name": "Visible", "type": "bool", "value": True}, + { + "name": "Display mode", + "type": "list", + "value": DisplayMode.SHADED.value, + "values": [m.value for m in DisplayMode], + }, ] def __init__( @@ -71,6 +77,7 @@ def __init__( self.shape = shape self.shape_display = shape_display self.sig = sig + self.base_transparency = ais.Transparency() if ais is not None else 0.0 self.properties = Parameter.create(name="Properties", children=self.props) @@ -85,6 +92,16 @@ def __init__( # ) self.properties.sigTreeStateChanged.connect(self.propertiesChanged) + @property + def display_mode(self) -> DisplayMode: + + return DisplayMode(self.properties["Display mode"]) + + @display_mode.setter + def display_mode(self, mode: DisplayMode): + + self.properties["Display mode"] = mode.value + def propertiesChanged(self, properties, changed): changed_prop = changed[0][0] @@ -99,10 +116,8 @@ def propertiesChanged(self, properties, changed): # self.ais.Redisplay() - if self.properties["Visible"]: - self.setCheckState(0, Qt.Checked) - else: - self.setCheckState(0, Qt.Unchecked) + hidden = self.display_mode is DisplayMode.HIDDEN + self.setCheckState(0, Qt.Unchecked if hidden else Qt.Checked) if self.sig: self.sig.emit() @@ -134,6 +149,13 @@ class ObjectTree(QWidget, ComponentMixin): {"name": "Clear all before each run", "type": "bool", "value": True}, {"name": "Merge Assemblies", "type": "bool", "value": False}, {"name": "STL precision", "type": "float", "value": 0.1}, + { + "name": "Transparency level", + "type": "float", + "value": 0.7, + "limits": (0, 1), + "step": 0.05, + }, ], ) @@ -144,11 +166,20 @@ class ObjectTree(QWidget, ComponentMixin): sigItemChanged = pyqtSignal(QTreeWidgetItem, int) sigObjectPropertiesChanged = pyqtSignal() sigHelpersResized = pyqtSignal(list) + sigDisplayModesChanged = pyqtSignal(list) + sigGlobalModeChanged = pyqtSignal(object) def __init__(self, parent): super(ObjectTree, self).__init__(parent) + self._global_mode = GlobalMode.AS_SET + + # ObjectTree never calls ComponentMixin.__init__ (super() resolves to + # QWidget.__init__, which does not cascade), so this connection - which + # ComponentMixin would normally make - has to be made by hand. + self.preferences.sigTreeStateChanged.connect(self.updatePreferences) + self.tree = tree = QTreeWidget( self, selectionMode=QAbstractItemView.ExtendedSelection ) @@ -206,6 +237,8 @@ def __init__(self, parent): self.prepareLayout() + self.sigObjectPropertiesChanged.connect(self._apply_modes) + def _axis_points(self, direction, halfLen): """Calculates the points needed to draw the axis helper lines""" p1 = Geom_CartesianPoint(gp_Pnt(*(-halfLen * d for d in direction))) @@ -407,6 +440,62 @@ def _subtree_ais(self, tops): if it.ais is not None ] + def _visible_ais(self, tops): + """_subtree_ais, minus the items that start out hidden.""" + return [ + it.ais + for top in tops + for it in self._iter_subtree(top) + if it.ais is not None and it.display_mode is not DisplayMode.HIDDEN + ] + + @property + def global_mode(self) -> GlobalMode: + + return self._global_mode + + @pyqtSlot(object) + def setGlobalMode(self, mode: GlobalMode): + + if mode is self._global_mode: + return + + self._global_mode = mode + self.sigGlobalModeChanged.emit(mode) + self._apply_modes() + + @pyqtSlot() + def _apply_modes(self): + """ + Resolve every CQ object's effective mode and transparency and hand the + result to the viewer. Assembly parts are nested items and carry their + own mode, so the whole subtree is walked. Helpers are excluded - they + keep their checkbox and are not affected by the global override. + """ + + transparency = self.preferences["Transparency level"] + + payload = [] + for i in range(self.CQ.childCount()): + for item in self._iter_subtree(self.CQ.child(i)): + if item.ais is None: + continue + mode = effective_mode(item.display_mode, self._global_mode) + t = ( + transparency + if mode is DisplayMode.TRANSPARENT + else item.base_transparency + ) + payload.append((item.ais, mode, t)) + + if payload: + self.sigDisplayModesChanged.emit(payload) + + @pyqtSlot(object, object) + def updatePreferences(self, *args): + + self._apply_modes() + @pyqtSlot(dict, bool) @pyqtSlot(dict) def addObjects(self, objects, clean=False, root=None): @@ -429,20 +518,21 @@ def addObjects(self, objects, clean=False, root=None): objects_f = {k: v for k, v in objects.items() if not is_obj_empty(v.shape)} for name, obj in objects_f.items(): - top_items, obj_ais = self._build_items(name, obj.shape, obj.options) + top_items, _ = self._build_items(name, obj.shape, obj.options) for item in top_items: if preserve_props and name in current_props: self._restore_properties(item, current_props) self.CQ.addChild(item) self.tree.expandItem(item) - ais_list.extend(obj_ais) + ais_list.extend(self._visible_ais(top_items)) if request_fit_view: self.sigObjectsAdded[list, bool].emit(ais_list, True) else: self.sigObjectsAdded[list].emit(ais_list) + self._apply_modes() self._rescale_helpers() @pyqtSlot(object, str, object) @@ -457,6 +547,7 @@ def addObject(self, obj, name="", options=None): for item in top_items: self.CQ.addChild(item) self.sigObjectsAdded.emit(ais_list) + self._apply_modes() @pyqtSlot(list) @pyqtSlot() @@ -489,6 +580,7 @@ def stashObjects(self, action: bool): self.CQ.addChildren(self._stash) ais_list = self._subtree_ais(self._stash) self.sigObjectsAdded.emit(ais_list) + self._apply_modes() @pyqtSlot() def removeSelected(self): @@ -576,8 +668,11 @@ def handleGraphicalSelection(self, shapes): @pyqtSlot(QTreeWidgetItem, int) def handleChecked(self, item, col): - if type(item) is ObjectTreeItem: - if item.checkState(0): - item.properties["Visible"] = True - else: - item.properties["Visible"] = False + if type(item) is not ObjectTreeItem: + return + + if item.checkState(0): + if item.display_mode is DisplayMode.HIDDEN: + item.display_mode = DisplayMode.SHADED + else: + item.display_mode = DisplayMode.HIDDEN diff --git a/tests/test_app.py b/tests/test_app.py index 76deda64..dce6bb35 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -18,6 +18,7 @@ from cq_editor.__main__ import MainWindow from cq_editor.widgets.editor import Editor from cq_editor.cq_utils import export, get_occ_color +from cq_editor.display import DisplayMode, GlobalMode, effective_mode code = """import cadquery as cq result = cq.Workplane("XY" ) @@ -1109,17 +1110,74 @@ def test_preserve_properties(main): assert object_tree.CQ.childCount() == 1 props = object_tree.CQ.child(0).properties - props["Visible"] = False - # props["Color"] = "#caffee" - # props["Alpha"] = 0.5 + props["Display mode"] = "Wireframe" debugger._actions["Run"][0].triggered.emit() assert object_tree.CQ.childCount() == 1 props = object_tree.CQ.child(0).properties - assert props["Visible"] == False - # assert props["Color"].name() == "#caffee" - # assert props["Alpha"] == 0.5 + assert props["Display mode"] == "Wireframe" + assert object_tree.CQ.child(0).display_mode is DisplayMode.WIREFRAME + + +def test_display_mode_defaults_to_shaded(main): + qtbot, win = main + + object_tree = win.components["object_tree"] + + assert object_tree.CQ.child(0).display_mode is DisplayMode.SHADED + assert object_tree.global_mode is GlobalMode.AS_SET + + +def test_base_transparency_captured_from_script_alpha(main): + qtbot, win = main + + object_tree = win.components["object_tree"] + editor = win.components["editor"] + debugger = win.components["debugger"] + + editor.set_text( + 'import cadquery as cq\n' + 'r = cq.Workplane("XY").box(1, 1, 1)\n' + 'show_object(r, options={"alpha": 0.3})\n' + ) + debugger._actions["Run"][0].triggered.emit() + + # make_AIS inverts alpha: alpha=0.3 -> OCCT transparency 0.7 + assert object_tree.CQ.child(0).base_transparency == pytest.approx(0.7, abs=1e-6) + + +def test_display_modes_payload_resolves_global_override(main_multi): + qtbot, win = main_multi + + object_tree = win.components["object_tree"] + object_tree.CQ.child(0).display_mode = DisplayMode.HIDDEN + object_tree.CQ.child(1).display_mode = DisplayMode.WIREFRAME + + with qtbot.waitSignal(object_tree.sigDisplayModesChanged) as blocker: + object_tree.setGlobalMode(GlobalMode.SHADED) + + payload = blocker.args[0] + modes = [mode for _, mode, _ in payload] + + # The override never unhides child 0, and it does replace child 1's wireframe. + assert modes == [DisplayMode.HIDDEN, DisplayMode.SHADED] + + +def test_transparent_uses_the_preference_and_shaded_restores_base(main): + qtbot, win = main + + object_tree = win.components["object_tree"] + object_tree.preferences["Transparency level"] = 0.42 + item = object_tree.CQ.child(0) + + with qtbot.waitSignal(object_tree.sigDisplayModesChanged) as blocker: + item.display_mode = DisplayMode.TRANSPARENT + assert blocker.args[0][0][2] == pytest.approx(0.42) + + with qtbot.waitSignal(object_tree.sigDisplayModesChanged) as blocker: + item.display_mode = DisplayMode.SHADED + assert blocker.args[0][0][2] == pytest.approx(item.base_transparency) def test_selection(main_multi, mocker): From 208f910bcf8f2528f32f5faf07804b60324a692c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Fri, 10 Jul 2026 23:06:25 +0400 Subject: [PATCH 4/7] Apply per-object display modes in the viewer Wires the object tree's display-mode signals to the viewer: apply_display_modes erases hidden objects and sets wireframe/shaded/transparency per object, and the Wireframe/Shaded/Transparent toolbar actions drive and reflect the global mode. --- cq_editor/main_window.py | 9 +++ cq_editor/widgets/viewer.py | 109 ++++++++++++++++++++++++++++++------ tests/test_app.py | 109 ++++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 18 deletions(-) diff --git a/cq_editor/main_window.py b/cq_editor/main_window.py index 986e8f44..b9fdf528 100644 --- a/cq_editor/main_window.py +++ b/cq_editor/main_window.py @@ -461,6 +461,15 @@ def prepare_actions(self): self.components["object_tree"].sigHelpersResized.connect( self.components["viewer"].redisplay ) + self.components["object_tree"].sigDisplayModesChanged.connect( + self.components["viewer"].apply_display_modes + ) + self.components["object_tree"].sigGlobalModeChanged.connect( + self.components["viewer"].sync_global_actions + ) + self.components["viewer"].sigGlobalModeChanged.connect( + self.components["object_tree"].setGlobalMode + ) self.components["viewer"].sigObjectSelected.connect( self.components["object_tree"].handleGraphicalSelection diff --git a/cq_editor/widgets/viewer.py b/cq_editor/widgets/viewer.py index c3bce2cd..3aa5fef6 100644 --- a/cq_editor/widgets/viewer.py +++ b/cq_editor/widgets/viewer.py @@ -31,6 +31,7 @@ from ..mixins import ComponentMixin from ..icons import icon from ..cq_utils import to_occ_color, make_AIS, DEFAULT_FACE_COLOR +from ..display import DisplayMode, GlobalMode from .occt_widget import OCCTWidget @@ -106,6 +107,7 @@ class OCCViewer(QWidget, ComponentMixin): IMAGE_EXTENSIONS = "png" sigObjectSelected = pyqtSignal(list) + sigGlobalModeChanged = pyqtSignal(object) def __init__(self, parent=None): @@ -115,6 +117,7 @@ def __init__(self, parent=None): self.canvas = OCCTWidget() self.canvas.sigObjectSelected.connect(self.handle_selection) + self._global_mode = GlobalMode.AS_SET self.create_actions(self) self.layout_ = layout( @@ -186,6 +189,39 @@ def updatePreferences(self, *args): def create_actions(self, parent): + self._wireframe_action = QAction( + qta.icon("fa5.stop-circle"), + "Wireframe (Shift+F9)", + parent, + shortcut="shift+F9", + checkable=True, + triggered=lambda checked: self.wireframe_view(), + ) + + self._shaded_action = QAction( + qta.icon("fa5.square"), + "Shaded (Shift+F10)", + parent, + shortcut="shift+F10", + checkable=True, + triggered=lambda checked: self.shaded_view(), + ) + + self._transparent_action = QAction( + qta.icon("fa5s.adjust"), + "Transparent (Shift+F12)", + parent, + shortcut="shift+F12", + checkable=True, + triggered=lambda checked: self.transparent_view(), + ) + + self._global_mode_actions = { + GlobalMode.WIREFRAME: self._wireframe_action, + GlobalMode.TRANSPARENT: self._transparent_action, + GlobalMode.SHADED: self._shaded_action, + } + self._actions = { "View": [ QAction( @@ -244,20 +280,9 @@ def create_actions(self, parent): shortcut="shift+F8", triggered=self.right_view, ), - QAction( - qta.icon("fa5.stop-circle"), - "Wireframe (Shift+F9)", - parent, - shortcut="shift+F9", - triggered=self.wireframe_view, - ), - QAction( - qta.icon("fa5.square"), - "Shaded (Shift+F10)", - parent, - shortcut="shift+F10", - triggered=self.shaded_view, - ), + self._wireframe_action, + self._transparent_action, + self._shaded_action, ], "Tools": [ QAction( @@ -409,15 +434,63 @@ def right_view(self): v.SetProj(1, 0, 0) v.SetTwist(0) + def _toggle_global_mode(self, mode: GlobalMode): + """Clicking the mode that is already active returns to AS_SET.""" + + new = GlobalMode.AS_SET if self._global_mode is mode else mode + self.sigGlobalModeChanged.emit(new) + def shaded_view(self): - c = self._get_context() - c.SetDisplayMode(AIS_Shaded, True) + self._toggle_global_mode(GlobalMode.SHADED) def wireframe_view(self): - c = self._get_context() - c.SetDisplayMode(AIS_WireFrame, True) + self._toggle_global_mode(GlobalMode.WIREFRAME) + + def transparent_view(self): + + self._toggle_global_mode(GlobalMode.TRANSPARENT) + + @pyqtSlot(object) + def sync_global_actions(self, mode: GlobalMode): + + self._global_mode = mode + + for m, action in self._global_mode_actions.items(): + action.blockSignals(True) + action.setChecked(m is mode) + action.blockSignals(False) + + @pyqtSlot(list) + def apply_display_modes(self, entries): + """ + entries: [(ais, DisplayMode, transparency), ...] - already resolved by + ObjectTree. Only the 3-arg SetDisplayMode is used; the context-wide + overload skips objects that carry a per-object override. + """ + + ctx = self._get_context() + + for ais, mode, transparency in entries: + if mode is DisplayMode.HIDDEN: + ctx.Erase(ais, False) + continue + + ctx.Display(ais, False) + + if mode is DisplayMode.WIREFRAME: + ctx.SetDisplayMode(ais, AIS_WireFrame, False) + else: + ctx.SetDisplayMode(ais, AIS_Shaded, False) + if transparency > 0: + ctx.SetTransparency(ais, transparency, False) + else: + ctx.UnsetTransparency(ais, False) + + if entries: + ctx.UpdateCurrentViewer() + self.canvas.apply_selection_mode() def show_grid( self, step=1.0, size=10.0 + 1e-6, color1=(0.7, 0.7, 0.7), color2=(0, 0, 0) diff --git a/tests/test_app.py b/tests/test_app.py index dce6bb35..55e2ad3b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2260,3 +2260,112 @@ def test_editor_autoreload(editor): # with qtbot.waitSignal(editor.triggerRerender, timeout=TIMEOUT): # # modify file - NB: separate process is needed to avoid Windows quirks # modify_file(code_nested_bottom, "test_nested_bottom.py") + + +def test_apply_display_modes_erases_hidden_and_sets_modes(main_multi): + from OCP.AIS import AIS_Shaded, AIS_WireFrame + + qtbot, win = main_multi + + object_tree = win.components["object_tree"] + viewer = win.components["viewer"] + ctx = viewer._get_context() + + ais0 = object_tree.CQ.child(0).ais + ais1 = object_tree.CQ.child(1).ais + + object_tree.CQ.child(0).display_mode = DisplayMode.HIDDEN + object_tree.CQ.child(1).display_mode = DisplayMode.WIREFRAME + + assert not ctx.IsDisplayed(ais0) + assert ctx.IsDisplayed(ais1) + assert ais1.DisplayMode() == AIS_WireFrame + + # A global override must not unhide child 0. + object_tree.setGlobalMode(GlobalMode.SHADED) + + assert not ctx.IsDisplayed(ais0) + assert ais1.DisplayMode() == AIS_Shaded + + +def test_shaded_restores_script_alpha(main): + qtbot, win = main + + object_tree = win.components["object_tree"] + viewer = win.components["viewer"] + editor = win.components["editor"] + debugger = win.components["debugger"] + + editor.set_text( + 'import cadquery as cq\n' + 'r = cq.Workplane("XY").box(1, 1, 1)\n' + 'show_object(r, options={"alpha": 0.3})\n' + ) + debugger._actions["Run"][0].triggered.emit() + + object_tree.preferences["Transparency level"] = 0.9 + item = object_tree.CQ.child(0) + + item.display_mode = DisplayMode.TRANSPARENT + assert item.ais.Transparency() == pytest.approx(0.9, abs=1e-6) + + # Back to shaded: the script asked for alpha=0.3, i.e. transparency 0.7. + # It must come back, not be forced opaque. + item.display_mode = DisplayMode.SHADED + assert item.ais.Transparency() == pytest.approx(0.7, abs=1e-6) + + +def test_shaded_unsets_transparency_when_base_is_opaque(main): + qtbot, win = main + + object_tree = win.components["object_tree"] + item = object_tree.CQ.child(0) + + item.display_mode = DisplayMode.TRANSPARENT + assert item.ais.Transparency() > 0 + + item.display_mode = DisplayMode.SHADED + assert item.ais.Transparency() == pytest.approx(0.0) + + +def test_toolbar_actions_drive_and_track_the_global_mode(main): + qtbot, win = main + + object_tree = win.components["object_tree"] + viewer = win.components["viewer"] + + wireframe = viewer._global_mode_actions[GlobalMode.WIREFRAME] + transparent = viewer._global_mode_actions[GlobalMode.TRANSPARENT] + shaded = viewer._global_mode_actions[GlobalMode.SHADED] + + # Nothing is checked while the global mode is AS_SET. + assert not any(a.isChecked() for a in viewer._global_mode_actions.values()) + + wireframe.trigger() + assert object_tree.global_mode is GlobalMode.WIREFRAME + assert wireframe.isChecked() + assert not transparent.isChecked() + assert not shaded.isChecked() + + transparent.trigger() + assert object_tree.global_mode is GlobalMode.TRANSPARENT + assert not wireframe.isChecked() + assert transparent.isChecked() + + # Clicking the already-checked action returns to AS_SET. + transparent.trigger() + assert object_tree.global_mode is GlobalMode.AS_SET + assert not any(a.isChecked() for a in viewer._global_mode_actions.values()) + + +def test_setting_global_mode_syncs_the_toolbar(main): + qtbot, win = main + + object_tree = win.components["object_tree"] + viewer = win.components["viewer"] + + object_tree.setGlobalMode(GlobalMode.SHADED) + assert viewer._global_mode_actions[GlobalMode.SHADED].isChecked() + + object_tree.setGlobalMode(GlobalMode.AS_SET) + assert not any(a.isChecked() for a in viewer._global_mode_actions.values()) From c9c3e9ff5ddc6edb69448978610921f94c1846f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Fri, 10 Jul 2026 23:18:18 +0400 Subject: [PATCH 5/7] Add per-object display mode radios to the Objects panel Replaces the name-column visibility checkbox with a row of mutually exclusive radios per object (Hidden/Wireframe/Transparent/Shaded) and an 'All' row that sets the global override. Radios are centered under centered header icons, and are torn down with the item so the joining QButtonGroup does not leak it. --- cq_editor/display.py | 3 + cq_editor/widgets/object_tree.py | 315 ++++++++++++++++++++-- cq_editor/widgets/viewer.py | 5 +- tests/test_app.py | 449 ++++++++++++++++++++++++++++++- 4 files changed, 736 insertions(+), 36 deletions(-) diff --git a/cq_editor/display.py b/cq_editor/display.py index 9402dccc..50dd8c5f 100644 --- a/cq_editor/display.py +++ b/cq_editor/display.py @@ -29,3 +29,6 @@ def effective_mode(item: DisplayMode, glob: GlobalMode) -> DisplayMode: return item return DisplayMode[glob.name] + + +HIDDEN_COL, WIREFRAME_COL, TRANSPARENT_COL, SHADED_COL, NAME_COL = range(5) diff --git a/cq_editor/widgets/object_tree.py b/cq_editor/widgets/object_tree.py index a401ec55..f2f8d725 100644 --- a/cq_editor/widgets/object_tree.py +++ b/cq_editor/widgets/object_tree.py @@ -6,11 +6,19 @@ QMenu, QWidget, QAbstractItemView, + QButtonGroup, + QRadioButton, + QHeaderView, + QHBoxLayout, + QStyle, ) -from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal +from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QRect +from PyQt5 import sip from pyqtgraph.parametertree import Parameter, ParameterTree +import qtawesome as qta + from OCP.AIS import AIS_Line from OCP.Geom import Geom_CartesianPoint from OCP.gp import gp_Pnt @@ -18,7 +26,16 @@ from ..mixins import ComponentMixin from ..icons import icon -from ..display import DisplayMode, GlobalMode, effective_mode +from ..display import ( + DisplayMode, + GlobalMode, + effective_mode, + HIDDEN_COL, + WIREFRAME_COL, + TRANSPARENT_COL, + SHADED_COL, + NAME_COL, +) from ..cq_utils import ( make_AIS, export, @@ -35,6 +52,147 @@ # Default size of the axis helper lines half-length DEFAULT_AXIS_HALF_LEN = 100.0 +OBJECT_MODES = [ + DisplayMode.HIDDEN, + DisplayMode.WIREFRAME, + DisplayMode.TRANSPARENT, + DisplayMode.SHADED, +] + +GLOBAL_MODES = [ + GlobalMode.AS_SET, + GlobalMode.WIREFRAME, + GlobalMode.TRANSPARENT, + GlobalMode.SHADED, +] + +MODE_COLUMN_ICONS = ( + "fa5s.eye-slash", + "mdi.vector-square", + "fa5s.adjust", + "fa5s.square", +) + +MODE_COLUMN_WIDTH = 26 + + +class CenteredIconHeader(QHeaderView): + """ + A header that centres a column's icon. + + QHeaderView::paintSection only ever sets AlignVCenter on the section icon, + so its horizontal alignment falls back to AlignLeft. setTextAlignment does + not help - it aligns the label text, and the mode columns have none. The + icons are therefore held here rather than on the header item, and painted + centred over whatever the style drew. + """ + + def __init__(self, orientation, parent=None): + + super(CenteredIconHeader, self).__init__(orientation, parent) + self._icons = {} + + def set_column_icon(self, col, icon): + + self._icons[col] = icon + + def paintSection(self, painter, rect, logicalIndex): + + painter.save() + super(CenteredIconHeader, self).paintSection(painter, rect, logicalIndex) + painter.restore() + + icon = self._icons.get(logicalIndex) + if icon is None: + return + + size = self.style().pixelMetric(QStyle.PM_SmallIconSize, None, self) + target = QRect(0, 0, size, size) + target.moveCenter(rect.center()) + icon.paint(painter, target) + + +class ModeRadioMixin(object): + """ + A row of mutually exclusive radios, one per mode column. Auto-exclusivity + cannot be used: setItemWidget reparents each radio into a different + per-column widget, so they are not siblings. A QButtonGroup joins them. + + The class attributes stand in for __init__: QTreeWidgetItem's constructor + is C++ and does not cascade into a Python mixin's __init__. + """ + + mode_group = None + modes = () + mode_widgets = () + + def build_mode_radios(self, tree, modes, tooltips): + + self.mode_group = QButtonGroup(tree) + self.mode_widgets = [] + + for col, tooltip in enumerate(tooltips): + radio = QRadioButton() + radio.setToolTip(tooltip) + self.mode_group.addButton(radio, col) + + # setItemWidget stretches its widget across the whole cell, which + # would pin each radio's indicator to the cell's left edge. Center + # the radio inside a container so the indicators line up with the + # centred header icons above them. + container = QWidget() + container.setToolTip(tooltip) + box = QHBoxLayout(container) + box.setContentsMargins(0, 0, 0, 0) + box.addWidget(radio, 0, Qt.AlignCenter) + + tree.setItemWidget(self, col, container) + self.mode_widgets.append(container) + + self.modes = list(modes) + + def set_mode_checked(self, mode): + + if self.mode_group is None: + return + + button = self.mode_group.button(self.modes.index(mode)) + self.mode_group.blockSignals(True) + button.setChecked(True) + self.mode_group.blockSignals(False) + + def detach_mode_radios(self, tree): + """ + Destroy the per-column item widgets and the QButtonGroup that joins + them. Without this, the QButtonGroup - C++-parented to the + long-lived tree - and its idClicked connections keep the item (and + everything it references) alive after it is taken off the tree. + + Call this while the item is still on the tree. removeItemWidget() + resolves a QModelIndex for the item, so on an item that has already + been taken it silently no-ops - and the view is then left to release + the widgets itself, which the sip.delete() below would race. + + removeItemWidget() only unsets the widget, it does not delete it, so + the containers and the group are destroyed explicitly. Each radio dies + with its container. + """ + + if self.mode_group is None: + return + + for col in range(len(self.modes)): + tree.removeItemWidget(self, col) + + for container in self.mode_widgets: + if not sip.isdeleted(container): + sip.delete(container) + self.mode_widgets = [] + + if not sip.isdeleted(self.mode_group): + sip.delete(self.mode_group) + self.mode_group = None + class TopTreeItem(QTreeWidgetItem): @@ -43,7 +201,7 @@ def __init__(self, *args, **kwargs): super(TopTreeItem, self).__init__(*args, **kwargs) -class ObjectTreeItem(QTreeWidgetItem): +class ObjectTreeItem(ModeRadioMixin, QTreeWidgetItem): props = [ {"name": "Name", "type": "str", "value": "", "readonly": True}, @@ -69,9 +227,7 @@ def __init__( **kwargs, ): - super(ObjectTreeItem, self).__init__([name], **kwargs) - self.setFlags(self.flags() | Qt.ItemIsUserCheckable) - self.setCheckState(0, Qt.Checked) + super(ObjectTreeItem, self).__init__(["", "", "", "", name], **kwargs) self.ais = ais self.shape = shape @@ -106,7 +262,7 @@ def propertiesChanged(self, properties, changed): changed_prop = changed[0][0] - self.setData(0, 0, self.properties["Name"]) + self.setData(NAME_COL, 0, self.properties["Name"]) # if changed_prop.name() == "Alpha": # self.ais.SetTransparency(self.properties["Alpha"]) @@ -116,8 +272,17 @@ def propertiesChanged(self, properties, changed): # self.ais.Redisplay() - hidden = self.display_mode is DisplayMode.HIDDEN - self.setCheckState(0, Qt.Unchecked if hidden else Qt.Checked) + if changed_prop.name() == "Display mode": + mode = self.display_mode + self.set_mode_checked(mode) + + # An item stands for its whole subtree - an assembly row often has + # no shape of its own, so alone it would have nothing to show - so + # the mode cascades to its parts. This is where the radios and the + # properties editor's dropdown meet: the dropdown writes the + # Parameter itself and never goes through the display_mode setter. + for i in range(self.childCount()): + self.child(i).display_mode = mode if self.sig: self.sig.emit() @@ -137,6 +302,14 @@ def __init__(self, *args, **kwargs): super(HelpersRootItem, self).__init__(["Helpers"], *args, **kwargs) +class GlobalModeItem(ModeRadioMixin, TopTreeItem): + + def __init__(self, *args, **kwargs): + + super(GlobalModeItem, self).__init__(["", "", "", "", "All"], *args, **kwargs) + self.setFlags(Qt.ItemIsEnabled) # not selectable, no hover highlight + + class ObjectTree(QWidget, ComponentMixin): name = "Object Tree" @@ -185,23 +358,55 @@ def __init__(self, parent): ) self.properties_editor = ParameterTree(self) - tree.setHeaderHidden(True) + header = CenteredIconHeader(Qt.Horizontal, tree) + tree.setHeader(header) + + tree.setColumnCount(5) + tree.setHeaderItem(QTreeWidgetItem(["", "", "", "", "Name"])) + tree.setHeaderHidden(False) tree.setItemsExpandable(True) tree.setRootIsDecorated(False) tree.setContextMenuPolicy(Qt.ActionsContextMenu) - # forward itemChanged singal - tree.itemChanged.connect(lambda item, col: self.sigItemChanged.emit(item, col)) - # handle visibility changes form tree + # Assembly parts nest, so the branch indent has to be drawn in the name + # column. Left in column 0 it would inset that column's cell, shifting + # its radio right and shrinking it while columns 1-3 keep the full + # section width. + tree.setTreePosition(NAME_COL) + + header_item = tree.headerItem() + for col, (icon_name, mode) in enumerate(zip(MODE_COLUMN_ICONS, OBJECT_MODES)): + header.set_column_icon(col, qta.icon(icon_name)) + header_item.setToolTip(col, mode.value) + header.setSectionResizeMode(col, QHeaderView.Fixed) + tree.setColumnWidth(col, MODE_COLUMN_WIDTH) + header_item.setTextAlignment(NAME_COL, Qt.AlignLeft | Qt.AlignVCenter) + header.setSectionResizeMode(NAME_COL, QHeaderView.Stretch) + + # forward itemChanged signal, but only for helpers - CQ objects are + # driven by their radios, and their NAME_COL carries no check state + tree.itemChanged.connect(self._forward_item_changed) + # handle visibility changes from tree tree.itemChanged.connect(self.handleChecked) + self.GlobalItem = GlobalModeItem() self.CQ = CQRootItem() self.Helpers = HelpersRootItem() root = tree.invisibleRootItem() + root.addChild(self.GlobalItem) root.addChild(self.CQ) root.addChild(self.Helpers) + self.CQ.setFirstColumnSpanned(True) + self.Helpers.setFirstColumnSpanned(True) + + self.GlobalItem.build_mode_radios( + tree, GLOBAL_MODES, [m.value for m in GLOBAL_MODES] + ) + self.GlobalItem.set_mode_checked(GlobalMode.AS_SET) + self.GlobalItem.mode_group.idClicked.connect(self._handleGlobalRadio) + tree.expandToDepth(1) self._export_STL_action = QAction( @@ -318,6 +523,8 @@ def addLines(self): item = ObjectTreeItem(name, ais=line) self.Helpers.addChild(item) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(NAME_COL, Qt.Checked) self._helper_dirs.append((item, direction)) ais_list.append(line) @@ -333,6 +540,29 @@ def _item_path(self, item): node = node.parent() return "/".join(reversed(parts)) + def _attach_mode_radios(self, top): + """ + Give every item in the subtree its own row of radios. Assembly parts + are items in their own right, so they get their own modes too. + + setItemWidget needs the item to be on the tree already, so this runs + after the subtree has been parented. + """ + + for item in self._iter_subtree(top): + item.build_mode_radios( + self.tree, OBJECT_MODES, [m.value for m in OBJECT_MODES] + ) + item.set_mode_checked(item.display_mode) + item.mode_group.idClicked.connect( + lambda col, item=item: self._handleObjectRadio(item, col) + ) + + def _detach_mode_radios(self, top): + + for item in self._iter_subtree(top): + item.detach_mode_radios(self.tree) + def _current_properties(self): """ Snapshot every CQ item's properties before a reload, keyed by tree @@ -389,9 +619,6 @@ def _build_assembly_item( ) ) - if node.children: - item.setFlags(item.flags() | Qt.ItemIsAutoTristate) - return item def _build_items(self, name, shape, options): @@ -461,9 +688,37 @@ def setGlobalMode(self, mode: GlobalMode): return self._global_mode = mode + + # Every route into the global mode lands here - the toolbar actions, + # the "All" row's own radios, and _handleObjectRadio's snap back to + # AS_SET - so this is the one place that can keep the row in sync. + self.GlobalItem.set_mode_checked(mode) + self.sigGlobalModeChanged.emit(mode) self._apply_modes() + @pyqtSlot(int) + def _handleGlobalRadio(self, col): + + self.setGlobalMode(GLOBAL_MODES[col]) + + def _handleObjectRadio(self, item, col): + """ + Clicking a per-object radio releases the global override, so the click + takes visible effect immediately. Clearing the override first means + _apply_modes runs off the display_mode change that follows. + + The change cascades to the item's parts - see ObjectTreeItem. + """ + + self.setGlobalMode(GlobalMode.AS_SET) + item.display_mode = OBJECT_MODES[col] + + def _forward_item_changed(self, item, col): + + if item.parent() is self.Helpers: + self.sigItemChanged.emit(item, col) + @pyqtSlot() def _apply_modes(self): """ @@ -524,6 +779,7 @@ def addObjects(self, objects, clean=False, root=None): self._restore_properties(item, current_props) self.CQ.addChild(item) self.tree.expandItem(item) + self._attach_mode_radios(item) ais_list.extend(self._visible_ais(top_items)) @@ -546,6 +802,7 @@ def addObject(self, obj, name="", options=None): top_items, ais_list = self._build_items(name, obj, options) for item in top_items: self.CQ.addChild(item) + self._attach_mode_radios(item) self.sigObjectsAdded.emit(ais_list) self._apply_modes() @@ -553,17 +810,21 @@ def addObject(self, obj, name="", options=None): @pyqtSlot() def removeObjects(self, objects=None): + tops = ( + [self.CQ.child(i) for i in objects] + if objects + else [self.CQ.child(i) for i in range(self.CQ.childCount())] + ) + for item in tops: + self._detach_mode_radios(item) + taken = ( [self.CQ.takeChild(i) for i in objects] if objects else self.CQ.takeChildren() ) - removed_items_ais = [ - it.ais - for top in taken - for it in self._iter_subtree(top) - if it.ais is not None - ] + + removed_items_ais = self._subtree_ais(taken) self.sigObjectsRemoved.emit(removed_items_ais) @@ -571,13 +832,16 @@ def removeObjects(self, objects=None): def stashObjects(self, action: bool): if action: + for i in range(self.CQ.childCount()): + self._detach_mode_radios(self.CQ.child(i)) self._stash = self.CQ.takeChildren() - # removed_items_ais = [ch.ais for ch in self._stash] removed_items_ais = self._subtree_ais(self._stash) self.sigObjectsRemoved.emit(removed_items_ais) else: self.removeObjects() self.CQ.addChildren(self._stash) + for item in self._stash: + self._attach_mode_radios(item) ais_list = self._subtree_ais(self._stash) self.sigObjectsAdded.emit(ais_list) self._apply_modes() @@ -587,6 +851,7 @@ def removeSelected(self): tops = [it for it in self.tree.selectedItems() if it.parent() is self.CQ] removed_items_ais = self._subtree_ais(tops) for it in tops: + self._detach_mode_radios(it) self.CQ.removeChild(it) self.sigObjectsRemoved.emit(removed_items_ais) @@ -668,10 +933,10 @@ def handleGraphicalSelection(self, shapes): @pyqtSlot(QTreeWidgetItem, int) def handleChecked(self, item, col): - if type(item) is not ObjectTreeItem: + if item.parent() is not self.Helpers: return - if item.checkState(0): + if item.checkState(NAME_COL): if item.display_mode is DisplayMode.HIDDEN: item.display_mode = DisplayMode.SHADED else: diff --git a/cq_editor/widgets/viewer.py b/cq_editor/widgets/viewer.py index 3aa5fef6..c4d3b9f5 100644 --- a/cq_editor/widgets/viewer.py +++ b/cq_editor/widgets/viewer.py @@ -31,7 +31,7 @@ from ..mixins import ComponentMixin from ..icons import icon from ..cq_utils import to_occ_color, make_AIS, DEFAULT_FACE_COLOR -from ..display import DisplayMode, GlobalMode +from ..display import DisplayMode, GlobalMode, NAME_COL from .occt_widget import OCCTWidget @@ -347,7 +347,7 @@ def update_item(self, item, col): return ctx = self._get_context() - if item.checkState(0): + if item.checkState(NAME_COL): ctx.Display(item.ais, True) else: ctx.Erase(item.ais, True) @@ -490,7 +490,6 @@ def apply_display_modes(self, entries): if entries: ctx.UpdateCurrentViewer() - self.canvas.apply_selection_mode() def show_grid( self, step=1.0, size=10.0 + 1e-6, color1=(0.7, 0.7, 0.7), color2=(0, 0, 0) diff --git a/tests/test_app.py b/tests/test_app.py index 55e2ad3b..5eac123c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -18,7 +18,7 @@ from cq_editor.__main__ import MainWindow from cq_editor.widgets.editor import Editor from cq_editor.cq_utils import export, get_occ_color -from cq_editor.display import DisplayMode, GlobalMode, effective_mode +from cq_editor.display import DisplayMode, GlobalMode, effective_mode, NAME_COL code = """import cadquery as cq result = cq.Workplane("XY" ) @@ -269,7 +269,7 @@ def test_render(main): debugger._actions["Run"][0].triggered.emit() qtbot.wait(100) - assert obj_tree_comp.CQ.child(0).text(0) == "test" + assert obj_tree_comp.CQ.child(0).text(NAME_COL) == "test" assert "test" in log.toPlainText().splitlines()[-1] # cq reloading check @@ -360,12 +360,17 @@ def test_inspect(main): qtbot, win = main - # set focus and make invisible + # select the rendered object - three steps down past the "All" global row + # and the CQ root obj_tree = win.components["object_tree"].tree qtbot.mouseClick(obj_tree, Qt.LeftButton) qtbot.keyClick(obj_tree, Qt.Key_Down) qtbot.keyClick(obj_tree, Qt.Key_Down) - qtbot.keyClick(obj_tree, Qt.Key_Space) + qtbot.keyClick(obj_tree, Qt.Key_Down) + + # hide it via its display mode - the per-object visibility control that + # replaced the name-column checkbox + win.components["object_tree"].CQ.child(0).display_mode = DisplayMode.HIDDEN # enable object inspector insp = win.components["cq_object_inspector"] @@ -1137,7 +1142,7 @@ def test_base_transparency_captured_from_script_alpha(main): debugger = win.components["debugger"] editor.set_text( - 'import cadquery as cq\n' + "import cadquery as cq\n" 'r = cq.Workplane("XY").box(1, 1, 1)\n' 'show_object(r, options={"alpha": 0.3})\n' ) @@ -1236,11 +1241,13 @@ def test_selection(main_multi, mocker): assert len(object_tree.tree.selectedItems()) == 1 # go through different handleSelection paths + # one extra Down vs. before: the "All" global-mode row now precedes "CQ models" qtbot.mouseClick(object_tree.tree, Qt.LeftButton) qtbot.keyClick(object_tree.tree, Qt.Key_Down) qtbot.keyClick(object_tree.tree, Qt.Key_Down) qtbot.keyClick(object_tree.tree, Qt.Key_Down) qtbot.keyClick(object_tree.tree, Qt.Key_Down) + qtbot.keyClick(object_tree.tree, Qt.Key_Down) assert object_tree._export_STL_action.isEnabled() == False assert object_tree._export_STEP_action.isEnabled() == False @@ -1924,10 +1931,10 @@ def test_show_without_name(main): assert object_tree.CQ.childCount() == 2 # Check the name of the first object - assert object_tree.CQ.child(0).text(0) == "res" + assert object_tree.CQ.child(0).text(NAME_COL) == "res" # Check that the name of the seconf object is an int - int(object_tree.CQ.child(1).text(0)) + int(object_tree.CQ.child(1).text(NAME_COL)) def test_print_redirect(main): @@ -2297,7 +2304,7 @@ def test_shaded_restores_script_alpha(main): debugger = win.components["debugger"] editor.set_text( - 'import cadquery as cq\n' + "import cadquery as cq\n" 'r = cq.Workplane("XY").box(1, 1, 1)\n' 'show_object(r, options={"alpha": 0.3})\n' ) @@ -2369,3 +2376,429 @@ def test_setting_global_mode_syncs_the_toolbar(main): object_tree.setGlobalMode(GlobalMode.AS_SET) assert not any(a.isChecked() for a in viewer._global_mode_actions.values()) + + +def test_tree_has_five_columns_with_a_visible_header(main): + qtbot, win = main + + tree = win.components["object_tree"].tree + + assert tree.columnCount() == 5 + assert not tree.isHeaderHidden() + assert tree.headerItem().text(4) == "Name" + + +def test_clicking_a_radio_sets_the_object_mode(main): + from cq_editor.widgets.object_tree import WIREFRAME_COL + + qtbot, win = main + + object_tree = win.components["object_tree"] + item = object_tree.CQ.child(0) + + item.mode_group.button(WIREFRAME_COL).click() + + assert item.display_mode is DisplayMode.WIREFRAME + + +def test_clicking_a_radio_snaps_the_global_mode_back_to_as_set(main_multi): + from cq_editor.widgets.object_tree import TRANSPARENT_COL + + qtbot, win = main_multi + + object_tree = win.components["object_tree"] + viewer = win.components["viewer"] + + object_tree.CQ.child(1).display_mode = DisplayMode.WIREFRAME + object_tree.setGlobalMode(GlobalMode.SHADED) + assert viewer._global_mode_actions[GlobalMode.SHADED].isChecked() + + object_tree.CQ.child(0).mode_group.button(TRANSPARENT_COL).click() + + assert object_tree.global_mode is GlobalMode.AS_SET + assert object_tree.CQ.child(0).display_mode is DisplayMode.TRANSPARENT + # child 1 is released from the override and returns to its own stored mode + assert object_tree.CQ.child(1).ais.DisplayMode() == 0 # AIS_WireFrame + assert not any(a.isChecked() for a in viewer._global_mode_actions.values()) + + +def test_global_row_radios_drive_the_global_mode(main): + from cq_editor.widgets.object_tree import HIDDEN_COL, SHADED_COL + + qtbot, win = main + + object_tree = win.components["object_tree"] + + object_tree.GlobalItem.mode_group.button(SHADED_COL).click() + assert object_tree.global_mode is GlobalMode.SHADED + + object_tree.GlobalItem.mode_group.button(HIDDEN_COL).click() + assert object_tree.global_mode is GlobalMode.AS_SET + + +def test_setting_the_mode_programmatically_updates_the_radios(main): + from cq_editor.widgets.object_tree import WIREFRAME_COL, SHADED_COL + + qtbot, win = main + + item = win.components["object_tree"].CQ.child(0) + + assert item.mode_group.button(SHADED_COL).isChecked() + + item.display_mode = DisplayMode.WIREFRAME + + assert item.mode_group.button(WIREFRAME_COL).isChecked() + assert not item.mode_group.button(SHADED_COL).isChecked() + + +def test_helpers_keep_a_checkbox_in_the_name_column(main): + from cq_editor.widgets.object_tree import NAME_COL + + qtbot, win = main + + object_tree = win.components["object_tree"] + viewer = win.components["viewer"] + ctx = viewer._get_context() + + helper = object_tree.Helpers.child(0) + assert helper.checkState(NAME_COL) == Qt.Checked + assert ctx.IsDisplayed(helper.ais) + + helper.setCheckState(NAME_COL, Qt.Unchecked) + assert not ctx.IsDisplayed(helper.ais) + + +def test_global_override_does_not_touch_helpers(main): + from cq_editor.widgets.object_tree import NAME_COL + + qtbot, win = main + + object_tree = win.components["object_tree"] + viewer = win.components["viewer"] + ctx = viewer._get_context() + + helper = object_tree.Helpers.child(0) + object_tree.setGlobalMode(GlobalMode.WIREFRAME) + + assert helper.checkState(NAME_COL) == Qt.Checked + assert ctx.IsDisplayed(helper.ais) + + +def test_rerunning_the_script_does_not_leak_mode_radios(main): + from PyQt5.QtWidgets import QButtonGroup, QRadioButton + + qtbot, win = main + + object_tree = win.components["object_tree"] + debugger = win.components["debugger"] + + # run once more so "before" already reflects steady-state churn + debugger._actions["Run"][0].triggered.emit() + groups_before = len(object_tree.tree.findChildren(QButtonGroup)) + radios_before = len(object_tree.tree.findChildren(QRadioButton)) + + debugger._actions["Run"][0].triggered.emit() + debugger._actions["Run"][0].triggered.emit() + debugger._actions["Run"][0].triggered.emit() + + assert object_tree.CQ.childCount() == 1 + assert len(object_tree.tree.findChildren(QButtonGroup)) == groups_before + assert len(object_tree.tree.findChildren(QRadioButton)) == radios_before + + +def test_stash_unstash_reinstalls_the_mode_radios(main): + from PyQt5.QtWidgets import QRadioButton + + qtbot, win = main + + object_tree = win.components["object_tree"] + item = object_tree.CQ.child(0) + item.display_mode = DisplayMode.WIREFRAME + + object_tree.stashObjects(True) + object_tree.stashObjects(False) + + child = object_tree.CQ.child(0) + container = object_tree.tree.itemWidget(child, 0) + radio = container.layout().itemAt(0).widget() + + assert isinstance(radio, QRadioButton) + assert child.mode_group.button(child.modes.index(child.display_mode)).isChecked() + + +def test_radio_click_after_unstash_still_drives_display_mode(main): + from cq_editor.widgets.object_tree import TRANSPARENT_COL + + qtbot, win = main + + object_tree = win.components["object_tree"] + + object_tree.stashObjects(True) + object_tree.stashObjects(False) + + item = object_tree.CQ.child(0) + # go through the tree's own item widget, not item.mode_group directly - + # this is what proves the radios installed post-unstash are both visible + # and wired up, not merely a dangling reference to the pre-stash group + container = object_tree.tree.itemWidget(item, TRANSPARENT_COL) + radio = container.layout().itemAt(0).widget() + radio.click() + + assert item.display_mode is DisplayMode.TRANSPARENT + + +def test_handle_checked_preserves_a_non_hidden_mode_when_still_checked(main): + """ + handleChecked fires on every itemChanged for a helper's NAME_COL, not + only genuine checkbox clicks (e.g. propertiesChanged's setData call on + the same column re-triggers it). It must not stomp an already non-HIDDEN + mode back to SHADED just because the box is (still) checked. + """ + from cq_editor.widgets.object_tree import NAME_COL + + qtbot, win = main + + object_tree = win.components["object_tree"] + helper = object_tree.Helpers.child(0) + assert helper.checkState(NAME_COL) == Qt.Checked + + helper.display_mode = DisplayMode.WIREFRAME + object_tree.handleChecked(helper, NAME_COL) + + assert helper.display_mode is DisplayMode.WIREFRAME + + +def test_toolbar_click_updates_the_all_row_radios(main): + from cq_editor.widgets.object_tree import ( + HIDDEN_COL, + WIREFRAME_COL, + TRANSPARENT_COL, + SHADED_COL, + ) + + qtbot, win = main + + object_tree = win.components["object_tree"] + viewer = win.components["viewer"] + group = object_tree.GlobalItem.mode_group + + # HIDDEN_COL is the "as set below" slot on the All row. + assert group.checkedId() == HIDDEN_COL + + viewer._global_mode_actions[GlobalMode.WIREFRAME].trigger() + assert group.checkedId() == WIREFRAME_COL + + viewer._global_mode_actions[GlobalMode.TRANSPARENT].trigger() + assert group.checkedId() == TRANSPARENT_COL + + viewer._global_mode_actions[GlobalMode.SHADED].trigger() + assert group.checkedId() == SHADED_COL + + # Clicking the checked action returns to AS_SET, and the row follows. + viewer._global_mode_actions[GlobalMode.SHADED].trigger() + assert group.checkedId() == HIDDEN_COL + + +def test_object_radio_click_resets_the_all_row_radios(main_multi): + from cq_editor.widgets.object_tree import HIDDEN_COL, WIREFRAME_COL + + qtbot, win = main_multi + + object_tree = win.components["object_tree"] + group = object_tree.GlobalItem.mode_group + + object_tree.setGlobalMode(GlobalMode.SHADED) + assert group.checkedId() != HIDDEN_COL + + # Snapping back to "as set below" must move the All row, not just the state. + object_tree.CQ.child(0).mode_group.button(WIREFRAME_COL).click() + + assert object_tree.global_mode is GlobalMode.AS_SET + assert group.checkedId() == HIDDEN_COL + + +def test_mode_radios_are_centered_in_their_columns(main): + qtbot, win = main + + object_tree = win.components["object_tree"] + tree = object_tree.tree + header = tree.header() + + # The branch indent insets the tree-position column's cell only. Drawn in + # column 0 it would shift that column's radio right and shrink it while + # columns 1-3 keep the full section width, so it belongs on the name column. + assert tree.treePosition() == NAME_COL + + for item in (object_tree.GlobalItem, object_tree.CQ.child(0)): + for col in range(4): + container = tree.itemWidget(item, col) + assert container is not None + + # Every cell must span exactly its header section, or the radio is + # centred inside the wrong rect. + assert container.geometry().x() == header.sectionPosition(col) + assert container.geometry().width() == header.sectionSize(col) + + radio = container.layout().itemAt(0).widget() + assert container.layout().itemAt(0).alignment() & Qt.AlignHCenter + + +code_show_assy_parts = """import cadquery as cq +box = cq.Workplane("XY").box(1, 1, 1) +cyl = cq.Workplane("XY").circle(0.3).extrude(1) + +assy = cq.Assembly(name="assy") +assy.add(box, name="box") +assy.add(cyl, name="cyl", loc=cq.Location(cq.Vector(2, 0, 0))) + +show_object(assy) +""" + + +def _show_assembly(qtbot, win): + """Run code_show_assy_parts and hand back the (root, box, cyl) items.""" + + win.components["editor"].set_text(code_show_assy_parts) + win.components["debugger"]._actions["Run"][0].triggered.emit() + qtbot.wait(500) + + object_tree = win.components["object_tree"] + + assert object_tree.CQ.childCount() == 1 + root = object_tree.CQ.child(0) + assert root.childCount() == 2 + + return root, root.child(0), root.child(1) + + +def test_assembly_parts_get_their_own_mode_radios(main): + from PyQt5.QtWidgets import QRadioButton + + qtbot, win = main + + object_tree = win.components["object_tree"] + header = object_tree.tree.header() + root, box, cyl = _show_assembly(qtbot, win) + + # The root of an assembly carries no shape of its own, but it still gets a + # row of radios so the whole subtree can be driven from it. + for item in (root, box, cyl): + assert item.mode_group is not None + container = object_tree.tree.itemWidget(item, 0) + assert isinstance(container.layout().itemAt(0).widget(), QRadioButton) + + # The parts are indented, but the indent is drawn in the name column, so + # their radio cells still line up with the header sections. + for col in range(4): + container = object_tree.tree.itemWidget(box, col) + assert container.geometry().x() == header.sectionPosition(col) + assert container.geometry().width() == header.sectionSize(col) + + +def test_assembly_parts_hold_independent_modes(main): + from OCP.AIS import AIS_WireFrame + + qtbot, win = main + + ctx = win.components["viewer"]._get_context() + root, box, cyl = _show_assembly(qtbot, win) + + box.display_mode = DisplayMode.HIDDEN + cyl.display_mode = DisplayMode.WIREFRAME + + assert not ctx.IsDisplayed(box.ais) + assert ctx.IsDisplayed(cyl.ais) + assert cyl.ais.DisplayMode() == AIS_WireFrame + + +def test_assembly_mode_from_the_properties_editor_cascades(main): + from cq_editor.widgets.object_tree import HIDDEN_COL + + qtbot, win = main + + ctx = win.components["viewer"]._get_context() + root, box, cyl = _show_assembly(qtbot, win) + + # The properties editor writes the Parameter itself - it never goes + # through the display_mode setter, so it has to cascade all the same. + root.properties["Display mode"] = DisplayMode.HIDDEN.value + + assert box.display_mode is DisplayMode.HIDDEN + assert cyl.display_mode is DisplayMode.HIDDEN + assert box.mode_group.checkedId() == HIDDEN_COL + assert not ctx.IsDisplayed(box.ais) + assert not ctx.IsDisplayed(cyl.ais) + + +def test_clicking_an_assembly_root_radio_cascades_to_its_parts(main): + from cq_editor.widgets.object_tree import HIDDEN_COL + + qtbot, win = main + + ctx = win.components["viewer"]._get_context() + root, box, cyl = _show_assembly(qtbot, win) + + root.mode_group.button(HIDDEN_COL).click() + + assert root.display_mode is DisplayMode.HIDDEN + assert box.display_mode is DisplayMode.HIDDEN + assert cyl.display_mode is DisplayMode.HIDDEN + assert not ctx.IsDisplayed(box.ais) + assert not ctx.IsDisplayed(cyl.ais) + + +def test_a_hidden_assembly_part_stays_hidden_across_a_rerun(main): + qtbot, win = main + + object_tree = win.components["object_tree"] + ctx = win.components["viewer"]._get_context() + + object_tree.preferences["Preserve properties on reload"] = True + try: + root, box, cyl = _show_assembly(qtbot, win) + box.display_mode = DisplayMode.HIDDEN + + win.components["debugger"]._actions["Run"][0].triggered.emit() + qtbot.wait(500) + + root = object_tree.CQ.child(0) + box, cyl = root.child(0), root.child(1) + + assert box.display_mode is DisplayMode.HIDDEN + assert not ctx.IsDisplayed(box.ais) + assert ctx.IsDisplayed(cyl.ais) + finally: + object_tree.preferences["Preserve properties on reload"] = False + + +def test_rerunning_an_assembly_script_does_not_leak_mode_radios(main): + from PyQt5.QtWidgets import QButtonGroup, QRadioButton + + qtbot, win = main + + object_tree = win.components["object_tree"] + debugger = win.components["debugger"] + + _show_assembly(qtbot, win) + groups_before = len(object_tree.tree.findChildren(QButtonGroup)) + radios_before = len(object_tree.tree.findChildren(QRadioButton)) + + for _ in range(3): + debugger._actions["Run"][0].triggered.emit() + qtbot.wait(500) + + assert object_tree.CQ.childCount() == 1 + assert len(object_tree.tree.findChildren(QButtonGroup)) == groups_before + assert len(object_tree.tree.findChildren(QRadioButton)) == radios_before + + +def test_header_icons_are_centered_in_their_columns(main): + qtbot, win = main + + header = win.components["object_tree"].tree.header() + + # The icons live on the header, not the header item: QHeaderView paints a + # section icon with AlignVCenter only, so it would otherwise sit hard left. + assert win.components["object_tree"].tree.headerItem().icon(0).isNull() + for col in range(4): + assert not header._icons[col].isNull() From 16709b1ae5de9af7908f0739cd942ae5b4595df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Sat, 11 Jul 2026 00:02:12 +0400 Subject: [PATCH 6/7] Mention per-object display modes in the README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 4686bdf8..0a14675e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ Additional screenshots are available in [the wiki](https://github.com/CadQuery/C * CadQuery object stack inspector * Visual inspection of current workplane and selected items * Insight into evolution of the model +* Per-object display modes + * Show each object as shaded, transparent or wireframe, or hide it + * Assembly parts have their own mode; setting one on the assembly applies it to all of them + * A global override applies one mode to every object at once * Export to various formats * STL * STEP From aa5647491d8464f1260bb568d3b673da67d239f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Sat, 11 Jul 2026 00:02:12 +0400 Subject: [PATCH 7/7] Add per-object display modes screenshot --- screenshots/display_modes.png | Bin 0 -> 32405 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 screenshots/display_modes.png diff --git a/screenshots/display_modes.png b/screenshots/display_modes.png new file mode 100644 index 0000000000000000000000000000000000000000..0453c187e64997208040c7359447e107968a9e7b GIT binary patch literal 32405 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