From 6256d0c82a8c720bc2149442e1cd03fa67c03ee7 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 01/12] 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 edc176d8531150482a5e80f65e4bfd87361cbc59 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 02/12] 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 224c7fee..8ac4789b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -25,6 +25,7 @@ from cq_editor.main_window import DockSeparatorStyle 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" ) @@ -1116,17 +1117,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 bf079816d1a739c59e7194dda90f9bd148495030 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 03/12] 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 d976556c..abf5abea 100644 --- a/cq_editor/main_window.py +++ b/cq_editor/main_window.py @@ -485,6 +485,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 8ac4789b..1ce58d1e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2291,3 +2291,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 e5999fab7b9a39d81782698618eec4421032dfbd 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 04/12] 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 1ce58d1e..c5658b35 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -25,7 +25,7 @@ from cq_editor.main_window import DockSeparatorStyle 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" ) @@ -276,7 +276,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 @@ -367,12 +367,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"] @@ -1144,7 +1149,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' ) @@ -1243,11 +1248,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 @@ -1955,10 +1962,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): @@ -2328,7 +2335,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' ) @@ -2400,3 +2407,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 e294373bd7640aee3a6cb0090adaf644387bd5f8 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 05/12] 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 fee47a2384959b7944983103640952a071eb37bf 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 06/12] 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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 Date: Sat, 5 Sep 2026 16:21:17 +0400 Subject: [PATCH 07/12] Retrigger CI (flaky render-timing test) From 6e5fbc6471abf48260aa93c1bc12f82aaaddf408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Sat, 5 Sep 2026 16:24:21 +0400 Subject: [PATCH 08/12] Loosen the many-shapes render/erase timing thresholds --- tests/test_app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index c5658b35..1e6eb10e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -318,8 +318,10 @@ def test_many_shapes_render_and_erase_time(main_clean): erase_elapsed = perf_counter() - start assert object_tree.CQ.childCount() == 0 - assert render_elapsed < 2, f"400-shape render took {render_elapsed:.3f}s" - assert erase_elapsed < 2, f"400-shape erase took {erase_elapsed:.3f}s" + # CI runners have been observed taking up to ~2.6s for the render; give a + # comfortable margin above that instead of the tight 2s bound. + assert render_elapsed < 4, f"400-shape render took {render_elapsed:.3f}s" + assert erase_elapsed < 4, f"400-shape erase took {erase_elapsed:.3f}s" def test_export(main, mocker): From c38b86b0b4e193d5744e0c4c821a47c9407024f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Sat, 5 Sep 2026 19:55:00 +0400 Subject: [PATCH 09/12] Revert "Loosen the many-shapes render/erase timing thresholds" This reverts commit 6e5fbc6471abf48260aa93c1bc12f82aaaddf408. --- tests/test_app.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 1e6eb10e..c5658b35 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -318,10 +318,8 @@ def test_many_shapes_render_and_erase_time(main_clean): erase_elapsed = perf_counter() - start assert object_tree.CQ.childCount() == 0 - # CI runners have been observed taking up to ~2.6s for the render; give a - # comfortable margin above that instead of the tight 2s bound. - assert render_elapsed < 4, f"400-shape render took {render_elapsed:.3f}s" - assert erase_elapsed < 4, f"400-shape erase took {erase_elapsed:.3f}s" + assert render_elapsed < 2, f"400-shape render took {render_elapsed:.3f}s" + assert erase_elapsed < 2, f"400-shape erase took {erase_elapsed:.3f}s" def test_export(main, mocker): From b98e91054dad06f9be7cf747fd6f99663ececc35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Sat, 5 Sep 2026 19:55:45 +0400 Subject: [PATCH 10/12] Attach display-mode radios after the object tree is populated Attaching the four setItemWidget radios inside the insert loop made every later addChild/expandItem walk the view's persistent editor table, so addObjects was quadratic in the object count: the 400-shape render went from 0.23s to 1.28s locally and past the 2s test bound on CI. Inserting and expanding every item first and attaching the radios afterwards brings it back to 0.36s. The finally keeps items already added before a failing object (the partial-add case test_render_colors checks) with their radios. --- cq_editor/widgets/object_tree.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/cq_editor/widgets/object_tree.py b/cq_editor/widgets/object_tree.py index f2f8d725..0d6b03ff 100644 --- a/cq_editor/widgets/object_tree.py +++ b/cq_editor/widgets/object_tree.py @@ -772,17 +772,25 @@ def addObjects(self, objects, clean=False, root=None): # remove empty objects 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, _ = 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) + # Radios are attached only once every item is on the tree: each item + # widget makes every later insert/expand walk the view's editor table, + # so attaching per item is quadratic in the object count. + added = [] + try: + for name, obj in objects_f.items(): + 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) + added.append(item) + + ais_list.extend(self._visible_ais(top_items)) + finally: + for item in added: self._attach_mode_radios(item) - ais_list.extend(self._visible_ais(top_items)) - if request_fit_view: self.sigObjectsAdded[list, bool].emit(ais_list, True) else: From 88178fceef1cc8545b8778d0e9865d67686c711d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Wed, 7 Oct 2026 17:52:26 +0400 Subject: [PATCH 11/12] Keep the object tree's mode controls visible in the dark theme Draw the header icons in the palette's text color instead of qtawesome's default near-black, and lighten the tree's Window color under the dark theme so Fusion's radio and checkbox outlines stand out from the dark Base. --- cq_editor/main_window.py | 10 ++++++++ cq_editor/widgets/object_tree.py | 19 ++++++++------ tests/test_app.py | 44 +++++++++++++++++++++++++++++--- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/cq_editor/main_window.py b/cq_editor/main_window.py index abf5abea..03864649 100644 --- a/cq_editor/main_window.py +++ b/cq_editor/main_window.py @@ -235,6 +235,16 @@ def preferencesChanged(self, param, changes): self.toolbar.setPalette(p) + # Fusion outlines radios and checkboxes in a darkened Window color, + # which vanishes against the dark Base, so lighten it for the tree + tree = self.components["object_tree"].tree + if self.preferences["Light/Dark Theme"] == "Dark": + tree_palette = tree.palette() + tree_palette.setColor(QPalette.Window, QColor(150, 150, 150)) + tree.setPalette(tree_palette) + else: + tree.setPalette(QPalette()) + def closeEvent(self, event): self.saveWindow() diff --git a/cq_editor/widgets/object_tree.py b/cq_editor/widgets/object_tree.py index 0d6b03ff..39993307 100644 --- a/cq_editor/widgets/object_tree.py +++ b/cq_editor/widgets/object_tree.py @@ -13,6 +13,7 @@ QStyle, ) from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QRect +from PyQt5.QtGui import QPalette from PyQt5 import sip from pyqtgraph.parametertree import Parameter, ParameterTree @@ -84,17 +85,18 @@ class CenteredIconHeader(QHeaderView): 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. + centred over whatever the style drew. They are rendered at paint time in + the header's text color, so they follow the light/dark theme. """ def __init__(self, orientation, parent=None): super(CenteredIconHeader, self).__init__(orientation, parent) - self._icons = {} + self._icon_names = {} - def set_column_icon(self, col, icon): + def set_column_icon(self, col, icon_name): - self._icons[col] = icon + self._icon_names[col] = icon_name def paintSection(self, painter, rect, logicalIndex): @@ -102,14 +104,15 @@ def paintSection(self, painter, rect, logicalIndex): super(CenteredIconHeader, self).paintSection(painter, rect, logicalIndex) painter.restore() - icon = self._icons.get(logicalIndex) - if icon is None: + icon_name = self._icon_names.get(logicalIndex) + if icon_name 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) + color = self.palette().color(QPalette.ButtonText) + qta.icon(icon_name, color=color).paint(painter, target) class ModeRadioMixin(object): @@ -376,7 +379,7 @@ def __init__(self, parent): 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.set_column_icon(col, icon_name) header_item.setToolTip(col, mode.value) header.setSectionResizeMode(col, QHeaderView.Fixed) tree.setColumnWidth(col, MODE_COLUMN_WIDTH) diff --git a/tests/test_app.py b/tests/test_app.py index c5658b35..7c1be41f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -11,7 +11,7 @@ import pytestqt import cadquery as cq -from PyQt5.QtCore import Qt, QSettings, QPoint, QEvent, QSize +from PyQt5.QtCore import Qt, QSettings, QPoint, QEvent, QSize, QRect from PyQt5.QtWidgets import ( QApplication, QFileDialog, @@ -19,7 +19,7 @@ QStyle, QStyleFactory, ) -from PyQt5.QtGui import QMouseEvent +from PyQt5.QtGui import QMouseEvent, QPalette from cq_editor.__main__ import MainWindow from cq_editor.main_window import DockSeparatorStyle @@ -2832,4 +2832,42 @@ def test_header_icons_are_centered_in_their_columns(main): # 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() + assert header._icon_names[col] + + +def test_mode_controls_follow_dark_theme(main): + qtbot, win = main + + tree = win.components["object_tree"].tree + header = tree.header() + + def brightest_in_icon_column(): + image = header.grab().toImage() + rect = QRect( + header.sectionViewportPosition(0), 0, header.sectionSize(0), header.height() + ) + return max( + image.pixelColor(x, y).lightness() + for x in range(rect.left(), rect.right()) + for y in range(rect.top(), rect.bottom()) + ) + + win.preferences["Light/Dark Theme"] = "Dark" + win.preferencesChanged(None, None) + + # Header icons are drawn in the header's text color, not qtawesome's + # default near-black, which is invisible on the dark header + assert brightest_in_icon_column() > 200 + + # Fusion outlines the radios in a darkened Window color, so it has to + # stand clear of the dark Base the radios are drawn on + palette = tree.palette() + outline = palette.color(QPalette.Window).darker(140).lightness() + assert outline - palette.color(QPalette.Base).lightness() > 50 + + win.preferences["Light/Dark Theme"] = "Light" + win.preferencesChanged(None, None) + + assert tree.palette().color(QPalette.Window) == QApplication.palette().color( + QPalette.Window + ) From 7ba817ef2a6ac4bb5066f0760153783e8959a7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Wed, 7 Oct 2026 18:00:16 +0400 Subject: [PATCH 12/12] Keep opaque part colors when applying display modes The color lives on the object's own shading aspect, which UnsetTransparency drops. It was called directly for a transparency of 0, and AIS_InteractiveContext::SetTransparency calls it too when leaving the transparent mode, so set the transparency on the object instead. --- cq_editor/widgets/viewer.py | 9 +++++---- tests/test_app.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/cq_editor/widgets/viewer.py b/cq_editor/widgets/viewer.py index c4d3b9f5..c1829c8a 100644 --- a/cq_editor/widgets/viewer.py +++ b/cq_editor/widgets/viewer.py @@ -483,10 +483,11 @@ def apply_display_modes(self, entries): 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) + # The color lives on the object's own shading aspect + # (cq_utils.set_color), which UnsetTransparency drops. The + # context's SetTransparency calls it for a value of 0, so + # set the transparency on the object directly. + ais.SetTransparency(transparency) if entries: ctx.UpdateCurrentViewer() diff --git a/tests/test_app.py b/tests/test_app.py index 7c1be41f..984cb753 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2823,6 +2823,42 @@ def test_rerunning_an_assembly_script_does_not_leak_mode_radios(main): assert len(object_tree.tree.findChildren(QRadioButton)) == radios_before +code_show_colored_assy = """import cadquery as cq +box = cq.Workplane("XY").box(1, 1, 1) + +assy = cq.Assembly(name="assy") +assy.add(box, name="opaque", color=cq.Color(1, 0, 0, 1)) +assy.add(box, name="translucent", color=cq.Color(0, 0, 1, 0.5), loc=cq.Location((2, 0, 0))) + +show_object(assy) +""" + + +def test_assembly_part_colors_survive_display_modes(main): + qtbot, win = main + + win.components["editor"].set_text(code_show_colored_assy) + win.components["debugger"]._actions["Run"][0].triggered.emit() + qtbot.wait(500) + + root = win.components["object_tree"].CQ.child(0) + opaque, translucent = root.child(0), root.child(1) + + def check_colors(): + assert get_rgba(opaque.ais) == pytest.approx((1, 0, 0, 0), abs=1e-3) + assert get_rgba(translucent.ais) == pytest.approx((0, 0, 1, 0.5), abs=1e-3) + + # A fully opaque part must keep its color too: clearing its transparency + # must not drop the shading aspect that carries the color + check_colors() + + for item in (opaque, translucent): + item.display_mode = DisplayMode.TRANSPARENT + item.display_mode = DisplayMode.SHADED + + check_colors() + + def test_header_icons_are_centered_in_their_columns(main): qtbot, win = main