diff --git a/src/surface_morphometrics_gui/experiment_manager.py b/src/surface_morphometrics_gui/experiment_manager.py index 5da6312..5d33b9a 100644 --- a/src/surface_morphometrics_gui/experiment_manager.py +++ b/src/surface_morphometrics_gui/experiment_manager.py @@ -422,6 +422,23 @@ def _on_experiment_selected(self): # User is typing a new name — just update button text self.submit_button.setText('Start New Experiment') + def _apply_config_to_ui(self, existing_config, config_path=None): + """Push a loaded experiment config into the manager widgets.""" + if get_seg_dir(existing_config): + self.data_dir.value = get_seg_dir(existing_config) + self.config_template.changed.disconnect(self._handle_config_template_selection) + try: + if 'config_template' in existing_config: + self.config_template.value = existing_config['config_template'] + elif config_path is not None: + self.config_template.value = str(config_path) + finally: + self.config_template.changed.connect(self._handle_config_template_selection) + if 'cores' in existing_config: + self.cores_input.setValue(existing_config['cores']) + if 'segmentation_values' in existing_config: + self.segmentation_container._set_values(existing_config['segmentation_values']) + def _load_existing_experiment_config(self): """Load configuration from an existing experiment""" @@ -457,34 +474,12 @@ def _load_existing_experiment_config(self): # the flat exp_dir for a raw CLI project). self.current_config['work_dir'] = cli_work_dir(resolve_work_dir(exp_dir)) - # Block config_template.changed while restoring UI so that - # _handle_config_template_selection does not re-load the - # original template file and overwrite current_config / data_dir. - self.config_template.changed.disconnect(self._handle_config_template_selection) - try: - # Update UI with loaded config values - if get_seg_dir(existing_config): - self.data_dir.value = get_seg_dir(existing_config) - # Load the original config template if available - if 'config_template' in existing_config: - self.config_template.value = existing_config['config_template'] - else: - self.config_template.value = str(config_path) - finally: - self.config_template.changed.connect(self._handle_config_template_selection) - # Set cores if available - if 'cores' in existing_config: - - self.cores_input.setValue(existing_config['cores']) - # Load segmentation values if available - if 'segmentation_values' in existing_config: - - self.segmentation_container._set_values(existing_config['segmentation_values']) + self._apply_config_to_ui(existing_config, config_path=config_path) # Emit signal that config was loaded - this will update job tabs self.config_loaded.emit() except Exception as e: - pass + print(f'[Resume] Failed to load experiment config: {e}') def _check_start_button_state(self): """Enable start button only when all required fields are filled""" @@ -690,6 +685,8 @@ def _resume_experiment(self): # so the config the tabs read matches where the files really are. self.current_config['work_dir'] = cli_work_dir(resolve_work_dir(exp_dir)) + self._apply_config_to_ui(self.current_config, config_path=config_path) + # Emit signal that config was loaded - this will update job tabs self.config_loaded.emit() @@ -865,6 +862,8 @@ def _import_cli_project(self): idx = self.experiment_name.findText(plan.exp_name) if idx >= 0: self.experiment_name.setCurrentIndex(idx) + # Ensure job tabs see the adopted config even if the index didn't change. + self._load_existing_experiment_config() if result.failed_moves: detail = "\n".join(f" {s.name}: {msg}" for s, msg in result.failed_moves[:5]) diff --git a/src/surface_morphometrics_gui/jobs/refinement_tab.py b/src/surface_morphometrics_gui/jobs/refinement_tab.py index dfccb3e..db5c833 100644 --- a/src/surface_morphometrics_gui/jobs/refinement_tab.py +++ b/src/surface_morphometrics_gui/jobs/refinement_tab.py @@ -1,13 +1,16 @@ import copy import os +import re import subprocess import threading +from collections import OrderedDict from pathlib import Path from magicgui import widgets -from qtpy.QtCore import QTimer +from qtpy.QtCore import Qt, QTimer from ruamel.yaml import YAML from qtpy.QtWidgets import ( + QComboBox, QLabel, QMessageBox, QPushButton, @@ -23,6 +26,8 @@ REFINE_MESH, ACCEPT_REFINEMENT, resolve_work_dir, + resolve_config_work_dir, + work_dir_search_candidates, cli_work_dir, ) from ..widgets.job_status import JobStatusWidget @@ -38,6 +43,13 @@ '*_profile_evolution.png', ] +# Above this many surfaces, previewing one napari layer per surface is too heavy, +# so the tab switches to single-surface mode: a dropdown picks one basename and +# only that surface's current iteration is shown. +PREVIEW_ALL_THRESHOLD = 8 +# Cap on cached parsed meshes so scrubbing back is instant without unbounded RAM. +PREVIEW_CACHE_MAX = 32 + class RefinementWidget(QWidget): """Optional density-guided mesh refinement tab. @@ -59,9 +71,24 @@ class RefinementWidget(QWidget): both before running. """ - def __init__(self, experiment_manager): + def __init__(self, experiment_manager, mesh_viewer=None): super().__init__() self.experiment_manager = experiment_manager + # Optional MeshViewer used to preview refined iterations in napari before + # accepting one. None in headless/test paths — preview is then disabled. + self.mesh_viewer = mesh_viewer + # Lazy preview state. Rather than loading every iteration of every + # surface up front, we scan the work dir for paths only, then load the + # current spinbox iteration on demand and cache parsed meshes. + # _preview_catalog: basename -> {iter_n: Path} (filesystem scan) + # _preview_mesh_cache: (basename, iter_n) -> mesh_tuple (LRU-bounded) + # _preview_layers: basename -> napari layer (<=1 per surface, + # or exactly one total in large single-surface mode) + # _preview_active_basename: the surface shown in large mode (else None) + self._preview_catalog = {} + self._preview_mesh_cache = OrderedDict() + self._preview_layers = {} + self._preview_active_basename = None self.is_running = False main_layout = QVBoxLayout() @@ -145,16 +172,58 @@ def __init__(self, experiment_manager): # --- Accept an iteration (destructive: promotes one, removes the rest) --- inner_layout.addWidget(QLabel("Accept Iteration")) inner_layout.addWidget(QLabel( - "Promote one iteration to be the working surface (originals are backed\n" - "up). Inspect *_refinement_convergence.png first to pick the best one.")) - accept = widgets.Container(layout='vertical', labels=True) - accept.native.layout().setSpacing(5) - accept.native.layout().setContentsMargins(3, 3, 3, 3) - self.accept_step_input = widgets.SpinBox( - value=1, min=1, max=50, label='Iteration to Accept') - accept.extend([self.accept_step_input]) - inner_layout.addWidget(accept.native) - self.accept_btn = QPushButton('Accept Iteration') + "Promote one iteration per surface (one row per tomogram × membrane;\n" + "originals are backed up). Inspect *_refinement_convergence.png first;\n" + "each surface can converge at a different iteration.")) + # One step spinbox per surface basename, rebuilt from *_refined_iter* files. + self.accept_container = widgets.Container(layout='vertical', labels=True) + self.accept_container.native.layout().setSpacing(5) + self.accept_container.native.layout().setContentsMargins(3, 3, 3, 3) + accept_scroll = QScrollArea() + accept_scroll.setWidgetResizable(True) + accept_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + accept_scroll.setFrameShape(QScrollArea.NoFrame) + accept_scroll.setWidget(self.accept_container.native) + accept_scroll.setMinimumHeight(80) + accept_scroll.setMaximumHeight(280) + inner_layout.addWidget(accept_scroll) + self._surface_steps = {} + + self.refresh_btn = QPushButton('Refresh Surfaces') + self.refresh_btn.clicked.connect(self._refresh_accept_components) + inner_layout.addWidget(self.refresh_btn) + + # Preview the refined iterations in napari before the destructive accept. + # The per-component spinbox above is the scrubber: the iteration it shows + # is the one Accept promotes. Only available when a MeshViewer was wired in. + if self.mesh_viewer is not None: + inner_layout.addWidget(QLabel( + "Preview loads each surface's current iteration as a napari surface;\n" + "scrub a spinbox — the shown iteration is the one Accept promotes for\n" + "that surface. With many surfaces, pick one below to preview at a time.")) + # Large-mode surface picker: only one layer is shown at a time when + # there are more than PREVIEW_ALL_THRESHOLD surfaces. Hidden otherwise. + self.preview_combo_label = QLabel("Preview surface:") + self.preview_combo_label.setVisible(False) + inner_layout.addWidget(self.preview_combo_label) + self.preview_surface_combo = QComboBox() + self.preview_surface_combo.setVisible(False) + self.preview_surface_combo.currentTextChanged.connect( + self._on_preview_surface_changed) + inner_layout.addWidget(self.preview_surface_combo) + self.preview_btn = QPushButton('Preview Iterations') + self.preview_btn.clicked.connect(self._preview_iterations) + inner_layout.addWidget(self.preview_btn) + self.clear_preview_btn = QPushButton('Clear Preview') + self.clear_preview_btn.clicked.connect(self._clear_preview) + inner_layout.addWidget(self.clear_preview_btn) + else: + self.preview_btn = None + self.clear_preview_btn = None + self.preview_surface_combo = None + self.preview_combo_label = None + + self.accept_btn = QPushButton('Accept Iterations') self.accept_btn.clicked.connect(self._accept_iteration) inner_layout.addWidget(self.accept_btn) @@ -191,16 +260,97 @@ def _on_config_loaded(self): self.laplacian_input.value = refine.get('laplacian_iterations', 5) self.laplacian_lambda_input.value = refine.get('laplacian_lambda', 0.5) self.lowpass_input.value = refine.get('lowpass_sigma', 0) + self._refresh_accept_components() except Exception as e: print(f"[RefinementWidget] Error in _on_config_loaded: {e}") + def _resolve_exp_dir(self): + """Experiment directory from the manager's work-dir field and name. + + The work-dir field is usually the parent of experiment folders, but + users sometimes point it directly at an experiment directory. + """ + exp_name = self.experiment_manager.experiment_name.currentText().strip() + parent = Path(str(self.experiment_manager.work_dir.value or '')) + if not exp_name: + raise ValueError("No experiment selected") + if not parent: + raise ValueError("Work directory not set") + + nested = parent / exp_name + if nested.is_dir() and ( + (nested / 'config.yml').exists() or list(nested.glob('*_config.yml')) + ): + return nested + if (parent / 'config.yml').exists() or list(parent.glob('*_config.yml')): + return parent + if parent.name == exp_name and parent.is_dir(): + return parent + return nested + def _config_path(self): - exp_name = self.experiment_manager.experiment_name.currentText() - exp_dir = Path(self.experiment_manager.work_dir.value) / exp_name + exp_name = self.experiment_manager.experiment_name.currentText().strip() + exp_dir = self._resolve_exp_dir() preferred = exp_dir / f"{exp_name}_config.yml" fallback = exp_dir / 'config.yml' return (preferred if preferred.exists() else fallback), exp_dir + def _iter_work_dir_candidates(self): + """Yield directories that may contain refinement iteration surfaces.""" + config = self.experiment_manager.current_config or {} + try: + exp_dir = self._resolve_exp_dir() + except Exception as e: + print(f"[RefinementWidget] Could not resolve experiment dir: {e}") + exp_dir = None + + if exp_dir is not None: + for d in work_dir_search_candidates(config, exp_dir): + yield d + + # work_dir field may already be the experiment folder (not its parent). + raw = self.experiment_manager.work_dir.value + exp_name = self.experiment_manager.experiment_name.currentText().strip() + if raw and exp_name: + raw_p = Path(str(raw)) + if raw_p.is_dir() and raw_p.name == exp_name: + for d in work_dir_search_candidates(config, raw_p): + yield d + + def _resolve_work_dir(self): + """Best directory for refinement outputs (first candidate with iter files).""" + _found, primary = self._discover_refined_surfaces_all() + if primary is not None: + return primary + for d in self._iter_work_dir_candidates(): + if d.is_dir(): + return d + return None + + def _discover_refined_surfaces_all(self): + """Scan every plausible output dir; merge discoveries across layouts.""" + merged = {} + primary = None + searched = [] + for d in self._iter_work_dir_candidates(): + key = str(d.resolve()) if d.exists() else str(d) + if key in searched: + continue + searched.append(key) + if not d.is_dir(): + print(f"[RefinementWidget] Skip (not a dir): {d}") + continue + part = self._discover_refined_surfaces(d) + n_files = sum(len(v) for v in part.values()) + print(f"[RefinementWidget] Scan {d}: {n_files} refined iteration(s)") + if part and primary is None: + primary = d + for basename, iters in part.items(): + merged.setdefault(basename, set()).update(iters) + if not merged: + print(f"[RefinementWidget] No refined iterations under: {', '.join(searched) or '(none)'}") + return ({b: sorted(v) for b, v in sorted(merged.items())}, primary) + def _radius_hit(self): config = self.experiment_manager.current_config or {} return config.get('curvature_measurements', {}).get('radius_hit', 9) @@ -270,7 +420,11 @@ def _run_refinement(self): return config_path, exp_dir = self._config_path() - work_dir = resolve_work_dir(exp_dir) + work_dir = self._resolve_work_dir() + if work_dir is None: + QMessageBox.warning(self, "No Work Directory", + "Could not resolve the experiment output directory.") + return radius_hit = self._radius_hit() if not list(work_dir.glob(f'*.AVV_rh{radius_hit}.gt')): QMessageBox.warning( @@ -311,6 +465,8 @@ def _run_refinement(self): self.is_running = True self.submit_btn.enabled = False self.accept_btn.setEnabled(False) + if self.preview_btn is not None: + self.preview_btn.setEnabled(False) self.status.update_status('Starting...') self.status.update_progress(0) threading.Thread(target=self._run_refinement_worker, args=(job_data,), daemon=True).start() @@ -364,6 +520,7 @@ def _run_refinement_worker(self, job_data): self.status.update_status( 'Refinement complete. Inspect *_refinement_convergence.png, then accept an iteration.') print("===== Mesh refinement complete. =====") + QTimer.singleShot(0, self._refresh_accept_components) except Exception as e: self.status.update_status(f'Error: {e}') @@ -375,6 +532,275 @@ def _run_refinement_worker(self, job_data): # ----- Accept an iteration ----- + def _discover_refined_surfaces(self, work_dir): + """Map surface basename -> sorted list of available iteration numbers. + + Refined surfaces are named ``{basename}_refined_iter{N}.surface.vtp`` where + ``basename`` is typically ``{tomogram}_{component}`` (e.g. ``TE1_IMM``). + Each basename gets its own spinbox so tomograms can accept different steps. + """ + pat = re.compile( + r'^(?P.+)_refined_iter(?P\d+)\.surface\.vtp$', re.IGNORECASE) + surfaces = {} + for p in work_dir.iterdir(): + if not p.is_file(): + continue + m = pat.match(p.name) + if not m: + continue + surfaces.setdefault(m.group('base'), set()).add(int(m.group('n'))) + return {b: sorted(v) for b, v in sorted(surfaces.items())} + + @staticmethod + def _basename_filters(basename): + """Split ``{tomogram}_{component}`` for ``accept_refinement`` CLI filters.""" + component = basename.rsplit('_', 1)[-1] + tomogram = basename[:-(len(component) + 1)] + return tomogram, component + + def _refresh_accept_components(self): + """Rebuild the per-surface step spinboxes from the refined files on disk.""" + # Preserve current selections across a refresh so a rescan doesn't reset them. + prev = {b: sb.value for b, sb in self._surface_steps.items()} + # The file set is about to change (refine/accept just ran); drop stale + # preview layers so they can't outlive the iterations they represent. + self._clear_preview() + self.accept_container.clear() + self._surface_steps = {} + self._resolved_work_dir = None + + found, self._resolved_work_dir = self._discover_refined_surfaces_all() + if not found: + self.accept_container.append(widgets.Label( + value='No refined iterations found. Run refinement, then Refresh.')) + self.accept_btn.setEnabled(False) + if self.preview_btn is not None: + self.preview_btn.setEnabled(False) + self._populate_preview_combo() + return + + for basename, iters in found.items(): + lo, hi = iters[0], iters[-1] + # Default to the final iteration (usually the converged one); keep the + # user's prior pick if it's still in range. + default = min(max(prev.get(basename, hi), lo), hi) + sb = widgets.SpinBox(value=default, min=lo, max=hi, + label=f'{basename} (iters {lo}-{hi})') + # Scrubber: when a preview is loaded, changing the step shows that + # iteration's layer and hides the rest for this surface. + sb.changed.connect(lambda _=None, b=basename: self._on_step_changed(b)) + self.accept_container.append(sb) + self._surface_steps[basename] = sb + self.accept_btn.setEnabled(not self.is_running) + if self.preview_btn is not None: + self.preview_btn.setEnabled(not self.is_running) + self._populate_preview_combo() + + # ----- Preview iterations in napari ----- + + def _populate_preview_combo(self): + """Refill the large-mode surface picker and show it only when needed.""" + if self.preview_surface_combo is None: + return + large = len(self._surface_steps) > PREVIEW_ALL_THRESHOLD + # Block signals so refilling doesn't fire a spurious surface swap. + self.preview_surface_combo.blockSignals(True) + self.preview_surface_combo.clear() + self.preview_surface_combo.addItems(list(self._surface_steps)) + self.preview_surface_combo.blockSignals(False) + self.preview_surface_combo.setVisible(large) + if self.preview_combo_label is not None: + self.preview_combo_label.setVisible(large) + + def _build_preview_catalog(self, work_dir): + """Scan the work dir for each surface's iteration files (paths only). + + Returns ``{basename: {iter_n: Path}}`` including iter0 (the original + ``{basename}.surface.vtp``) when present. No VTK parsing happens here. + """ + pat = re.compile(r'^(?P.+)_refined_iter(?P\d+)\.surface\.vtp$') + catalog = {} + for basename in self._surface_steps: + iters = {} + for p in sorted(work_dir.glob(f'{basename}_refined_iter*.surface.vtp')): + m = pat.match(p.name) + if not m or m.group('base') != basename: + continue + iters[int(m.group('n'))] = p + orig = work_dir / f'{basename}.surface.vtp' + if orig.exists(): + iters[0] = orig + if iters: + catalog[basename] = iters + return catalog + + def _preview_iterations(self): + """Preview the current iteration of each surface as a napari layer. + + Small datasets (<= PREVIEW_ALL_THRESHOLD surfaces) get one layer per + surface, each showing its spinbox iteration. Larger datasets show a + single surface at a time, chosen by the picker combo. Only the shown + iterations are parsed; scrubbing swaps layer data from the mesh cache. + """ + if self.mesh_viewer is None or not self._surface_steps: + return + + self._clear_preview() + + work_dir = self._resolved_work_dir or self._resolve_work_dir() + if work_dir is None: + QMessageBox.warning(self, "Preview Failed", "Could not resolve work dir.") + return + + self._preview_catalog = self._build_preview_catalog(work_dir) + if not self._preview_catalog: + QMessageBox.information( + self, "Nothing to Preview", + "No refined iteration surfaces were found for the current surfaces.") + return + + if len(self._surface_steps) > PREVIEW_ALL_THRESHOLD: + # Single-surface mode: load only the picked (or first) surface. + basename = None + if self.preview_surface_combo is not None: + basename = self.preview_surface_combo.currentText() or None + if basename not in self._preview_catalog: + basename = next(iter(self._preview_catalog)) + self._preview_active_basename = basename + self._ensure_preview_layer(basename) + else: + self._preview_active_basename = None + for basename in self._surface_steps: + if basename in self._preview_catalog: + self._ensure_preview_layer(basename) + + self.mesh_viewer.viewer.reset_view() + + def _current_iter(self, basename, iters): + """The spinbox iteration for a surface, clamped to available files.""" + sb = self._surface_steps.get(basename) + n = sb.value if sb is not None else max(iters) + if n in iters: + return n + return min(iters, key=lambda k: abs(k - n)) + + def _get_preview_mesh(self, basename, n): + """Return the parsed mesh tuple for ``(basename, n)``, using the cache. + + On a miss, reads the file via the viewer's ``read_mesh_tuple`` and stores + it, evicting the oldest entry once the cache exceeds PREVIEW_CACHE_MAX. + """ + if self.mesh_viewer is None: + return None + key = (basename, n) + cached = self._preview_mesh_cache.get(key) + if cached is not None: + self._preview_mesh_cache.move_to_end(key) + return cached + path = self._preview_catalog.get(basename, {}).get(n) + if path is None: + return None + mesh_tuple = self.mesh_viewer.read_mesh_tuple(str(path)) + if mesh_tuple is None: + return None + self._preview_mesh_cache[key] = mesh_tuple + while len(self._preview_mesh_cache) > PREVIEW_CACHE_MAX: + self._preview_mesh_cache.popitem(last=False) + return mesh_tuple + + def _ensure_preview_layer(self, basename): + """Create the surface's preview layer if missing, else update its data.""" + if self.mesh_viewer is None: + return + iters = self._preview_catalog.get(basename) + if not iters: + return + n = self._current_iter(basename, iters) + layer = self._preview_layers.get(basename) + if layer is None: + path = iters[n] + name = f'refine-preview:{basename}:iter{n}' + layer = self._load_preview_layer(str(path), name) + if layer is not None: + self._preview_layers[basename] = layer + else: + self._apply_iter_to_layer(basename, layer, n) + + def _load_preview_layer(self, path, name): + """Load a .vtp as a flat gray preview surface and return the layer. + + Loaded flat (no per-vertex scalar coloring) so previews show shape, not + the noisy scalar arrays some refined surfaces carry. ``reset_view`` is + skipped so preview resets the camera once, after all layers are loaded.""" + try: + return self.mesh_viewer._load_mesh_file( + path, name=name, flat=True, reset_view=False) + except Exception as e: + print(f"[RefinementWidget] Failed to preview {path}: {e}") + return None + + def _apply_iter_to_layer(self, basename, layer, n): + """Swap an existing preview layer's geometry to iteration ``n``.""" + mesh_tuple = self._get_preview_mesh(basename, n) + if mesh_tuple is None: + return + try: + layer.data = mesh_tuple + layer.name = f'refine-preview:{basename}:iter{n}' + except Exception as e: + print(f"[RefinementWidget] Failed to update preview {basename}: {e}") + + def _on_preview_surface_changed(self, basename): + """Combo callback: swap the single previewed surface in large mode.""" + if not basename or not self._preview_catalog: + return + if len(self._surface_steps) <= PREVIEW_ALL_THRESHOLD: + return + self._set_active_preview_surface(basename) + + def _set_active_preview_surface(self, basename): + """Show ``basename`` as the sole preview layer (single-surface mode).""" + if self.mesh_viewer is None or basename not in self._preview_catalog: + return + self._remove_preview_layers() + self._preview_active_basename = basename + self._ensure_preview_layer(basename) + self.mesh_viewer.viewer.reset_view() + + def _on_step_changed(self, basename): + """Update the surface's layer data to its newly selected iteration.""" + if self.mesh_viewer is None or not self._preview_catalog: + return + # In single-surface mode only the active surface has a layer. + if (self._preview_active_basename is not None + and basename != self._preview_active_basename): + return + layer = self._preview_layers.get(basename) + iters = self._preview_catalog.get(basename) + if layer is None or not iters: + return + n = self._current_iter(basename, iters) + self._apply_iter_to_layer(basename, layer, n) + + def _remove_preview_layers(self): + """Remove the napari layers we created, keeping catalog/cache intact.""" + if self.mesh_viewer is not None: + layers = self.mesh_viewer.viewer.layers + for layer in self._preview_layers.values(): + try: + if layer in layers: + layers.remove(layer) + except Exception: + pass + self._preview_layers = {} + + def _clear_preview(self): + """Remove preview layers and drop the catalog/cache/active selection.""" + self._remove_preview_layers() + self._preview_catalog = {} + self._preview_mesh_cache.clear() + self._preview_active_basename = None + def _accept_iteration(self): if self.is_running: return @@ -388,21 +814,33 @@ def _accept_iteration(self): QMessageBox.warning(self, "No Config", f"Config not found: {config_path}") return - step = self.accept_step_input.value - work_dir = resolve_work_dir(exp_dir) - if not list(work_dir.glob(f'*_refined_iter{step}.surface.vtp')): + if not self._surface_steps: + QMessageBox.warning(self, "No Surfaces", + "No refined surfaces found. Run refinement, then Refresh.") + return + + work_dir = self._resolved_work_dir or self._resolve_work_dir() + if work_dir is None: + QMessageBox.warning(self, "No Work Directory", + "Could not resolve the experiment output directory.") + return + choices = {b: sb.value for b, sb in self._surface_steps.items()} + # Validate each chosen iteration exists for its surface before touching files. + missing = [f"{b}: iteration {s}" for b, s in choices.items() + if not (work_dir / f'{b}_refined_iter{s}.surface.vtp').exists()] + if missing: QMessageBox.warning( self, "No Such Iteration", - f"No refined surfaces for iteration {step} found in {work_dir}.\n" - "Run refinement first, or pick an iteration that was produced.") + "These selections have no refined surface:\n " + "\n ".join(missing)) return + summary = "\n".join(f" {b}: iteration {s}" for b, s in choices.items()) confirm = QMessageBox.question( - self, "Accept Iteration", - f"Promote iteration {step} to be the working surface?\n\n" - "The original surfaces are backed up (*.orig.bak), but the other " - "refinement iterations and intermediates will be removed. This cannot " - "be undone from the GUI.", + self, "Accept Iterations", + f"Promote these iterations to be the working surfaces?\n\n{summary}\n\n" + "Originals are backed up (*.orig.bak), but the other refinement " + "iterations and intermediates will be removed. This cannot be undone " + "from the GUI.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if confirm != QMessageBox.Yes: return @@ -412,33 +850,46 @@ def _accept_iteration(self): QMessageBox.critical(self, "morphometrics CLI not found", CLI_MISSING_MESSAGE) return - job_data = {'runner': runner, 'config_path': config_path, 'step': step} + job_data = {'runner': runner, 'config_path': config_path, 'choices': choices} self.is_running = True self.submit_btn.enabled = False self.accept_btn.setEnabled(False) - self.status.update_status(f'Accepting iteration {step}...') + if self.preview_btn is not None: + self.preview_btn.setEnabled(False) + self.status.update_status('Accepting iterations...') threading.Thread(target=self._accept_worker, args=(job_data,), daemon=True).start() def _accept_worker(self, job_data): try: runner = job_data['runner'] config_path = job_data['config_path'] - step = job_data['step'] + choices = job_data['choices'] work_dir = resolve_work_dir(Path(config_path).parent).resolve() - cmd = runner + [ACCEPT_REFINEMENT, str(config_path), str(step)] - print(f"--- Accepting refinement: {' '.join(map(str, cmd))} ---") - try: - subprocess.run(cmd, cwd=work_dir, check=True, text=True) - except subprocess.CalledProcessError: - self.status.update_status('Error: accept_refinement failed. See terminal.') - print("[ERROR] accept_refinement failed. Check the terminal output.") - return - - self.status.update_status( - f'Accepted iteration {step}. If it was a lightweight (xcorr) iteration, ' - 're-run Curvature before distances.') - print(f"===== Accepted refinement iteration {step}. =====") + # One accept_refinement call per surface basename, scoped with + # --tomogram and --component so each tomogram×membrane can pick a + # different iteration without affecting the others. + failed = [] + for basename, step in choices.items(): + tomogram, component = self._basename_filters(basename) + cmd = runner + [ACCEPT_REFINEMENT, str(config_path), str(step), + '--tomogram', tomogram, '--component', component] + print(f"--- Accepting refinement: {' '.join(map(str, cmd))} ---") + try: + subprocess.run(cmd, cwd=work_dir, check=True, text=True) + except subprocess.CalledProcessError: + failed.append(basename) + print(f"[ERROR] accept_refinement failed for {basename}.") + + if failed: + self.status.update_status( + f"Error accepting: {', '.join(failed)}. See terminal.") + else: + accepted = ", ".join(f"{b}={s}" for b, s in choices.items()) + self.status.update_status( + f'Accepted {accepted}. If any was a lightweight (xcorr) iteration, ' + 're-run Curvature before distances.') + print(f"===== Accepted refinement: {accepted}. =====") except Exception as e: self.status.update_status(f'Error: {e}') @@ -446,9 +897,12 @@ def _accept_worker(self, job_data): import traceback traceback.print_exc() finally: + QTimer.singleShot(0, self._refresh_accept_components) QTimer.singleShot(0, self._job_cleanup) def _job_cleanup(self): self.submit_btn.enabled = True - self.accept_btn.setEnabled(True) + self.accept_btn.setEnabled(bool(self._surface_steps)) + if self.preview_btn is not None: + self.preview_btn.setEnabled(bool(self._surface_steps)) self.is_running = False diff --git a/src/surface_morphometrics_gui/main.py b/src/surface_morphometrics_gui/main.py index 949bb37..46ea7fc 100644 --- a/src/surface_morphometrics_gui/main.py +++ b/src/surface_morphometrics_gui/main.py @@ -83,22 +83,22 @@ def main(): # Setup responsive layout setup_responsive_layout(viewer) + # Create mesh viewer widget (before RefinementWidget, which reuses it to + # preview refined iterations in napari before accepting one). + mesh_viewer = MeshViewer(viewer) + # Create widgets experiment_manager = ExperimentManager(viewer) mesh_widget = MeshGenerationWidget(experiment_manager) pycurv_widget = PyCurvWidget(experiment_manager=experiment_manager) - refinement_widget = RefinementWidget(experiment_manager) + refinement_widget = RefinementWidget(experiment_manager, mesh_viewer=mesh_viewer) distance_widget = DistanceOrientationWidget(experiment_manager) thickness_widget = ThicknessWidget(experiment_manager) # (Mesh completion connection set after dock widgets are created below) # Create tomoslice plugin tomoslice = TomoslicePlugin(viewer, experiment_manager) - - - # Create mesh viewer widget - mesh_viewer = MeshViewer(viewer) - + # Setup and add dock widgets with proper sizing dw1 = viewer.window.add_dock_widget(experiment_manager, name='Experiment Manager', area='right') diff --git a/src/surface_morphometrics_gui/plugins/mesh_viewer.py b/src/surface_morphometrics_gui/plugins/mesh_viewer.py index 8ce78a6..866d84b 100644 --- a/src/surface_morphometrics_gui/plugins/mesh_viewer.py +++ b/src/surface_morphometrics_gui/plugins/mesh_viewer.py @@ -191,8 +191,12 @@ def _on_load_mesh_clicked(self): self._load_mesh_file(filepath) - def _load_mesh_file(self, filepath): - """Load a mesh file using VTK and add it to napari.""" + def read_mesh_tuple(self, filepath): + """Read a mesh file with VTK and return ``(vertices, faces, values)``. + + Pure I/O: no napari calls, so it can be used off the layer-creation path + (e.g. to swap a preview layer's data). Returns None on failure. + """ ext = os.path.splitext(filepath)[1].lower() reader = None @@ -206,7 +210,7 @@ def _load_mesh_file(self, filepath): reader = vtk.vtkOBJReader() else: print(f"Unsupported file format: {ext}") - return + return None reader.SetFileName(filepath) reader.Update() @@ -214,7 +218,7 @@ def _load_mesh_file(self, filepath): if polydata is None or polydata.GetNumberOfPoints() == 0: print(f"Failed to load mesh from {filepath}") - return + return None # Extract vertices vtk_points = polydata.GetPoints() @@ -224,7 +228,7 @@ def _load_mesh_file(self, filepath): vtk_cells = polydata.GetPolys() if vtk_cells is None or vtk_cells.GetNumberOfCells() == 0: print(f"No polygon data in {filepath}") - return + return None cell_array = numpy_support.vtk_to_numpy(vtk_cells.GetData()) # VTK cell array format: [n_verts, v0, v1, v2, n_verts, v0, v1, v2, ...] @@ -235,20 +239,61 @@ def _load_mesh_file(self, filepath): # Default scalar values (ones so AO can attenuate them) values = np.ones(len(vertices)) - mesh_tuple = (vertices, faces, values) + return (vertices, faces, values) + + def _load_mesh_file(self, filepath, name=None, flat=False, reset_view=True): + """Load a mesh file using VTK and add it to napari. + + flat=True loads the mesh as a matte gray surface for shape comparison: + it skips the per-vertex scalar auto-coloring (marks the layer already + initialized and omits ``source_vtp_path``), so the noisy scalar arrays + some VTPs carry don't paint the surface. Ambient occlusion still applies, + giving depth. Returns the created layer (or None on failure). + + reset_view=False skips the camera reset — callers that load several + layers at once (e.g. preview) can reset the view a single time instead. + """ + mesh_tuple = self.read_mesh_tuple(filepath) + if mesh_tuple is None: + return None + + ext = os.path.splitext(filepath)[1].lower() - name = os.path.splitext(os.path.basename(filepath))[0] - # Only tag VTP files as the source path; other formats (PLY, STL, OBJ) - # cannot be re-read by vtkXMLPolyDataReader and would cause an XML parse - # error at byte 0 when _initialize_vtp_layer tries to load them. - metadata = {'source_vtp_path': filepath} if ext == '.vtp' else {} + if name is None: + name = os.path.splitext(os.path.basename(filepath))[0] + add_kwargs = {'name': name} + if flat: + # Skip scalar auto-coloring (see docstring); show a matte gray surface. + add_kwargs['metadata'] = {'vtp_initialized': True} + add_kwargs['colormap'] = 'gray' + else: + # Only tag VTP files as the source path; other formats (PLY, STL, OBJ) + # cannot be re-read by vtkXMLPolyDataReader and would cause an XML parse + # error at byte 0 when _initialize_vtp_layer tries to load them. + add_kwargs['metadata'] = {'source_vtp_path': filepath} if ext == '.vtp' else {} # Surface layers require 3D display mode; switch automatically. if self.viewer.dims.ndisplay != 3: self.viewer.dims.ndisplay = 3 - self.viewer.add_surface(mesh_tuple, name=name, metadata=metadata) - self.viewer.reset_view() + layer = self.viewer.add_surface(mesh_tuple, **add_kwargs) + if reset_view: + self.viewer.reset_view() + return layer + + def update_surface_layer(self, layer, filepath, flat=True): + """Swap an existing surface layer's geometry to another mesh file. + + Reads ``filepath`` via :meth:`read_mesh_tuple` and assigns the resulting + ``(vertices, faces, values)`` to ``layer.data`` so the on-screen surface + updates without creating a new layer (used to scrub preview iterations). + Returns the mesh tuple on success, or None on failure. + """ + mesh_tuple = self.read_mesh_tuple(filepath) + if mesh_tuple is None: + return None + layer.data = mesh_tuple + return mesh_tuple def _is_vtp_surface_layer(self, layer): """Checks if a layer is a Surface derived from a VTP file.""" diff --git a/src/surface_morphometrics_gui/utils/script_resolver.py b/src/surface_morphometrics_gui/utils/script_resolver.py index 9f459a3..ec670e5 100644 --- a/src/surface_morphometrics_gui/utils/script_resolver.py +++ b/src/surface_morphometrics_gui/utils/script_resolver.py @@ -46,7 +46,8 @@ def cli_work_dir(results_directory): # Filenames the pipeline produces at any stage — used to detect whether a # directory already holds pipeline outputs. _OUTPUT_MARKERS = ("*.surface.vtp", "*.AVV_rh*.gt", "*.AVV_rh*.vtp", - "*.AVV_rh*.csv", "*.ply", "*.xyz") + "*.AVV_rh*.csv", "*.ply", "*.xyz", + "*_refined_iter*.surface.vtp") def _has_pipeline_outputs(directory): @@ -57,6 +58,48 @@ def _has_pipeline_outputs(directory): for pattern in _OUTPUT_MARKERS) +def resolve_config_work_dir(work_dir_value, exp_dir): + """Normalize ``work_dir`` from a config, resolving relative paths. + + Config files may store a relative ``work_dir`` (e.g. ``results/``); interpret + that relative to the experiment directory, not the process cwd. + """ + if not work_dir_value: + return None + p = Path(str(work_dir_value).strip()) + if not p.is_absolute(): + p = (Path(exp_dir) / p).resolve() + return p + + +def work_dir_search_candidates(config, exp_dir): + """Ordered unique directories that may hold pipeline outputs for *exp_dir*.""" + exp_dir = Path(exp_dir) + ordered = [] + cfg_work = (config or {}).get('work_dir') + if cfg_work: + resolved = resolve_config_work_dir(cfg_work, exp_dir) + if resolved is not None: + ordered.append(resolved) + ordered.extend([ + resolve_work_dir(exp_dir), + exp_dir, + exp_dir / 'results', + ]) + seen = set() + out = [] + for d in ordered: + if d is None: + continue + p = Path(d) + key = str(p.resolve()) if p.exists() else str(p) + if key in seen: + continue + seen.add(key) + out.append(p) + return out + + def resolve_work_dir(exp_dir): """The directory the pipeline reads from and writes to for an experiment. diff --git a/tests/test_experiment_manager.py b/tests/test_experiment_manager.py index af928fe..d2fee04 100644 --- a/tests/test_experiment_manager.py +++ b/tests/test_experiment_manager.py @@ -487,3 +487,10 @@ def test_non_output_files_do_not_count_as_flat(self, tmp_path): exp.mkdir() (exp / "exp_config.yml").write_text("x") # config alone is not an output assert resolve_work_dir(exp) == exp / "results" + + def test_resolve_config_work_dir_relative(self, tmp_path): + from utils.script_resolver import resolve_config_work_dir + exp = tmp_path / "exp" + exp.mkdir() + out = resolve_config_work_dir("results/", exp) + assert out == (exp / "results").resolve() diff --git a/tests/test_refinement_tab.py b/tests/test_refinement_tab.py index 47522ff..eedcfc8 100644 --- a/tests/test_refinement_tab.py +++ b/tests/test_refinement_tab.py @@ -1,7 +1,7 @@ """Tests for RefinementWidget.""" import pytest from pathlib import Path -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from ruamel.yaml import YAML @@ -65,3 +65,351 @@ def test_update_config_writes_refinement_keys(self, qapp, mock_experiment_manage assert cfg["tomo_dir"].endswith("/") # work_dir must end in a separator for the CLI's string concatenation. assert cfg["work_dir"].endswith("/") + + def _setup_refined(self, w, mock_experiment_manager, tmp_path, files): + """Create refined_iter surfaces under the experiment's work dir.""" + mock_experiment_manager.work_dir.value = str(tmp_path) + mock_experiment_manager.experiment_name.currentText.return_value = "exp" + from utils.script_resolver import resolve_work_dir + work_dir = resolve_work_dir(tmp_path / "exp") + work_dir.mkdir(parents=True, exist_ok=True) + for name in files: + (work_dir / name).write_text("") + return work_dir + + def test_refresh_discovers_per_surface_iterations(self, qapp, mock_experiment_manager, tmp_path): + w = self._make_widget(qapp, mock_experiment_manager) + self._setup_refined(w, mock_experiment_manager, tmp_path, [ + "tomo1_labels_IMM_refined_iter1.surface.vtp", + "tomo1_labels_IMM_refined_iter6.surface.vtp", + "tomo1_labels_OMM_refined_iter1.surface.vtp", + "tomo1_labels_OMM_refined_iter4.surface.vtp", + "tomo1_labels_OMM_refined_iter5.surface.vtp", + ]) + + w._refresh_accept_components() + + assert set(w._surface_steps) == {"tomo1_labels_IMM", "tomo1_labels_OMM"} + imm, omm = w._surface_steps["tomo1_labels_IMM"], w._surface_steps["tomo1_labels_OMM"] + assert (imm.min, imm.max, imm.value) == (1, 6, 6) + assert (omm.min, omm.max, omm.value) == (1, 5, 5) + assert w.accept_btn.isEnabled() + + def test_refresh_discovers_all_tomograms_separately(self, qapp, mock_experiment_manager, tmp_path): + w = self._make_widget(qapp, mock_experiment_manager) + self._setup_refined(w, mock_experiment_manager, tmp_path, [ + "TE1_IMM_refined_iter1.surface.vtp", + "TE1_IMM_refined_iter6.surface.vtp", + "TE2_IMM_refined_iter1.surface.vtp", + "TE2_IMM_refined_iter4.surface.vtp", + ]) + w._refresh_accept_components() + + assert set(w._surface_steps) == {"TE1_IMM", "TE2_IMM"} + assert w._surface_steps["TE1_IMM"].value == 6 + assert w._surface_steps["TE2_IMM"].value == 4 + + def test_refresh_preserves_prior_selection(self, qapp, mock_experiment_manager, tmp_path): + w = self._make_widget(qapp, mock_experiment_manager) + self._setup_refined(w, mock_experiment_manager, tmp_path, [ + "t_IMM_refined_iter1.surface.vtp", + "t_IMM_refined_iter6.surface.vtp", + ]) + w._refresh_accept_components() + w._surface_steps["t_IMM"].value = 3 + w._refresh_accept_components() + assert w._surface_steps["t_IMM"].value == 3 + + def test_refresh_no_files_disables_accept(self, qapp, mock_experiment_manager, tmp_path): + w = self._make_widget(qapp, mock_experiment_manager) + self._setup_refined(w, mock_experiment_manager, tmp_path, []) + w._refresh_accept_components() + assert w._surface_steps == {} + assert not w.accept_btn.isEnabled() + + def test_refresh_uses_saved_config_work_dir(self, qapp, mock_experiment_manager, tmp_path): + """After resume, discovery must honor config work_dir, not re-guess paths.""" + import os + w = self._make_widget(qapp, mock_experiment_manager) + exp_parent = tmp_path / "projects" + exp_parent.mkdir() + exp_dir = exp_parent / "exp" + exp_dir.mkdir() + work_dir = exp_dir / "results" + work_dir.mkdir() + for name in [ + "t_IMM_refined_iter1.surface.vtp", + "t_IMM_refined_iter6.surface.vtp", + ]: + (work_dir / name).write_text("") + (exp_dir / "exp_config.yml").write_text("work_dir: placeholder\n") + + mock_experiment_manager.work_dir.value = str(exp_parent) + mock_experiment_manager.experiment_name.currentText.return_value = "exp" + mock_experiment_manager.current_config = { + "work_dir": str(work_dir) + os.sep, + } + + w._refresh_accept_components() + assert set(w._surface_steps) == {"t_IMM"} + assert w._surface_steps["t_IMM"].value == 6 + + def test_refresh_finds_relative_config_work_dir(self, qapp, mock_experiment_manager, tmp_path): + """Config work_dir like results/ must resolve against exp_dir, not cwd.""" + w = self._make_widget(qapp, mock_experiment_manager) + exp_parent = tmp_path / "projects" + exp_dir = exp_parent / "exp" + work_dir = exp_dir / "results" + work_dir.mkdir(parents=True) + (work_dir / "t_IMM_refined_iter6.surface.vtp").write_text("") + (exp_dir / "exp_config.yml").write_text("work_dir: results/\n") + + mock_experiment_manager.work_dir.value = str(exp_parent) + mock_experiment_manager.experiment_name.currentText.return_value = "exp" + mock_experiment_manager.current_config = {"work_dir": "results/"} + + w._refresh_accept_components() + assert "t_IMM" in w._surface_steps + + def test_refresh_when_work_dir_field_is_experiment_dir(self, qapp, mock_experiment_manager, tmp_path): + """Work-dir field may point at the experiment folder, not its parent.""" + w = self._make_widget(qapp, mock_experiment_manager) + exp_dir = tmp_path / "myexp" + work_dir = exp_dir / "results" + work_dir.mkdir(parents=True) + (work_dir / "TE1_OMM_refined_iter3.surface.vtp").write_text("") + (exp_dir / "config.yml").write_text("work_dir: results/\n") + + mock_experiment_manager.work_dir.value = str(exp_dir) + mock_experiment_manager.experiment_name.currentText.return_value = "myexp" + mock_experiment_manager.current_config = {"work_dir": "results/"} + + w._refresh_accept_components() + assert set(w._surface_steps) == {"TE1_OMM"} + + def test_accept_worker_runs_one_call_per_surface(self, qapp, mock_experiment_manager, tmp_path): + w = self._make_widget(qapp, mock_experiment_manager) + work_dir = self._setup_refined(w, mock_experiment_manager, tmp_path, []) + config_path = work_dir / "exp_config.yml" + config_path.write_text("work_dir: x\n") + + job_data = { + "runner": ["morphometrics"], + "config_path": config_path, + "choices": {"TE1_IMM": 6, "TE1_OMM": 5, "TE2_IMM": 4}, + } + with patch("jobs.refinement_tab.subprocess.run") as run: + w._accept_worker(job_data) + + cmds = [c.args[0] for c in run.call_args_list] + assert len(cmds) == 3 + expected = [ + (["6", "--tomogram", "TE1", "--component", "IMM"]), + (["5", "--tomogram", "TE1", "--component", "OMM"]), + (["4", "--tomogram", "TE2", "--component", "IMM"]), + ] + for cmd, exp_tail in zip(cmds, expected): + assert cmd[-5:] == exp_tail + + +class _FakeLayer: + def __init__(self, path): + self.path = path + self.name = path + self.visible = True + self.data = None + + +class _FakeLayerViewer: + def __init__(self): + self.layers = [] + self.reset_view_called = 0 + + def reset_view(self): + self.reset_view_called += 1 + + +class _FakeMeshViewer: + """Minimal stand-in for MeshViewer used by the lazy preview path. + + ``_load_mesh_file`` appends a surface layer to the viewer (layer creation). + ``read_mesh_tuple`` records disk reads separately so tests can assert the + mesh cache prevents re-reads while scrubbing. + """ + def __init__(self): + self.viewer = _FakeLayerViewer() + self.loaded = [] # _load_mesh_file calls (layer creation) + self.reads = [] # read_mesh_tuple calls (disk parses used for scrubbing) + + def read_mesh_tuple(self, path): + self.reads.append(path) + # Distinct tuple per path so layer.data swaps are observable. + return ("verts", "faces", path) + + def _load_mesh_file(self, path, name=None, flat=False, reset_view=True): + self.loaded.append((path, name, flat, reset_view)) + layer = _FakeLayer(path) + if name is not None: + layer.name = name + # Layer creation carries its own data; not counted as a scrub read. + layer.data = ("loaded", path) + self.viewer.layers.append(layer) + return layer + + def update_surface_layer(self, layer, filepath, flat=True): + mesh_tuple = self.read_mesh_tuple(filepath) + layer.data = mesh_tuple + return mesh_tuple + + +@pytest.mark.gui +class TestRefinementPreview: + def _make_widget(self, mock_experiment_manager, mesh_viewer): + from jobs.refinement_tab import RefinementWidget + mock_experiment_manager.config_loaded = MagicMock() + mock_experiment_manager.current_config = None + return RefinementWidget(mock_experiment_manager, mesh_viewer=mesh_viewer) + + def _setup_refined(self, mock_experiment_manager, tmp_path, files): + mock_experiment_manager.work_dir.value = str(tmp_path) + mock_experiment_manager.experiment_name.currentText.return_value = "exp" + from utils.script_resolver import resolve_work_dir + work_dir = resolve_work_dir(tmp_path / "exp") + work_dir.mkdir(parents=True, exist_ok=True) + for name in files: + (work_dir / name).write_text("") + return work_dir + + def test_preview_loads_only_current_iteration(self, qapp, mock_experiment_manager, tmp_path): + mv = _FakeMeshViewer() + w = self._make_widget(mock_experiment_manager, mv) + self._setup_refined(mock_experiment_manager, tmp_path, [ + "t_IMM.surface.vtp", # iter0 = original + "t_IMM_refined_iter1.surface.vtp", + "t_IMM_refined_iter6.surface.vtp", + "t_OMM.surface.vtp", + "t_OMM_refined_iter1.surface.vtp", + "t_OMM_refined_iter5.surface.vtp", + ]) + w._refresh_accept_components() + w._preview_iterations() + + # One layer per surface, each at its current (default = final) iteration. + # No eager loading of iter0/iter1 layers. + assert set(w._preview_layers) == {"t_IMM", "t_OMM"} + assert w._preview_layers["t_IMM"].name == "refine-preview:t_IMM:iter6" + assert w._preview_layers["t_OMM"].name == "refine-preview:t_OMM:iter5" + assert len(mv.viewer.layers) == 2 + # Only the current iteration was read from disk (via _load_mesh_file). + assert len(mv.loaded) == 2 + # Preview surfaces load flat and skip the per-load camera reset. + assert all(flat for _p, _n, flat, _rv in mv.loaded) + assert all(rv is False for _p, _n, _f, rv in mv.loaded) + # Single camera reset for the whole preview, not one per layer. + assert mv.viewer.reset_view_called == 1 + # Small dataset: the large-mode picker stays hidden. + assert w.preview_surface_combo.isHidden() + + def test_scrub_updates_layer_data_without_new_layer(self, qapp, mock_experiment_manager, tmp_path): + mv = _FakeMeshViewer() + w = self._make_widget(mock_experiment_manager, mv) + self._setup_refined(mock_experiment_manager, tmp_path, [ + "t_IMM_refined_iter1.surface.vtp", + "t_IMM_refined_iter6.surface.vtp", + ]) + w._refresh_accept_components() + w._preview_iterations() + layer = w._preview_layers["t_IMM"] + assert len(mv.viewer.layers) == 1 + + w._surface_steps["t_IMM"].value = 1 # emits changed -> _on_step_changed + + # No new layer created; the same layer's data/name reflects iter1. + assert len(mv.viewer.layers) == 1 + assert w._preview_layers["t_IMM"] is layer + assert layer.name == "refine-preview:t_IMM:iter1" + iter1_path = str(w._preview_catalog["t_IMM"][1]) + assert layer.data == ("verts", "faces", iter1_path) + + def test_scrub_cache_hit_avoids_reread(self, qapp, mock_experiment_manager, tmp_path): + mv = _FakeMeshViewer() + w = self._make_widget(mock_experiment_manager, mv) + self._setup_refined(mock_experiment_manager, tmp_path, [ + "t_IMM_refined_iter1.surface.vtp", + "t_IMM_refined_iter6.surface.vtp", + ]) + w._refresh_accept_components() + w._preview_iterations() + + iter1_path = str(w._preview_catalog["t_IMM"][1]) + w._surface_steps["t_IMM"].value = 1 # first scrub to iter1 -> disk read + assert mv.reads.count(iter1_path) == 1 + w._surface_steps["t_IMM"].value = 6 # scrub away + w._surface_steps["t_IMM"].value = 1 # scrub back -> served from cache + assert mv.reads.count(iter1_path) == 1 + + def test_clear_preview_removes_layers_and_cache(self, qapp, mock_experiment_manager, tmp_path): + mv = _FakeMeshViewer() + w = self._make_widget(mock_experiment_manager, mv) + self._setup_refined(mock_experiment_manager, tmp_path, [ + "t_IMM_refined_iter1.surface.vtp", + ]) + w._refresh_accept_components() + w._preview_iterations() + assert mv.viewer.layers + w._clear_preview() + assert w._preview_layers == {} + assert w._preview_catalog == {} + assert len(w._preview_mesh_cache) == 0 + assert mv.viewer.layers == [] + + def test_refresh_clears_stale_preview(self, qapp, mock_experiment_manager, tmp_path): + mv = _FakeMeshViewer() + w = self._make_widget(mock_experiment_manager, mv) + self._setup_refined(mock_experiment_manager, tmp_path, [ + "t_IMM_refined_iter1.surface.vtp", + ]) + w._refresh_accept_components() + w._preview_iterations() + assert w._preview_layers + w._refresh_accept_components() + assert w._preview_layers == {} + assert w._preview_catalog == {} + assert mv.viewer.layers == [] + + def test_large_mode_single_layer_and_combo_swap(self, qapp, mock_experiment_manager, tmp_path): + mv = _FakeMeshViewer() + w = self._make_widget(mock_experiment_manager, mv) + # 9 surfaces > PREVIEW_ALL_THRESHOLD (8) triggers single-surface mode. + files = [] + for i in range(9): + files.append(f"t{i}_IMM_refined_iter1.surface.vtp") + files.append(f"t{i}_IMM_refined_iter6.surface.vtp") + self._setup_refined(mock_experiment_manager, tmp_path, files) + w._refresh_accept_components() + + assert len(w._surface_steps) == 9 + # Large mode shows the surface picker. + assert not w.preview_surface_combo.isHidden() + assert w.preview_surface_combo.count() == 9 + + w._preview_iterations() + # Only one layer exists even though there are 9 surfaces. + assert len(mv.viewer.layers) == 1 + assert len(w._preview_layers) == 1 + first = w.preview_surface_combo.itemText(0) + assert set(w._preview_layers) == {first} + assert w._preview_active_basename == first + + # Switching the combo swaps to the other surface, still one layer. + other = w.preview_surface_combo.itemText(1) + w.preview_surface_combo.setCurrentText(other) + assert len(mv.viewer.layers) == 1 + assert set(w._preview_layers) == {other} + assert w._preview_active_basename == other + + def test_no_viewer_disables_preview(self, qapp, mock_experiment_manager): + w = self._make_widget(mock_experiment_manager, None) + assert w.preview_btn is None + assert w.clear_preview_btn is None + assert w.preview_surface_combo is None