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): diff --git a/tests/test_copy_traces_to_sections.py b/tests/test_copy_traces_to_sections.py new file mode 100644 index 00000000..26eed619 --- /dev/null +++ b/tests/test_copy_traces_to_sections.py @@ -0,0 +1,359 @@ +"""Tests for the "Copy to sections" feature. + +Covers the data-model operation that places selected trace(s) onto multiple +sections at the same field (x, y) location: + + * Series.copyTracesToSections re-projects field coordinates through each + target section's own transform, so a trace lands at the identical field + x-y on every section regardless of that section's alignment. + * Trace attributes (name, color, closed, tags) are preserved verbatim. + * Alignment-locked target sections still receive the copied trace (a lock + protects the transform, not trace content). + +Also unit-tests the pure section-spec parser used by the picker dialog. +""" +import os +import shutil +import pytest + +FIXTURE = os.path.join( + os.path.dirname(__file__), "..", "PyReconstruct", "assets", + "checker", "files", "shapes1.jser", +) + +# a distinctive, easy-to-recognise field-coordinate shape (4 points, closed) +FIELD_PTS = [(11.5, 22.25), (33.0, 22.25), (33.0, 44.0), (11.5, 44.0)] + + +def _load_series(tmp_path): + if not os.path.exists(FIXTURE): + pytest.skip("fixture shapes1.jser not found") + fp = str(tmp_path / "shapes1.jser") + shutil.copyfile(FIXTURE, fp) + + from PySide6.QtWidgets import QApplication + QApplication.instance() or QApplication(["test"]) + from PyReconstruct.modules.datatypes.series import Series + from PyReconstruct.modules.datatypes.series_data import SeriesData + + series = Series.openJser(fp) + sd = SeriesData(series) + sd.refresh() + series.data = sd + return series + + +def _unlock(series, snum): + """Persist align_locked=False on a section so it can be copied onto.""" + section = series.loadSection(snum) + section.align_locked = False + section.save() + + +def _field_trace(series, snum, name): + """A copy of a real trace (for its attributes) with distinctive points and + a marker tag, its points already in FIELD coordinates.""" + section = series.loadSection(snum) + trace = section.contours[name].getTraces()[0].copy() + trace.points = list(FIELD_PTS) + trace.tags = set(trace.tags) + trace.tags.add("copytest") + return trace + + +def _mapped_matches(section, trace, field_pts, tol=1e-3): + """True if this section's transform maps the trace's stored points back to + the expected field points.""" + if len(trace.points) != len(field_pts): + return False + tform = section.tform + for stored, expected in zip(trace.points, field_pts): + mx, my = tform.map(*stored) + if abs(mx - expected[0]) > tol or abs(my - expected[1]) > tol: + return False + return True + + +# --------------------------------------------------------------------------- +# pure parser +# --------------------------------------------------------------------------- + +def test_parse_section_spec(): + from PyReconstruct.modules.gui.dialog.copy_to_sections import parse_section_spec + valid = {0, 1, 2, 3, 4} + + assert parse_section_spec("1-3, 4", valid) == ({1, 2, 3, 4}, [], []) + assert parse_section_spec("2", valid) == ({2}, [], []) + assert parse_section_spec("0 2 4", valid) == ({0, 2, 4}, [], []) + # reversed range is normalised + assert parse_section_spec("3-1", valid) == ({1, 2, 3}, [], []) + + # bad / out-of-range tokens are reported and excluded + chosen, bad, missing = parse_section_spec("7", valid) + assert chosen == set() and bad == ["7"] and missing == [] + chosen, bad, missing = parse_section_spec("abc", valid) + assert chosen == set() and bad == ["abc"] and missing == [] + chosen, bad, missing = parse_section_spec("1-", valid) + assert chosen == set() and bad == ["1-"] and missing == [] + chosen, bad, missing = parse_section_spec("1-2-3", valid) + assert chosen == set() and bad == ["1-2-3"] and missing == [] + # a whole range out of range is bad + chosen, bad, missing = parse_section_spec("10-20", valid) + assert chosen == set() and bad == ["10-20"] and missing == [] + # good and bad mixed + chosen, bad, missing = parse_section_spec("2, nope, 4", valid) + assert chosen == {2, 4} and bad == ["nope"] and missing == [] + + +def test_parse_section_spec_overhanging_range_is_surfaced(): + """A range that overhangs the valid set still yields the sections that + exist, but the nonexistent remainder is reported, not silently dropped.""" + from PyReconstruct.modules.gui.dialog.copy_to_sections import parse_section_spec + valid = {0, 1, 2, 3, 4} + + chosen, bad, missing = parse_section_spec("3-7", valid) + assert chosen == {3, 4} + assert bad == [] + assert len(missing) == 1 + # 5, 6, 7 do not exist + assert "3" in missing[0] and "7" in missing[0] and "3 " in missing[0] + + # a range interrupted by a gap in the section numbering is surfaced too + chosen, bad, missing = parse_section_spec("0-4", {0, 1, 3, 4}) + assert chosen == {0, 1, 3, 4} + assert bad == [] + assert len(missing) == 1 and "1 " in missing[0] + + +def test_parse_section_spec_huge_range_returns_fast(): + """A huge typed upper bound must not materialize the range (UI hang).""" + import time + from PyReconstruct.modules.gui.dialog.copy_to_sections import parse_section_spec + valid = set(range(100)) + + start = time.monotonic() + chosen, bad, missing = parse_section_spec("1-999999999", valid) + elapsed = time.monotonic() - start + + assert elapsed < 1.0, f"huge range took {elapsed:.2f}s (range materialized?)" + assert chosen == set(range(1, 100)) + assert bad == [] + assert len(missing) == 1 # the nonexistent 100..999999999 remainder + + +def test_parse_section_spec_exotic_unicode_digits_do_not_crash(): + """Characters where str.isdigit() is True but int() raises (superscripts, + circled digits) must be reported as bad tokens, not crash the parser.""" + from PyReconstruct.modules.gui.dialog.copy_to_sections import parse_section_spec + valid = {0, 1, 2, 3, 4} + + # single-number path + chosen, bad, missing = parse_section_spec("5²", valid) # "5²" + assert chosen == set() and bad == ["5²"] + chosen, bad, missing = parse_section_spec("①", valid) # "①" + assert chosen == set() and bad == ["①"] + + # range-endpoint path + chosen, bad, missing = parse_section_spec("1-5²", valid) + assert chosen == set() and bad == ["1-5²"] + chosen, bad, missing = parse_section_spec("³-4", valid) # "³-4" + assert chosen == set() and bad == ["³-4"] + + # good tokens alongside exotic ones still parse + chosen, bad, missing = parse_section_spec("2, 5²", valid) + assert chosen == {2} and bad == ["5²"] + + +# --------------------------------------------------------------------------- +# data-model copy +# --------------------------------------------------------------------------- + +def test_copy_preserves_field_xy_and_attributes(tmp_path): + series = _load_series(tmp_path) + + # targets have real, distinct, non-identity transforms in this fixture + for snum in (2, 4): + _unlock(series, snum) + + ft = _field_trace(series, 0, "star") + + before = {} + for snum in (2, 4): + before[snum] = len(series.loadSection(snum).contours["star"].getTraces()) + + copied_to, skipped = series.copyTracesToSections( + [ft], {2, 4}, log_event=False + ) + + assert sorted(copied_to) == [2, 4] + assert skipped == [] + + for snum in (2, 4): + section = series.loadSection(snum) + traces = section.contours["star"].getTraces() + # exactly one trace added + assert len(traces) == before[snum] + 1 + # the added trace sits at the SAME field x-y (re-projected through this + # section's own transform) and keeps every attribute + match = [t for t in traces if _mapped_matches(section, t, FIELD_PTS)] + assert len(match) == 1, f"no field-matching trace on section {snum}" + copied = match[0] + assert copied.name == "star" + assert tuple(copied.color) == tuple(ft.color) + assert copied.closed == ft.closed + assert "copytest" in copied.tags + + +def test_copies_onto_locked_sections(tmp_path): + """A lock protects a section's transform, not its trace content, so a + locked target section still receives the copied trace.""" + series = _load_series(tmp_path) + + # section 1 stays locked (fixture default); section 2 is unlocked + _unlock(series, 2) + assert series.loadSection(1).align_locked is True + + locked_before = len(series.loadSection(1).contours["star"].getTraces()) + unlocked_before = len(series.loadSection(2).contours["star"].getTraces()) + + ft = _field_trace(series, 0, "star") + + copied_to, skipped = series.copyTracesToSections( + [ft], {1, 2}, log_event=False + ) + + assert sorted(copied_to) == [1, 2] + assert skipped == [] + + # locked section received the trace at the shared field x-y, lock intact + locked_section = series.loadSection(1) + assert locked_section.align_locked is True + assert len(locked_section.contours["star"].getTraces()) == locked_before + 1 + assert any( + _mapped_matches(locked_section, t, FIELD_PTS) + for t in locked_section.contours["star"].getTraces() + ) + # unlocked section received the trace too + assert len(series.loadSection(2).contours["star"].getTraces()) == unlocked_before + 1 + + +def test_identity_transform_copies_points_verbatim(tmp_path): + """When source and target share the same transform, the stored points are + identical (the field re-projection collapses to a copy).""" + series = _load_series(tmp_path) + + # section 0 has the identity transform; give the target the identity too + target = series.loadSection(3) + from PyReconstruct.modules.datatypes.transform import Transform + target.tform = Transform([1, 0, 0, 0, 1, 0]) + target.align_locked = False + target.save() + + ft = _field_trace(series, 0, "star") # points already == FIELD_PTS + + copied_to, skipped = series.copyTracesToSections( + [ft], {3}, log_event=False + ) + assert copied_to == [3] + assert skipped == [] + + section = series.loadSection(3) + match = [ + t for t in section.contours["star"].getTraces() + if len(t.points) == len(FIELD_PTS) + and all(abs(a[0] - b[0]) < 1e-6 and abs(a[1] - b[1]) < 1e-6 + for a, b in zip(t.points, FIELD_PTS)) + ] + assert len(match) == 1 + + +def test_copy_lands_at_same_field_xy_under_rotation_and_scale(tmp_path): + """Targets with rotation and scale in their transforms still receive the + trace at the identical field x-y (re-projected through each section's own + inverse transform).""" + import math + from PyReconstruct.modules.datatypes.transform import Transform + + series = _load_series(tmp_path) + + # section 2: rotation by 30 degrees plus a translation + c, s = math.cos(math.radians(30)), math.sin(math.radians(30)) + rot = Transform([c, -s, 1.25, s, c, -0.75]) + # section 4: anisotropic scale plus rotation by -45 degrees + c2, s2 = math.cos(math.radians(-45)), math.sin(math.radians(-45)) + scl = Transform([1.5 * c2, -1.5 * s2, -2.0, 0.8 * s2, 0.8 * c2, 3.0]) + + for snum, tform in ((2, rot), (4, scl)): + section = series.loadSection(snum) + section.tform = tform + section.align_locked = False + section.save() + + ft = _field_trace(series, 0, "star") + + copied_to, skipped = series.copyTracesToSections( + [ft], {2, 4}, log_event=False + ) + assert sorted(copied_to) == [2, 4] + assert skipped == [] + + for snum in (2, 4): + section = series.loadSection(snum) + match = [ + t for t in section.contours["star"].getTraces() + if _mapped_matches(section, t, FIELD_PTS) + ] + assert len(match) == 1, ( + f"trace on section {snum} did not land at the shared field x-y" + ) + # the stored points differ from the field points (the transform is + # non-identity), proving a real re-projection happened + stored = match[0].points + assert any( + abs(a[0] - b[0]) > 1e-6 or abs(a[1] - b[1]) > 1e-6 + for a, b in zip(stored, FIELD_PTS) + ) + + +def test_non_invertible_target_transform_is_skipped(tmp_path): + """A singular (non-invertible) target transform cannot place the trace: + the section is skipped and reported, never crashed on or written to.""" + from PyReconstruct.modules.datatypes.transform import Transform + + series = _load_series(tmp_path) + + for snum in (2, 3): + _unlock(series, snum) + + # det = 1*1 - 1*1 = 0: singular + singular = Transform([1, 1, 0, 1, 1, 0]) + section = series.loadSection(3) + section.tform = singular + section.save() + + before = len(series.loadSection(3).contours["star"].getTraces()) + + ft = _field_trace(series, 0, "star") + + copied_to, skipped = series.copyTracesToSections( + [ft], {2, 3}, log_event=False + ) + + assert copied_to == [2] + assert skipped == [3] + # the singular section was not written to + assert len(series.loadSection(3).contours["star"].getTraces()) == before + + +def test_all_invalid_targets_noop(tmp_path): + """Requesting only nonexistent sections must no-op gracefully.""" + series = _load_series(tmp_path) + + ft = _field_trace(series, 0, "star") + + copied_to, skipped = series.copyTracesToSections( + [ft], {9998, 9999}, log_event=False + ) + + assert copied_to == [] + assert skipped == []