Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion PyReconstruct/modules/datatypes/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion PyReconstruct/modules/gui/dialog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@
from .backup_comment import BackupCommentDialog
from .table_columns import TableColumnsDialog
from .import_series import ImportSeriesDialog
from .malformed_contours import MalformedContoursDialog
from .malformed_contours import MalformedContoursDialog
from .copy_to_sections import CopyToSectionsDialog
154 changes: 154 additions & 0 deletions PyReconstruct/modules/gui/dialog/copy_to_sections.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions PyReconstruct/modules/gui/main/context_menu_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
61 changes: 60 additions & 1 deletion PyReconstruct/modules/gui/main/field_widget_2_trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
TraceDialog,
ShapesDialog,
ObjectGroupDialog,
CopyToSectionsDialog,
)
from PyReconstruct.modules.gui.utils import notify
from PyReconstruct.modules.calc import (
Expand Down Expand Up @@ -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):
Expand Down
Loading