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 diff --git a/cq_editor/display.py b/cq_editor/display.py new file mode 100644 index 00000000..50dd8c5f --- /dev/null +++ b/cq_editor/display.py @@ -0,0 +1,34 @@ +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] + + +HIDDEN_COL, WIREFRAME_COL, TRANSPARENT_COL, SHADED_COL, NAME_COL = range(5) diff --git a/cq_editor/main_window.py b/cq_editor/main_window.py index bece4094..b9fdf528 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): @@ -459,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/object_tree.py b/cq_editor/widgets/object_tree.py index 1e216e73..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,6 +26,16 @@ from ..mixins import ComponentMixin from ..icons import icon +from ..display import ( + DisplayMode, + GlobalMode, + effective_mode, + HIDDEN_COL, + WIREFRAME_COL, + TRANSPARENT_COL, + SHADED_COL, + NAME_COL, +) from ..cq_utils import ( make_AIS, export, @@ -34,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): @@ -42,13 +201,18 @@ 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}, # {"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__( @@ -63,14 +227,13 @@ 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 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,11 +248,21 @@ 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] - 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"]) @@ -99,10 +272,17 @@ def propertiesChanged(self, properties, changed): # self.ais.Redisplay() - if self.properties["Visible"]: - self.setCheckState(0, Qt.Checked) - else: - self.setCheckState(0, Qt.Unchecked) + 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() @@ -122,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" @@ -134,6 +322,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,33 +339,74 @@ 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 ) 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( @@ -206,6 +442,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))) @@ -285,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) @@ -300,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 @@ -356,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): @@ -407,6 +667,90 @@ 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 + + # 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): + """ + 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 +773,22 @@ 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) + self._attach_mode_radios(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) @@ -456,23 +802,29 @@ 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() @pyqtSlot(list) @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) @@ -480,21 +832,26 @@ 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() @pyqtSlot() 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) @@ -576,8 +933,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 item.parent() is not self.Helpers: + return + + if item.checkState(NAME_COL): + if item.display_mode is DisplayMode.HIDDEN: + item.display_mode = DisplayMode.SHADED + else: + item.display_mode = DisplayMode.HIDDEN diff --git a/cq_editor/widgets/viewer.py b/cq_editor/widgets/viewer.py index c3bce2cd..c4d3b9f5 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, NAME_COL 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( @@ -322,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) @@ -409,15 +434,62 @@ 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() 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/screenshots/display_modes.png b/screenshots/display_modes.png new file mode 100644 index 00000000..0453c187 Binary files /dev/null and b/screenshots/display_modes.png differ diff --git a/tests/test_app.py b/tests/test_app.py index 76deda64..5eac123c 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, NAME_COL code = """import cadquery as cq result = cq.Workplane("XY" ) @@ -268,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 @@ -359,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"] @@ -1109,17 +1115,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): @@ -1178,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 @@ -1866,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): @@ -2202,3 +2267,538 @@ 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()) + + +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() 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" 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 == []