From a9df72d4d1ae337fdd4da1fb475ff0ddedd7006b Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Thu, 23 Jul 2026 15:49:26 -0500 Subject: [PATCH] fix(gui): pre-beta-5 review findings (shortcuts help, collada, shuffle, z-trace) - shortcuts help dialog: reword the five View toggles to match the current menu labels (Focus mode / Hide trace layer / Show all traces (ignore hidden) / Hide image / Section blend); fix the "viwed" typo. - collada (.dae) export: detect pycollada via importlib.util.find_spec and disable the menu item ("(not installed)" suffix) when absent, so frozen builds don't offer an export that can only fail; reword the runtime guard message to cover packaged installs. Runtime guard kept as a backstop. - shuffle autoseg colors: next_shuffle_seed accepts an optional ids iterable and the caller passes the overlay's visible label ids (new ZarrLayer.getPresentIds), so the "always reshuffles" guarantee applies to what the user actually sees; falls back to the 1..63 range when unavailable. - z-trace label casing: lowercase mid-label "z-trace(s)" in the field Z-trace menu (edit/delete) and the menubar item. - menubar "Toggle show Z-traces" becomes a checkable "Show z-traces" using the (series, "checkbox") form, synced from show_ztraces at build and in checkActions (menubar has no aboutToShow resync hook; external option changes reflect on the next checkActions pass). - dedup smoothtraces_act: the Object > Geometry smooth action is renamed smoothobj_act so it no longer shadows the field Trace submenu's smoothtraces_act on the shared widget. - update stale "copy row text" comments to "copy trace/z-trace values"; disable the Focus mode item when nothing is selected. - tests updated/added for the shuffle-ids fingerprint, collada label/disable logic, and the smooth attr_name dedup. --- .../modules/backend/autoseg/palette.py | 26 +++++-- .../modules/backend/view/zarr_layer.py | 19 ++++++ .../modules/backend/volume/export_volumes.py | 29 ++++++-- .../modules/datatypes/default_settings.py | 1 + PyReconstruct/modules/gui/dialog/shortcuts.py | 10 +-- .../modules/gui/main/context_menu_list.py | 60 ++++++++++++++-- .../modules/gui/main/field_widget_2_trace.py | 4 +- .../modules/gui/main/main_imports.py | 6 +- PyReconstruct/modules/gui/main/main_window.py | 34 +++++++++- PyReconstruct/modules/gui/main/menubar.py | 6 +- PyReconstruct/modules/gui/table/object.py | 7 ++ PyReconstruct/modules/gui/table/trace.py | 2 +- PyReconstruct/modules/gui/table/ztrace.py | 2 +- tests/test_autoseg_palette.py | 36 ++++++++++ tests/test_menu_restructure.py | 68 +++++++++++++++++-- 15 files changed, 275 insertions(+), 35 deletions(-) diff --git a/PyReconstruct/modules/backend/autoseg/palette.py b/PyReconstruct/modules/backend/autoseg/palette.py index e9019e29..19dfe554 100644 --- a/PyReconstruct/modules/backend/autoseg/palette.py +++ b/PyReconstruct/modules/backend/autoseg/palette.py @@ -163,7 +163,7 @@ def palette_color(label_id, palette=None, seed: int = 0) -> tuple: return tuple(int(c) for c in palette[index]) -def next_shuffle_seed(current_seed: int, palette=None, rng=None) -> int: +def next_shuffle_seed(current_seed: int, palette=None, ids=None, rng=None) -> int: """Pick a new seed whose color arrangement differs from the current one. This backs the "Shuffle colors" button: each click must visibly change the @@ -174,10 +174,20 @@ def next_shuffle_seed(current_seed: int, palette=None, rng=None) -> int: option, so determinism and preview==import are preserved exactly as with a hand-entered seed. + The guarantee is enforced over ``ids``: the label ids the caller can + actually see. With only a few labels on screen, a generic 1..63 fingerprint + can "change" while none of the *visible* labels move color -- a no-op click + for the user. Passing the overlay's present ids ties the guarantee to what + is on screen. When ``ids`` is None (or has no usable entries) it falls back + to the 1..63 range, which reaches every palette entry. + Params: current_seed (int): the seed currently in effect palette: list of (R, G, B) entries; falls back to the shipped default when None or empty + ids: optional iterable of label ids to fingerprint the arrangement + over (the visible/present ids); None uses the default 1..63 + range rng: an optional random.Random (injectable for deterministic tests); a fresh default source is used when None Returns: @@ -196,10 +206,16 @@ def next_shuffle_seed(current_seed: int, palette=None, rng=None) -> int: if len(palette) < 2: return int(current_seed) - # A small id sample fingerprints the arrangement. Reaching every entry over - # ~64 ids is near-certain, so two seeds agreeing across all of them means - # the visible mapping is identical. - sample = range(1, 64) + # Fingerprint the arrangement over the visible ids when supplied (id 0 is + # background, not a segment, so it is dropped); otherwise a 1..63 sample, + # which reaches every entry so two seeds agreeing across all of them means + # the mapping is identical. + if ids is None: + sample = range(1, 64) + else: + sample = [int(i) for i in ids if i] + if not sample: + sample = range(1, 64) def arrangement(seed): return tuple(palette_color(i, palette, seed) for i in sample) diff --git a/PyReconstruct/modules/backend/view/zarr_layer.py b/PyReconstruct/modules/backend/view/zarr_layer.py index ba42e136..f75ea767 100644 --- a/PyReconstruct/modules/backend/view/zarr_layer.py +++ b/PyReconstruct/modules/backend/view/zarr_layer.py @@ -118,6 +118,25 @@ def selectID(self, pix_x : int, pix_y : int): return True return False + def getPresentIds(self): + """Return the label ids visible on the current section. + + These are the unique non-zero ids in the current section's slice of the + label overlay -- i.e. the labels the user actually sees colored (the + whole crop is colored, not just ``selected_ids``). Used to tie the + shuffle-colors guarantee to what is on screen. Returns an empty list + when this is not a label overlay or the current section falls outside + the overlay's z-range. + """ + if not self.is_labels: + return [] + bz = self.zarr.shape[0] + z = round(self.section.n - self.zarr_s) + if not 0 <= z < bz: + return [] + present = np.unique(self.zarr[z]) + return [int(v) for v in present.tolist() if v != 0] + def deselectAll(self): """Deselect all the IDs.""" self.selected_ids = [] diff --git a/PyReconstruct/modules/backend/volume/export_volumes.py b/PyReconstruct/modules/backend/volume/export_volumes.py index 974c443c..04acbee2 100644 --- a/PyReconstruct/modules/backend/volume/export_volumes.py +++ b/PyReconstruct/modules/backend/volume/export_volumes.py @@ -1,5 +1,6 @@ """Export 3D objects.""" +import importlib.util from pathlib import Path from typing import List, Union @@ -12,6 +13,18 @@ from PyReconstruct.modules.gui.utils import notify +def collada_available() -> bool: + """Return True when the optional 'pycollada' package is importable. + + Collada (.dae) export goes through trimesh's Collada writer, which imports + 'pycollada' lazily. That package is NOT bundled by PyInstaller, so a frozen + ("packaged") build never has it available. Callers use this to grey out the + .dae menu item up front instead of offering an export that can only fail. + Uses ``find_spec`` so it merely checks importability without importing. + """ + return importlib.util.find_spec("collada") is not None + + def export3DObjects(series: Series, obj_names : list, output_dir : str, export_type: str, notify_user: bool = True) -> None: """Export 3D objects. @@ -26,18 +39,22 @@ def export3DObjects(series: Series, obj_names : list, output_dir : str, export_t ## Collada (.dae) export needs the optional 'pycollada' package (trimesh's ## Collada writer imports it lazily and otherwise raises a bare - ## ModuleNotFoundError). Surface the requirement here, before any work, so - ## the user gets a clear message instead of an unhandled traceback. + ## ModuleNotFoundError). The menu normally disables .dae when it is absent + ## (see collada_available), but keep this as a backstop -- surface the + ## requirement here, before any work, so the user gets a clear message + ## instead of an unhandled traceback. if export_type == "dae": try: import collada # noqa: F401 (provided by the 'pycollada' package) except ImportError: if notify_user: notify( - "Collada (.dae) export requires the 'pycollada' package, " - "which is not installed.\n\n" - "Install it (e.g. 'pip install pycollada') and try again, " - "or choose another export format." + "Collada (.dae) export needs the optional 'pycollada' " + "package, which is not available in this installation.\n\n" + "In a pip/uv environment you can install it (e.g. " + "'pip install pycollada'); packaged (frozen) builds do not " + "include Collada support. Otherwise choose another export " + "format." ) return diff --git a/PyReconstruct/modules/datatypes/default_settings.py b/PyReconstruct/modules/datatypes/default_settings.py index 9a5fb7c7..ec06073b 100644 --- a/PyReconstruct/modules/datatypes/default_settings.py +++ b/PyReconstruct/modules/datatypes/default_settings.py @@ -94,6 +94,7 @@ def get_username() -> str: "deccon_act": "[", "inccon_act": "]", "blend_act": "Space", + "toggleztraces_act": "", # checkable "Show z-traces"; no default key "homeview_act": "Home", "selectall_act": "Ctrl+A", "deselect_act": "Ctrl+D", diff --git a/PyReconstruct/modules/gui/dialog/shortcuts.py b/PyReconstruct/modules/gui/dialog/shortcuts.py index 6d8d4ea9..9b692bca 100644 --- a/PyReconstruct/modules/gui/dialog/shortcuts.py +++ b/PyReconstruct/modules/gui/dialog/shortcuts.py @@ -146,15 +146,15 @@ def getStaticShortcuts(w : QWidget) -> list[QKeySequence]: ("flicker_act", "Switch between current and last viewed section"), None, "View", - ("focus_act", "Toggle focus mode"), - ("hideall_act", "Toggle hide all traces (regardless of trace's hide status)"), - ("showall_act", "Toggle show all traces (regardless of trace's hide status)"), - ("hideimage_act", "Toggle hide images"), + ("focus_act", "Focus mode"), + ("hideall_act", "Hide trace layer"), + ("showall_act", "Show all traces (ignore hidden)"), + ("hideimage_act", "Hide image"), ("decbr_act", "Decrease brightness"), ("incbr_act", "Increase brightness"), ("deccon_act", "Decrease contrast"), ("inccon_act", "Increase contrast"), - ("blend_act", "Blend current and last viwed section"), + ("blend_act", "Section blend"), ("homeview_act", "Set view to image"), None, "Field Interactions", diff --git a/PyReconstruct/modules/gui/main/context_menu_list.py b/PyReconstruct/modules/gui/main/context_menu_list.py index aa83f9af..5055e250 100644 --- a/PyReconstruct/modules/gui/main/context_menu_list.py +++ b/PyReconstruct/modules/gui/main/context_menu_list.py @@ -4,6 +4,49 @@ from PyReconstruct.modules.gui.utils import getUserColsMenu, getAlignmentsMenu +def collada_menu_label(available=None): + """Label for the Collada (.dae) export item, flagged when unavailable. + + Collada export needs the optional 'pycollada' package, which frozen + (PyInstaller) builds never bundle. When it is unavailable the label gains a + "(not installed)" suffix so the (disabled) item explains itself. Pass + ``available`` explicitly to keep the string logic pure/testable; when None + the live availability is queried (import kept local so this menu module + doesn't pull in the heavy export backend at import time). + + Params: + available (bool | None): whether pycollada is importable; None to + query it live + Returns: + (str): the menu label + """ + if available is None: + from PyReconstruct.modules.backend.volume.export_volumes import ( + collada_available, + ) + available = collada_available() + return "Collada (.dae)" if available else "Collada (.dae) (not installed)" + + +def disable_unavailable_export_formats(widget): + """Grey out 3D-export formats whose optional dependency is missing. + + Currently only Collada (.dae), which needs 'pycollada' (absent from frozen + builds). Disabling the menu item up front means a packaged user is never + offered an export that can only fail; the runtime guard in + ``export3DObjects`` remains a backstop. Call once, right after the object + menu is populated onto ``widget`` (both the field context menu and the + object-list table build it). No-op when the action isn't on this widget or + Collada IS available. + """ + from PyReconstruct.modules.backend.volume.export_volumes import ( + collada_available, + ) + act = getattr(widget, "export3D_dae_act", None) + if act is not None and not collada_available(): + act.setEnabled(False) + + def edit_selected_label(active): """Resolve the Q8 top-level edit action's (label, enabled) for a selection. @@ -146,7 +189,11 @@ def get_context_menu_list_obj(self): ("editobjradius_act", "Edit radius...", "", self.editRadius), ("editobjshape_act", "Edit shape...", "", self.editShape), None, - ("smoothtraces_act", "Smooth traces", "", self.smoothObject), + # Distinct attr_name from the field Trace submenu's + # "smoothtraces_act": both menus are populated onto the same + # widget, so a shared name meant one silently shadowed the + # other (same class of bug as the old export3D_act). + ("smoothobj_act", "Smooth traces", "", self.smoothObject), ("splitobj_act", "Split into separate objects", "", self.splitObject), None, ("removealltags_act", "Remove all tags", "", self.removeAllTags), @@ -177,15 +224,16 @@ def get_context_menu_list_obj(self): [ # unique attr_names per format (previously all "export3D_act", # so four of five silently shadowed the last on the widget). - # Collada requires the optional 'pycollada' package; the - # export handler surfaces that requirement gracefully - # (export_volumes.export3DObjects), so the dependency note - # is no longer crammed into the label. + # Collada needs the optional 'pycollada' package (never + # bundled in frozen builds): its label is flagged when + # unavailable and the item is disabled after build (see + # disable_unavailable_export_formats); export3DObjects + # keeps a runtime guard as a backstop. ("export3D_obj_act", "Wavefront (.obj)", "", lambda : self.exportAs3D("obj")), ("export3D_off_act", "Object File Format (.off)", "", lambda : self.exportAs3D("off")), ("export3D_ply_act", "Stanford PLY (.ply)", "", lambda : self.exportAs3D("ply")), ("export3D_stl_act", "STL (.stl)", "", lambda : self.exportAs3D("stl")), - ("export3D_dae_act", "Collada (.dae)", "", lambda : self.exportAs3D("dae")), + ("export3D_dae_act", collada_menu_label(), "", lambda : self.exportAs3D("dae")), ] }, diff --git a/PyReconstruct/modules/gui/main/field_widget_2_trace.py b/PyReconstruct/modules/gui/main/field_widget_2_trace.py index 837bca73..e0b20f3a 100644 --- a/PyReconstruct/modules/gui/main/field_widget_2_trace.py +++ b/PyReconstruct/modules/gui/main/field_widget_2_trace.py @@ -1183,7 +1183,7 @@ def editTraceShape(self, traces : list): def getZtraceMenu(self): """Get the context menu list for interacting with ztraces.""" context_menu_list = [ - ("editztracce_act", "Edit Z-trace attributes...", "", self.editZtraceAttributes), + ("editztracce_act", "Edit z-trace attributes...", "", self.editZtraceAttributes), ("smoothztrace_act", "Smooth", "", self.smoothZtrace), None, { @@ -1207,7 +1207,7 @@ def getZtraceMenu(self): }, ("setztracealignment_act", "Edit alignment...", "", self.editZtraceAlignment), None, - ("deleteztrace_act", "Delete Z-traces", "", self.deleteZtrace) + ("deleteztrace_act", "Delete z-traces", "", self.deleteZtrace) ] return context_menu_list diff --git a/PyReconstruct/modules/gui/main/main_imports.py b/PyReconstruct/modules/gui/main/main_imports.py index 0433fd74..88543a9c 100644 --- a/PyReconstruct/modules/gui/main/main_imports.py +++ b/PyReconstruct/modules/gui/main/main_imports.py @@ -172,4 +172,8 @@ from .menubar import return_menubar -from .context_menu_list import get_field_menu_list, edit_selected_label +from .context_menu_list import ( + get_field_menu_list, + edit_selected_label, + disable_unavailable_export_formats, +) diff --git a/PyReconstruct/modules/gui/main/main_window.py b/PyReconstruct/modules/gui/main/main_window.py index 09d5282e..3235866a 100644 --- a/PyReconstruct/modules/gui/main/main_window.py +++ b/PyReconstruct/modules/gui/main/main_window.py @@ -143,6 +143,15 @@ def createMenuBar(self): ## Populate menu bar with menus and options populateMenuBar(self, self.menubar, menu) + ## Seed menubar checkables that have no aboutToShow resync hook from the + ## live option, so they are correct on first show. checkActions keeps + ## them synced thereafter (see its View-toggle block). NOTE: the menubar + ## has no per-open resync, so an external change to show_ztraces (e.g. + ## the all-options dialog) is only reflected on the next checkActions + ## pass (field interactions) or menubar rebuild -- an accepted + ## limitation for a rarely-out-of-band toggle. + self.toggleztraces_act.setChecked(bool(self.series.getOption("show_ztraces"))) + def createContextMenus(self): """Create right-click menus used in the field.""" ## Create user columns options @@ -150,6 +159,10 @@ def createContextMenus(self): self.field_menu = QMenu(self) populateMenu(self, self.field_menu, field_menu_list) + ## Grey out export formats whose optional dependency is missing + ## (e.g. Collada/.dae without 'pycollada', as in frozen builds). + disable_unavailable_export_formats(self) + ## Organize actions self.trace_actions = [ self.tracemenu, @@ -296,6 +309,17 @@ def checkActions(self, context_menu=False, clicked_trace=None, clicked_label=Non self.hideimage_act.setChecked(self.field.hide_image) self.blend_act.setChecked(self.field.blend_sections) + ## Focus mode needs a selection to turn ON (it focuses the selected + ## object); disabling the checkbox when nothing is selected avoids the + ## "Please select at least one trace" error from a menu click. Stays + ## enabled while already in focus mode so it can always be turned off. + self.focus_act.setEnabled(bool(self.field.focus_mode) or bool(selected_traces)) + + ## Menubar "Show z-traces" checkbox: same live-state resync as the + ## field View toggles above (setChecked emits `toggled`, not + ## `triggered`, so it never re-fires the handler). + self.toggleztraces_act.setChecked(bool(self.series.getOption("show_ztraces"))) + ## Group visibility for group, viz in self.series.groups_visibility.items(): try: @@ -2468,8 +2492,16 @@ def shuffleAutosegColors(self): current = self.series.getOption("autoseg_color_seed") or 0 palette = self.series.getOption("autoseg_color_palette") or None + # Enforce the "always reshuffles" guarantee over the labels actually + # visible on this section, not a fixed 1..63 range: with only a few + # labels on screen a new seed could recolor ids the user can't see and + # leave the visible ones unchanged (a no-op click). Fall back to the + # default range when the overlay can't supply present ids. + zarr_layer = self.field.zarr_layer + present_ids = zarr_layer.getPresentIds() if zarr_layer else None self.series.setOption( - "autoseg_color_seed", next_shuffle_seed(current, palette) + "autoseg_color_seed", + next_shuffle_seed(current, palette, ids=present_ids) ) self.field.generateView() diff --git a/PyReconstruct/modules/gui/main/menubar.py b/PyReconstruct/modules/gui/main/menubar.py index fb2e9a49..dbcb27cf 100644 --- a/PyReconstruct/modules/gui/main/menubar.py +++ b/PyReconstruct/modules/gui/main/menubar.py @@ -356,7 +356,11 @@ def return_view_menu(self): ("viewmag_act", "View magnification...", "", self.field.setViewMagnification), ("findview_act", "Set zoom when finding contours...", "", self.setFindZoom), None, - ("toggleztraces_act", "Toggle show Z-traces", "", self.toggleZtraces), + # Checkable, mirroring the live show_ztraces option (see + # MainWindow.checkActions / createMenuBar for the resync). The + # (series, "checkbox") form keeps it configurable-shortcut-capable + # like the field View toggles; its option default is "" (no key). + ("toggleztraces_act", "Show z-traces", (self.series, "checkbox"), self.toggleZtraces), None, { "attr_name": "palettemenu", diff --git a/PyReconstruct/modules/gui/table/object.py b/PyReconstruct/modules/gui/table/object.py index 442d3191..3951a6ee 100644 --- a/PyReconstruct/modules/gui/table/object.py +++ b/PyReconstruct/modules/gui/table/object.py @@ -239,6 +239,13 @@ def getCall(col_name, opt_name): self.context_menu = QMenu(self) populateMenu(self, self.context_menu, context_menu_list) + # Grey out export formats whose optional dependency is missing + # (e.g. Collada/.dae without 'pycollada', as in frozen builds). + from PyReconstruct.modules.gui.main.context_menu_list import ( + disable_unavailable_export_formats, + ) + disable_unavailable_export_formats(self) + def updateTitle(self): """Update the title of the table.""" is_regex = tuple(self.re_filters) != (".*",) diff --git a/PyReconstruct/modules/gui/table/trace.py b/PyReconstruct/modules/gui/table/trace.py index 0c50fffa..5e4587e1 100644 --- a/PyReconstruct/modules/gui/table/trace.py +++ b/PyReconstruct/modules/gui/table/trace.py @@ -275,7 +275,7 @@ def createMenus(self): populateMenuBar(self, self.menubar, menubar_list) # create the right-click menu -- prepend the list-only table ops - # (invert selection + copy row text) whose handlers belong to THIS + # (invert selection + copy trace values) whose handlers belong to THIS # table, then the shared trace menu built by the field. context_menu_list = [ ("inverttraceselection_act", "Invert selection", "", self.invertSelection), diff --git a/PyReconstruct/modules/gui/table/ztrace.py b/PyReconstruct/modules/gui/table/ztrace.py index d33c6641..915a415a 100644 --- a/PyReconstruct/modules/gui/table/ztrace.py +++ b/PyReconstruct/modules/gui/table/ztrace.py @@ -115,7 +115,7 @@ def createMenus(self): populateMenuBar(self, self.menubar, menubar_list) # create the right-click menu -- prepend the list-only table ops - # (invert selection + copy row text) whose handlers belong to THIS + # (invert selection + copy z-trace values) whose handlers belong to THIS # table, then the shared z-trace menu (also used in the field). context_menu_list = [ ("invertztraceselection_act", "Invert selection", "", self.invertSelection), diff --git a/tests/test_autoseg_palette.py b/tests/test_autoseg_palette.py index d8765010..d46b21a6 100644 --- a/tests/test_autoseg_palette.py +++ b/tests/test_autoseg_palette.py @@ -170,6 +170,42 @@ def test_shuffle_noop_on_single_color_palette(): assert next_shuffle_seed(5, palette=[(1, 2, 3)], rng=random.Random(0)) == 5 +def test_shuffle_changes_the_visible_ids_when_supplied(): + """When present ids are supplied, the guarantee applies to THEM: the + visible ids' colors must change on every click, not just some fixed 1..63 + fingerprint (which could move while the on-screen labels do not).""" + import random + visible = [3, 17, 42] + + def vis_arr(seed): + return [palette_color(i, DEFAULT_AUTOSEG_PALETTE, seed) for i in visible] + + seed = 0 + for _ in range(25): # simulate repeated clicks + new_seed = next_shuffle_seed(seed, ids=visible, rng=random.Random(_)) + assert vis_arr(new_seed) != vis_arr(seed) + seed = new_seed + + +def test_shuffle_empty_or_none_ids_fall_back_to_default_range(): + """Empty/None ids -> the default 1..63 fingerprint (still reshuffles).""" + import random + a = next_shuffle_seed(0, ids=[], rng=random.Random(1)) + b = next_shuffle_seed(0, ids=None, rng=random.Random(1)) + c = next_shuffle_seed(0, rng=random.Random(1)) + assert a == b == c + assert _arrangement(a) != _arrangement(0) + + +def test_shuffle_ids_drop_background_zero(): + """id 0 (background) is dropped from the fingerprint; [0] behaves as empty + and falls back to the default range.""" + import random + a = next_shuffle_seed(0, ids=[0], rng=random.Random(7)) + b = next_shuffle_seed(0, rng=random.Random(7)) + assert a == b + + # --- override / fallback --------------------------------------------------- diff --git a/tests/test_menu_restructure.py b/tests/test_menu_restructure.py index f48c30e5..7bd7da97 100644 --- a/tests/test_menu_restructure.py +++ b/tests/test_menu_restructure.py @@ -141,7 +141,7 @@ def test_geometry_submenu_holds_the_geometry_actions(): geo = _names(list(_walk(_submenu(menu, "Geometry")))) assert geo == [ "copyobj_act", "editobjradius_act", "editobjshape_act", - "smoothtraces_act", "splitobj_act", "removealltags_act", + "smoothobj_act", "splitobj_act", "removealltags_act", ] @@ -159,7 +159,7 @@ def test_no_object_capability_was_lost_in_restructure(): "addobjgroup_act", "removeobjgroup_act", "removeobjallgroups_act", "setobjalignment_act", "lockobj_act", "unlockobj_act", "copyobj_act", "editobjradius_act", "editobjshape_act", - "smoothtraces_act", "splitobj_act", "hideobj_act", "unhideobj_act", + "smoothobj_act", "splitobj_act", "hideobj_act", "unhideobj_act", "hideotherobj_act", "hideallobj_act", "showallobj_act", "removealltags_act", "blankcurate_act", "needscuration_act", "curated_act", "addobjto3D_act", "removeobj3D_act", "exportmeshdata", @@ -185,11 +185,67 @@ def test_export_formats_have_unique_attr_names(): assert "export3D_act" not in _names(walked) # the old shared name is gone -def test_collada_label_has_no_dependency_note(): - walked = _obj_menu() - dae = [t for n, t, _k in walked if n == "export3D_dae_act"] +def test_collada_label_helper_is_pure(): + """The label flags a missing dependency but never crams the old note in.""" + from PyReconstruct.modules.gui.main.context_menu_list import collada_menu_label + assert collada_menu_label(True) == "Collada (.dae)" + assert collada_menu_label(False) == "Collada (.dae) (not installed)" + # the pre-fix dependency note is gone from both branches + assert "requires" not in collada_menu_label(True).lower() + assert "requires" not in collada_menu_label(False).lower() + + +def test_collada_label_reflects_availability(monkeypatch): + """The built menu label tracks live pycollada availability.""" + import PyReconstruct.modules.backend.volume.export_volumes as ev + + monkeypatch.setattr(ev, "collada_available", lambda: True) + dae = [t for n, t, _k in _obj_menu() if n == "export3D_dae_act"] assert dae == ["Collada (.dae)"] - assert all("requires collada" not in t for _n, t, _k in walked) + + monkeypatch.setattr(ev, "collada_available", lambda: False) + dae = [t for n, t, _k in _obj_menu() if n == "export3D_dae_act"] + assert dae == ["Collada (.dae) (not installed)"] + + +def test_collada_menu_item_disabled_only_when_absent(qapp, monkeypatch): + """disable_unavailable_export_formats greys out .dae iff pycollada is + missing, and never touches the item when it IS present.""" + from PySide6.QtGui import QAction + import PyReconstruct.modules.backend.volume.export_volumes as ev + from PyReconstruct.modules.gui.main.context_menu_list import ( + disable_unavailable_export_formats, + ) + + # absent -> disabled + monkeypatch.setattr(ev, "collada_available", lambda: False) + w = types.SimpleNamespace(export3D_dae_act=QAction("Collada (.dae) (not installed)")) + disable_unavailable_export_formats(w) + assert w.export3D_dae_act.isEnabled() is False + + # present -> left enabled (must not break when Collada IS available) + monkeypatch.setattr(ev, "collada_available", lambda: True) + w = types.SimpleNamespace(export3D_dae_act=QAction("Collada (.dae)")) + disable_unavailable_export_formats(w) + assert w.export3D_dae_act.isEnabled() is True + + # no export action on the widget -> harmless no-op + disable_unavailable_export_formats(types.SimpleNamespace()) + + +def test_smooth_action_names_do_not_shadow_across_menus(): + """The object 'Smooth traces' and the field-trace 'Smooth traces' are + populated onto the same widget, so they must carry DISTINCT attr_names + (else one silently shadows the other, as export3D_act once did).""" + from PyReconstruct.modules.gui.main.context_menu_list import ( + get_context_menu_list_trace, + ) + obj_names = _names(_obj_menu()) + trace_names = _names(list(_walk(get_context_menu_list_trace(_Anything())))) + assert "smoothobj_act" in obj_names + assert "smoothtraces_act" in trace_names + assert "smoothobj_act" not in trace_names + assert "smoothtraces_act" not in obj_names def test_export_submenu_retitled():