From 2f4e7fddd7666a9e0390a65682810e18b9d74d42 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Fri, 17 Jul 2026 22:54:30 -0500 Subject: [PATCH 1/2] feat(gui): add "Copy to sections" action for selected traces Copying a trace today drops it only into the current section, so placing the same trace at the same field location across several sections means navigating to each one and pasting by hand. Add a "Copy to sections..." action that copies the selected trace(s) onto multiple chosen sections in one step. Each trace's field coordinates are re-projected through every target section's own inverse transform (computed once per section), so the traces land at the identical field x-y regardless of how each section is aligned. Sections whose transform is not invertible are skipped and reported rather than written to. Trace attributes are preserved and the source (current) section is left unchanged. An alignment lock protects a section's transform, not its trace content, so traces are copied onto every chosen section regardless of its lock status, just as traces can be drawn on a locked section. Target sections are chosen by number or inclusive range in a small picker dialog. The parser validates tokens without materializing typed ranges, so a huge upper bound cannot hang the UI, and it reports requested sections that do not exist instead of silently dropping them. The action is available at the field context-menu top level (next to Copy) and in the trace list. Includes tests for the parser and the data-model copy operation. Closes #94. #91 is a duplicate of the same request. --- PyReconstruct/modules/datatypes/series.py | 59 ++++++- PyReconstruct/modules/gui/dialog/__init__.py | 3 +- .../modules/gui/dialog/copy_to_sections.py | 154 ++++++++++++++++++ .../modules/gui/main/context_menu_list.py | 11 ++ .../modules/gui/main/field_widget_2_trace.py | 61 ++++++- 5 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 PyReconstruct/modules/gui/dialog/copy_to_sections.py diff --git a/PyReconstruct/modules/datatypes/series.py b/PyReconstruct/modules/datatypes/series.py index 0985b4d7..16fc010c 100644 --- a/PyReconstruct/modules/datatypes/series.py +++ b/PyReconstruct/modules/datatypes/series.py @@ -1173,7 +1173,64 @@ def copyObjects(self, obj_names: list, series_states=None, log_event=True) -> li self.modified = True return [f"{obj}_copy" for obj in obj_names] - + + def copyTracesToSections(self, traces : list, section_numbers, series_states=None, log_event=True): + """Copy traces into multiple sections at the same field (x, y) location. + + The traces' points must be given in FIELD coordinates (i.e. already + mapped through a section transform, the same form the clipboard/paste + path uses). Each target section stores the points through its own + inverse transform, so the traces land at the identical field x-y on + every section regardless of how each section is aligned. + + An alignment lock protects a section's transform, not its trace + content, so traces are copied onto every chosen section regardless of + its lock status (just as traces can be drawn on a locked section). + + Params: + traces (list): the traces to copy, points in field coordinates + section_numbers (iterable): the target section numbers + series_states (dict): section number : SectionStates (GUI undo) + log_event (bool): True if the trace creation should be logged + Returns: + (tuple): (list of section numbers that received the traces, + list of section numbers skipped because their + transform is not invertible) + """ + targets = set(section_numbers) + copied_to = [] + skipped = [] + + for snum, section in self.enumerateSections( + message="Copying traces to sections...", + series_states=series_states + ): + if snum not in targets: + continue + + # obtain this section's inverse transform ONCE; a singular + # (non-invertible) transform cannot place the trace, so skip the + # section rather than crash or store garbage points + try: + inv_tform = section.tform.inverted() + except Exception: + skipped.append(snum) + continue + + for trace in traces: + new_trace = trace.copy() + # re-project the shared field coordinates through this section's + # own inverse transform so the trace occupies the same field x-y + new_trace.points = [inv_tform.map(*p) for p in trace.points] + section.addTrace(new_trace, log_event=log_event) + section.save() + copied_to.append(snum) + + if copied_to: + self.modified = True + + return copied_to, skipped + def deleteAllTraces(self, trace_name : str, tags : set = None, series_states=None): """Delete all traces with a certain name and tag set. diff --git a/PyReconstruct/modules/gui/dialog/__init__.py b/PyReconstruct/modules/gui/dialog/__init__.py index 02890795..85d16595 100644 --- a/PyReconstruct/modules/gui/dialog/__init__.py +++ b/PyReconstruct/modules/gui/dialog/__init__.py @@ -19,4 +19,5 @@ from .backup_comment import BackupCommentDialog from .table_columns import TableColumnsDialog from .import_series import ImportSeriesDialog -from .malformed_contours import MalformedContoursDialog \ No newline at end of file +from .malformed_contours import MalformedContoursDialog +from .copy_to_sections import CopyToSectionsDialog \ No newline at end of file diff --git a/PyReconstruct/modules/gui/dialog/copy_to_sections.py b/PyReconstruct/modules/gui/dialog/copy_to_sections.py new file mode 100644 index 00000000..200b9081 --- /dev/null +++ b/PyReconstruct/modules/gui/dialog/copy_to_sections.py @@ -0,0 +1,154 @@ +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QLabel, + QLineEdit, + QDialogButtonBox, +) + +from PyReconstruct.modules.gui.utils import notify + + +def _to_int(token : str): + """Convert a token to an int, or return None if it is not a plain decimal + number. + + str.isdecimal() (not isdigit()) is the correct gate: isdigit() accepts + characters like superscripts ("5²") and circled digits that int() then + refuses, which would raise ValueError. The try/except covers any remaining + edge case so a weird token is reported instead of crashing. + """ + if not token.isdecimal(): + return None + try: + return int(token) + except ValueError: + return None + + +def parse_section_spec(text : str, valid_sections) -> tuple: + """Parse a section spec string into a set of section numbers. + + Accepts comma/space-separated tokens, each either a single number ("12") + or an inclusive range ("10-15"). Returns the parsed numbers that exist in + valid_sections, a list of tokens that were malformed or entirely out of + range, and a list describing requested sections that do not exist (e.g. a + range that overhangs the series), so nothing is silently dropped. + + Params: + text (str): the spec, e.g. "10-15, 20, 22" + valid_sections (iterable): the section numbers that exist + Returns: + (tuple): (set of valid section numbers, list of bad tokens, + list of descriptions of nonexistent requested sections) + """ + valid = set(valid_sections) + chosen = set() + bad = [] + missing = [] + + for token in text.replace(",", " ").split(): + if "-" in token.strip("-"): # a range like 10-15 + parts = token.split("-") + if len(parts) != 2: + bad.append(token) + continue + lo, hi = _to_int(parts[0]), _to_int(parts[1]) + if lo is None or hi is None: + bad.append(token) + continue + if lo > hi: + lo, hi = hi, lo + # intersect the range with the existing sections WITHOUT + # materializing it: iterating range(lo, hi + 1) would hang the UI + # on a huge upper bound like "1-999999999" + matched = {n for n in valid if lo <= n <= hi} + if not matched: + bad.append(token) + continue + chosen.update(matched) + missing_count = (hi - lo + 1) - len(matched) + if missing_count: + missing.append(f"{missing_count} section(s) in {token}") + else: # a single section number + n = _to_int(token) + if n is None: + bad.append(token) + continue + if n in valid: + chosen.add(n) + else: + bad.append(token) + + return chosen, bad, missing + + +class CopyToSectionsDialog(QDialog): + """Pick the target sections to copy the selected trace(s) onto.""" + + def __init__(self, parent, series): + super().__init__(parent) + self.series = series + self.valid_sections = set(series.sections.keys()) + self.sections = None + + current = series.current_section + self.smin, self.smax = min(self.valid_sections), max(self.valid_sections) + smin, smax = self.smin, self.smax + + self.setWindowTitle("Copy to sections") + + vlayout = QVBoxLayout() + + info = QLabel(self, text=( + "Copy the selected trace(s) onto other sections at the same " + "location.\n" + f"The current section ({current}) is left unchanged.\n" + f"Enter section numbers or ranges from {smin} to {smax}, " + "e.g. \"10-20\" or \"5, 8, 11\"." + )) + vlayout.addWidget(info) + + self.spec_input = QLineEdit(self) + self.spec_input.setPlaceholderText("e.g. 10-20 or 5, 8, 11") + vlayout.addWidget(self.spec_input) + + buttonbox = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttonbox.accepted.connect(self.accept) + buttonbox.rejected.connect(self.reject) + vlayout.addSpacing(10) + vlayout.addWidget(buttonbox) + + self.setLayout(vlayout) + + def accept(self): + chosen, bad, missing = parse_section_spec( + self.spec_input.text(), self.valid_sections + ) + if bad: + notify("Invalid or out-of-range section(s): " + ", ".join(bad)) + return + if missing: + notify( + "Some requested sections do not exist: " + + "; ".join(missing) + "\n" + f"Sections in this series range from {self.smin} to {self.smax}." + ) + return + if not chosen: + notify("Please enter at least one valid section.") + return + self.sections = chosen + super().accept() + + def get(self): + """Run the dialog. + + Returns: + (tuple): (set of section numbers, confirmed bool) + """ + if self.exec(): + return self.sections, True + return None, False diff --git a/PyReconstruct/modules/gui/main/context_menu_list.py b/PyReconstruct/modules/gui/main/context_menu_list.py index 8fac77c9..ba829a3d 100644 --- a/PyReconstruct/modules/gui/main/context_menu_list.py +++ b/PyReconstruct/modules/gui/main/context_menu_list.py @@ -43,6 +43,7 @@ def get_field_menu_list(self): None, self.cut_act, self.copy_act, + ("copytosections_act", "Copy to sections...", "", self.field.copyTracesToSections), self.paste_act, self.pasteattributes_act, None, @@ -163,6 +164,16 @@ def get_context_menu_list_trace(self, is_in_field=True): context_menu = [ ("edittrace_act", "Edit attributes...", sc, self.traceDialog), + ] + + # "Copy to sections..." lives at the field context-menu top level (next to + # "Copy") when invoked in the field; in the trace list it appears here. + if not is_in_field: + context_menu.append( + ("copytosections_act", "Copy to sections...", "", self.copyTracesToSections) + ) + + context_menu += [ None, ("smoothtraces_act", "Smooth traces", "", self.smoothTraces), ("mergetraces_act", "Merge traces", sc, self.mergeTraces), diff --git a/PyReconstruct/modules/gui/main/field_widget_2_trace.py b/PyReconstruct/modules/gui/main/field_widget_2_trace.py index e2edf754..3a6e805a 100644 --- a/PyReconstruct/modules/gui/main/field_widget_2_trace.py +++ b/PyReconstruct/modules/gui/main/field_widget_2_trace.py @@ -12,6 +12,7 @@ TraceDialog, ShapesDialog, ObjectGroupDialog, + CopyToSectionsDialog, ) from PyReconstruct.modules.gui.utils import notify from PyReconstruct.modules.calc import ( @@ -877,7 +878,65 @@ def wrapper(self, *args, **kwargs): # call to update is handled by field_interaction decorator return wrapper - + + @trace_function + def copyTracesToSections(self, traces : list): + """Copy the selected trace(s) onto multiple chosen sections at the same + field (x, y) location.""" + if self.hide_trace_layer: + return False + + # convert the selection to field coordinates, exactly as the copy path + # does, so the traces can be re-projected onto each target section + tform = self.section.tform + field_traces = [] + for trace in traces: + field_trace = trace.copy() + field_trace.points = [tform.map(*p) for p in trace.points] + field_traces.append(field_trace) + + # choose the target sections + chosen, confirmed = CopyToSectionsDialog(self, self.series).get() + if not confirmed: + return False + + # never copy onto the source (current) section + current = self.series.current_section + excluded_current = current in chosen + chosen.discard(current) + + if not chosen: + notify("No other sections were selected to copy to.") + return False + + names = list(set(t.name for t in field_traces)) + + copied_to, skipped = self.series.copyTracesToSections( + field_traces, chosen, self.series_states + ) + + # refresh the object/trace lists and the field view (only if anything + # actually changed) + if copied_to: + self.table_manager.updateObjects(names) + self.reload() + + # report the outcome to the user + msgs = [] + if copied_to: + msgs.append(f"Copied trace(s) to {len(copied_to)} section(s).") + if skipped: + msgs.append( + "Skipped section(s) with a non-invertible transform: " + + ", ".join(str(n) for n in sorted(skipped)) + ) + if excluded_current: + msgs.append(f"The current section ({current}) was left unchanged.") + if msgs: + notify("\n".join(msgs)) + + return bool(copied_to) + @trace_function @field_interaction def traceDialog(self, traces : list): From b5e645f787a8690e30a1f8af224b400dfafeb958 Mon Sep 17 00:00:00 2001 From: Dusten Hubbard Date: Tue, 21 Jul 2026 18:39:39 -0500 Subject: [PATCH 2/2] feat(gui): list actual sections in "Copy to sections" result Report the section numbers that actually received the trace(s) instead of a bare count, so the message reflects what was done rather than what was requested and surfaces any silently-skipped targets. Collapse long contiguous runs to ranges (e.g. "2-5, 10") and use singular/plural grammar ("section 5" vs "sections 2, 3"). Preserve the existing non-invertible "skipped" reporting. Move message building into a pure format_copy_result helper. --- PyReconstruct/modules/gui/dialog/__init__.py | 2 +- .../modules/gui/dialog/copy_to_sections.py | 67 +++++++++++++++++++ .../modules/gui/main/field_widget_2_trace.py | 21 +++--- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/PyReconstruct/modules/gui/dialog/__init__.py b/PyReconstruct/modules/gui/dialog/__init__.py index 85d16595..151d30c7 100644 --- a/PyReconstruct/modules/gui/dialog/__init__.py +++ b/PyReconstruct/modules/gui/dialog/__init__.py @@ -20,4 +20,4 @@ from .table_columns import TableColumnsDialog from .import_series import ImportSeriesDialog from .malformed_contours import MalformedContoursDialog -from .copy_to_sections import CopyToSectionsDialog \ No newline at end of file +from .copy_to_sections import CopyToSectionsDialog, format_copy_result \ No newline at end of file diff --git a/PyReconstruct/modules/gui/dialog/copy_to_sections.py b/PyReconstruct/modules/gui/dialog/copy_to_sections.py index 200b9081..2ba91985 100644 --- a/PyReconstruct/modules/gui/dialog/copy_to_sections.py +++ b/PyReconstruct/modules/gui/dialog/copy_to_sections.py @@ -83,6 +83,73 @@ def parse_section_spec(text : str, valid_sections) -> tuple: return chosen, bad, missing +def format_section_run(numbers) -> str: + """Format section numbers as a compact, readable list. + + A run of three or more consecutive numbers is collapsed to "lo-hi" + (e.g. [2, 3, 4, 5, 10] -> "2-5, 10"); singletons and pairs stay listed + plainly so a short run like "2, 3" is not obscured by a range. + + Params: + numbers (iterable): the section numbers to render + Returns: + (str): the formatted list, "" for no numbers + """ + nums = sorted(set(numbers)) + if not nums: + return "" + + runs = [] + start = prev = nums[0] + for n in nums[1:]: + if n == prev + 1: + prev = n + else: + runs.append((start, prev)) + start = prev = n + runs.append((start, prev)) + + parts = [] + for lo, hi in runs: + if hi - lo >= 2: # 3+ consecutive: collapse to a range + parts.append(f"{lo}-{hi}") + else: # single number or a pair: list plainly + parts.extend(str(n) for n in range(lo, hi + 1)) + return ", ".join(parts) + + +def format_copy_result(copied_to, skipped, excluded_current=None) -> str: + """Build the user-facing summary for a "Copy to sections" run. + + Reports the sections that ACTUALLY received the trace(s) rather than a bare + count, so the message reflects what was done (a self-check), not what was + typed. Existing non-invertible "skipped" reporting is preserved, and the + excluded current section is noted when applicable. + + Params: + copied_to (iterable): section numbers that received the trace(s) + skipped (iterable): section numbers skipped (non-invertible tform) + excluded_current (int): the current section left unchanged, or None + Returns: + (str): the message to show, "" if there is nothing to report + """ + msgs = [] + if copied_to: + unique = sorted(set(copied_to)) + noun = "section" if len(unique) == 1 else "sections" + msgs.append(f"Copied trace(s) to {noun} {format_section_run(unique)}.") + if skipped: + msgs.append( + "Skipped section(s) with a non-invertible transform: " + + ", ".join(str(n) for n in sorted(skipped)) + ) + if excluded_current is not None: + msgs.append( + f"The current section ({excluded_current}) was left unchanged." + ) + return "\n".join(msgs) + + class CopyToSectionsDialog(QDialog): """Pick the target sections to copy the selected trace(s) onto.""" diff --git a/PyReconstruct/modules/gui/main/field_widget_2_trace.py b/PyReconstruct/modules/gui/main/field_widget_2_trace.py index 3a6e805a..35367c99 100644 --- a/PyReconstruct/modules/gui/main/field_widget_2_trace.py +++ b/PyReconstruct/modules/gui/main/field_widget_2_trace.py @@ -13,6 +13,7 @@ ShapesDialog, ObjectGroupDialog, CopyToSectionsDialog, + format_copy_result, ) from PyReconstruct.modules.gui.utils import notify from PyReconstruct.modules.calc import ( @@ -921,19 +922,13 @@ def copyTracesToSections(self, traces : list): self.table_manager.updateObjects(names) self.reload() - # report the outcome to the user - msgs = [] - if copied_to: - msgs.append(f"Copied trace(s) to {len(copied_to)} section(s).") - if skipped: - msgs.append( - "Skipped section(s) with a non-invertible transform: " - + ", ".join(str(n) for n in sorted(skipped)) - ) - if excluded_current: - msgs.append(f"The current section ({current}) was left unchanged.") - if msgs: - notify("\n".join(msgs)) + # report the outcome to the user, listing the sections that ACTUALLY + # received the trace(s) so the message reflects what was done + message = format_copy_result( + copied_to, skipped, current if excluded_current else None + ) + if message: + notify(message) return bool(copied_to)