diff --git a/canvas.py b/canvas.py
index 7028cdd..bf04eca 100644
--- a/canvas.py
+++ b/canvas.py
@@ -56,7 +56,10 @@ class LedCanvas(QGraphicsView):
selectionChanged = Signal(list)
deleteSelectionRequested = Signal()
deletePointsRequested = Signal()
+ editPointRequested = Signal(int)
+ pointMoveFinished = Signal(int, float, float)
autoBlockFromPointRequested = Signal(int)
+ counterclockwiseBlockFromPointRequested = Signal(int)
brushStrokeStarted = Signal(bool)
brushPointTouched = Signal(int)
brushStrokeFinished = Signal()
@@ -108,6 +111,7 @@ class LedCanvas(QGraphicsView):
self._is_panning = False
self._last_pan_pos = None
self._last_mouse_pos = None
+ self._point_drag: dict | None = None
self._brush_enabled = False
self._add_led_enabled = False
self._is_brush_stroke = False
@@ -621,6 +625,9 @@ class LedCanvas(QGraphicsView):
self._start_pan(event.pos())
event.accept()
return
+ if event.button() == Qt.LeftButton and event.modifiers() & Qt.AltModifier and self._start_point_drag(event.pos()):
+ event.accept()
+ return
if self._background_edit_mode and event.button() == Qt.LeftButton and self._start_background_edit(event.pos()):
event.accept()
return
@@ -661,6 +668,10 @@ class LedCanvas(QGraphicsView):
self._move_background_edit(event.pos())
event.accept()
return
+ if self._point_drag is not None:
+ self._move_point_drag(event.pos())
+ event.accept()
+ return
if self._brush_enabled and self._is_brush_stroke:
self._update_brush_zone(event.pos())
self._touch_brush_point(event.pos())
@@ -685,6 +696,10 @@ class LedCanvas(QGraphicsView):
self._finish_background_edit()
event.accept()
return
+ if event.button() == Qt.LeftButton and self._point_drag is not None:
+ self._finish_point_drag(event.pos())
+ event.accept()
+ return
if self._brush_enabled and event.button() == Qt.LeftButton and self._is_brush_stroke:
self._update_brush_zone(event.pos())
self._touch_brush_point(event.pos())
@@ -695,6 +710,51 @@ class LedCanvas(QGraphicsView):
return
super().mouseReleaseEvent(event)
+ def _start_point_drag(self, viewport_pos) -> bool: # noqa: ANN001
+ if self.document is None:
+ return False
+ item = self._led_item_at(viewport_pos)
+ if item is None:
+ return False
+ scene_pos = self.mapToScene(viewport_pos)
+ self._point_drag = {
+ "point_id": item.point_id,
+ "item": item,
+ "offset": item.pos() - scene_pos,
+ "start_pos": item.pos(),
+ }
+ self.select_points([item.point_id])
+ self.setDragMode(QGraphicsView.NoDrag)
+ self.viewport().setCursor(Qt.ClosedHandCursor)
+ return True
+
+ def _move_point_drag(self, viewport_pos) -> None: # noqa: ANN001
+ if self._point_drag is None:
+ return
+ item = self._point_drag.get("item")
+ if item is None or not isValid(item):
+ return
+ offset = self._point_drag.get("offset", QPointF(0.0, 0.0))
+ item.setPos(self.mapToScene(viewport_pos) + offset)
+ self._position_line_index_labels()
+
+ def _finish_point_drag(self, viewport_pos) -> None: # noqa: ANN001
+ if self._point_drag is None:
+ return
+ self._move_point_drag(viewport_pos)
+ point_id = int(self._point_drag.get("point_id", -1))
+ item = self._point_drag.get("item")
+ start_pos = self._point_drag.get("start_pos")
+ self._point_drag = None
+ self.setDragMode(QGraphicsView.NoDrag if self._brush_enabled else QGraphicsView.RubberBandDrag)
+ self._apply_default_cursor()
+ if item is None or not isValid(item) or point_id < 0:
+ return
+ if isinstance(start_pos, QPointF) and (item.pos() - start_pos).manhattanLength() < 0.001:
+ return
+ document_point = self._document_point_from_scene(item.pos())
+ self.pointMoveFinished.emit(point_id, document_point.x(), document_point.y())
+
def _start_background_edit(self, viewport_pos) -> bool: # noqa: ANN001
if self.document is None or self._background_item is None or not isValid(self._background_item):
return False
@@ -815,6 +875,8 @@ class LedCanvas(QGraphicsView):
cursor_shape = Qt.ClosedHandCursor
elif self._background_drag is not None:
cursor_shape = Qt.ClosedHandCursor if self._background_drag.get("mode") == "move" else Qt.SizeAllCursor
+ elif self._point_drag is not None:
+ cursor_shape = Qt.ClosedHandCursor
elif self._space_pressed and self._can_pan_with_space():
cursor_shape = Qt.OpenHandCursor
elif self._brush_enabled or self._add_led_enabled:
@@ -852,10 +914,12 @@ class LedCanvas(QGraphicsView):
self._apply_default_cursor()
def contextMenuEvent(self, event) -> None: # noqa: ANN001
- item = self.itemAt(event.pos())
- point_id = item.point_id if isinstance(item, LedItem) and isValid(item) else self._single_selected_point_id()
+ item = self._led_item_at(event.pos())
+ point_id = item.point_id if item is not None else self._single_selected_point_id()
menu = QMenu(self)
+ edit_action = menu.addAction("Редактировать координаты")
+ edit_action.setEnabled(point_id is not None)
delete_action = menu.addAction("Удалить фиксацию выбранных LED")
delete_action.setEnabled(self._selection_has_assignment())
delete_points_action = menu.addAction("Удалить выбранные точки")
@@ -864,10 +928,14 @@ class LedCanvas(QGraphicsView):
clear_selection_action.setEnabled(bool(self.selected_point_ids()))
auto_block_action = menu.addAction("Автоматически добавить блок")
auto_block_action.setEnabled(point_id is not None)
+ counterclockwise_block_action = menu.addAction("Автоматически добавить блок против часовой")
+ counterclockwise_block_action.setEnabled(point_id is not None)
fit_action = menu.addAction("Показать все точки")
fit_action.setEnabled(self.document is not None and bool(self.document.points))
chosen = menu.exec(event.globalPos())
- if chosen == delete_action:
+ if chosen == edit_action and point_id is not None:
+ self.editPointRequested.emit(point_id)
+ elif chosen == delete_action:
self.deleteSelectionRequested.emit()
elif chosen == delete_points_action:
self.deletePointsRequested.emit()
@@ -875,9 +943,17 @@ class LedCanvas(QGraphicsView):
self.select_points([])
elif chosen == auto_block_action and point_id is not None:
self.autoBlockFromPointRequested.emit(point_id)
+ elif chosen == counterclockwise_block_action and point_id is not None:
+ self.counterclockwiseBlockFromPointRequested.emit(point_id)
elif chosen == fit_action:
self.fit_to_content()
+ def _led_item_at(self, viewport_pos) -> LedItem | None: # noqa: ANN001
+ for item in self.items(viewport_pos):
+ if isinstance(item, LedItem) and isValid(item):
+ return item
+ return None
+
def _emit_selection(self) -> None:
selected_ids = {
item.point_id
@@ -922,6 +998,7 @@ class LedCanvas(QGraphicsView):
label.setZValue(40)
label.setFlag(QGraphicsItem.ItemIgnoresTransformations, True)
label.setAcceptedMouseButtons(Qt.NoButton)
+ label.setEnabled(False)
self.scene().addItem(label)
self.line_index_label_items[point_id] = label
self._position_line_index_labels()
diff --git a/icons.py b/icons.py
index 113293a..1c5f224 100644
--- a/icons.py
+++ b/icons.py
@@ -23,6 +23,7 @@ LUCIDE_PATHS = {
"paintbrush": '',
"plus": '',
"redo-2": '',
+ "rotate-ccw": '',
"rotate-cw": '',
"save": '',
"save-all": '',
diff --git a/main_window.py b/main_window.py
index 1103686..6ec6217 100644
--- a/main_window.py
+++ b/main_window.py
@@ -9,7 +9,11 @@ from PySide6.QtWidgets import (
QAbstractItemView,
QAbstractScrollArea,
QApplication,
+ QDialog,
+ QDialogButtonBox,
+ QDoubleSpinBox,
QFileDialog,
+ QFormLayout,
QHBoxLayout,
QHeaderView,
QLabel,
@@ -37,7 +41,14 @@ try:
from .background_dialog import BackgroundDialog
from .canvas import LedCanvas
from .icons import lucide_icon
- from .localization import ask_question, localize_message_box, show_critical, show_information, show_warning
+ from .localization import (
+ ask_question,
+ localize_dialog_buttons,
+ localize_message_box,
+ show_critical,
+ show_information,
+ show_warning,
+ )
from .models import LedBlock, LedLine, MappingDocument
from .panel_mapping_generator import generate_panel_mapping_from_document
from .settings import DEFAULT_SHORTCUTS, AppSettings
@@ -48,7 +59,14 @@ except ImportError:
from background_dialog import BackgroundDialog
from canvas import LedCanvas
from icons import lucide_icon
- from localization import ask_question, localize_message_box, show_critical, show_information, show_warning
+ from localization import (
+ ask_question,
+ localize_dialog_buttons,
+ localize_message_box,
+ show_critical,
+ show_information,
+ show_warning,
+ )
from models import LedBlock, LedLine, MappingDocument
from panel_mapping_generator import generate_panel_mapping_from_document
from settings import DEFAULT_SHORTCUTS, AppSettings
@@ -67,12 +85,11 @@ MAX_WIDGET_WIDTH = 16777215
ID_COLUMN = 0
X_COLUMN = 1
Y_COLUMN = 2
-SOURCE_COLUMN = 3
-ROW_COLUMN = 4
-COL_COLUMN = 5
-LINE_COLUMN = 6
-BLOCK_COLUMN = 7
-UID_COLUMN = 8
+ROW_COLUMN = 3
+COL_COLUMN = 4
+LINE_COLUMN = 5
+BLOCK_COLUMN = 6
+UID_COLUMN = 7
_NATIVE_VIRTUAL_KEY_NAMES = {
0x08: "Backspace",
0x09: "Tab",
@@ -170,7 +187,10 @@ class MainWindow(QMainWindow):
self.canvas.selectionChanged.connect(self._on_selection_changed)
self.canvas.deleteSelectionRequested.connect(self.clear_selected_point_assignments)
self.canvas.deletePointsRequested.connect(self.delete_selected_points)
+ self.canvas.editPointRequested.connect(self.edit_point_coordinates)
+ self.canvas.pointMoveFinished.connect(self.move_point_from_canvas)
self.canvas.autoBlockFromPointRequested.connect(self.auto_block_from_point)
+ self.canvas.counterclockwiseBlockFromPointRequested.connect(self.auto_block_from_point_counterclockwise)
self.canvas.addLedRequested.connect(self.add_manual_led)
self.canvas.brushStrokeStarted.connect(self.start_brush_stroke)
self.canvas.brushPointTouched.connect(self.add_brush_point_to_block)
@@ -185,14 +205,14 @@ class MainWindow(QMainWindow):
self.tree.itemSelectionChanged.connect(self._on_tree_selection)
self.tree.customContextMenuRequested.connect(self._show_tree_menu)
- self.table = QTableWidget(0, 9)
+ self.table = QTableWidget(0, 8)
self.table.setHorizontalHeaderLabels(
- ["ID", "X", "Y", "Источник", "Ряд", "Кол.", "Линия", "Блок", "UID"]
+ ["ID", "X", "Y", "Ряд", "Кол.", "Линия", "Блок", "UID"]
)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
self.table.horizontalHeader().setMinimumSectionSize(24)
self.table.horizontalHeader().setStretchLastSection(False)
- for column, width in enumerate((48, 72, 72, 72, 54, 54, 58, 58, 54)):
+ for column, width in enumerate((48, 72, 72, 54, 54, 58, 58, 54)):
self.table.setColumnWidth(column, width)
self.table.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.table.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored)
@@ -271,6 +291,14 @@ class MainWindow(QMainWindow):
add_action("add_block", "Блок из выбора", self.add_block_from_selection, "plus", None, "Создать блок в активной линии")
add_action("auto_blocks", "Автоблоки", self.auto_blocks_from_selection, "layers", QKeySequence("Ctrl+Shift+B"), "Разбить выделение на связанные блоки")
add_action("clockwise", "По часовой", self.auto_connect_active_line, "rotate-cw", None, "Пересчитать порядок LED по часовой стрелке")
+ add_action(
+ "counterclockwise",
+ "Против часовой",
+ self.auto_block_counterclockwise_from_selection_seed,
+ "rotate-ccw",
+ None,
+ "Автоматически добавить блок от выбранной LED против часовой стрелки",
+ )
add_action("rebuild_route", "Пересобрать", self.rebuild_active_line_blocks, "redo-2", None, "Пересобрать блоки активной линии по правилу 34 мм")
brush_action = add_action("brush", "Кисть", self.toggle_brush_mode, "paintbrush", QKeySequence("B"), "Рисовать выбранный блок по точкам на схеме")
brush_action.setCheckable(True)
@@ -385,6 +413,7 @@ class MainWindow(QMainWindow):
tools_menu.addAction(self.actions["generate_script"])
tools_menu.addSeparator()
tools_menu.addAction(self.actions["auto_blocks"])
+ tools_menu.addAction(self.actions["counterclockwise"])
tools_menu.addSeparator()
tools_menu.addAction(self.actions["brush"])
@@ -997,6 +1026,61 @@ class MainWindow(QMainWindow):
self.status.setText(f"Добавлен свободный LED ID {point.source_id}: X={point.x:.3f}, Y={point.y:.3f}.")
self._refresh_ui_state()
+ def edit_point_coordinates(self, point_id: int) -> None:
+ if not 0 <= point_id < len(self.document.points):
+ return
+ point = self.document.get_point(point_id)
+ dialog = QDialog(self)
+ dialog.setWindowTitle(f"Редактировать LED ID {point.source_id}")
+ layout = QVBoxLayout(dialog)
+ form = QFormLayout()
+ x_spin = self._coordinate_spin_box(point.x)
+ y_spin = self._coordinate_spin_box(point.y)
+ form.addRow("X", x_spin)
+ form.addRow("Y", y_spin)
+ layout.addLayout(form)
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ localize_dialog_buttons(buttons)
+ buttons.accepted.connect(dialog.accept)
+ buttons.rejected.connect(dialog.reject)
+ layout.addWidget(buttons)
+ if dialog.exec() != QDialog.Accepted:
+ return
+ self.update_point_coordinates(point_id, x_spin.value(), y_spin.value(), source="dialog")
+
+ def move_point_from_canvas(self, point_id: int, x: float, y: float) -> None:
+ self.update_point_coordinates(point_id, x, y, source="drag")
+
+ def update_point_coordinates(self, point_id: int, x: float, y: float, *, source: str = "edit") -> bool:
+ if not 0 <= point_id < len(self.document.points):
+ return False
+ point = self.document.get_point(point_id)
+ if abs(point.x - x) < 1e-9 and abs(point.y - y) < 1e-9:
+ self.canvas.set_document(self.document, reset_view=False)
+ self.canvas.select_points([point_id])
+ self._on_selection_changed([point_id])
+ return False
+ self._push_undo()
+ self.document.update_point_position(point.uid, x=x, y=y)
+ self._dirty = True
+ self.canvas.set_document(self.document, reset_view=False)
+ self.canvas.select_points([point.uid])
+ self.refresh_tree()
+ self._on_selection_changed([point.uid])
+ action_text = "перемещен" if source == "drag" else "обновлен"
+ self.status.setText(f"LED ID {point.source_id}: координаты {action_text}.")
+ self._refresh_ui_state([point.uid])
+ return True
+
+ def _coordinate_spin_box(self, value: float) -> QDoubleSpinBox:
+ spin = QDoubleSpinBox()
+ spin.setRange(-1_000_000_000.0, 1_000_000_000.0)
+ spin.setDecimals(6)
+ spin.setSingleStep(1.0)
+ spin.setValue(float(value))
+ spin.setKeyboardTracking(False)
+ return spin
+
def add_block_from_selection(self) -> None:
line_id = self._require_active_line()
if line_id is None:
@@ -1034,6 +1118,19 @@ class MainWindow(QMainWindow):
)
def auto_block_from_point(self, point_id: int) -> None:
+ self._auto_block_from_point(point_id, direction="clockwise")
+
+ def auto_block_from_point_counterclockwise(self, point_id: int) -> None:
+ self._auto_block_from_point(point_id, direction="counterclockwise")
+
+ def auto_block_counterclockwise_from_selection_seed(self) -> None:
+ point_ids = self.canvas.selected_point_ids()
+ if len(point_ids) != 1:
+ show_information(self, "Нет стартовой точки", "Выберите одну LED-точку для построения блока против часовой.")
+ return
+ self.auto_block_from_point_counterclockwise(point_ids[0])
+
+ def _auto_block_from_point(self, point_id: int, *, direction: str = "clockwise") -> None:
line_id = self.active_line_id
needs_new_line = line_id is None and not self.document.lines
if line_id is None and self.document.lines:
@@ -1046,7 +1143,12 @@ class MainWindow(QMainWindow):
f"Новая модель поддерживает максимум {MAX_LINES} линии. Удалите лишнюю линию, чтобы создать новую.",
)
return
- preview = self.document.clockwise_block_from_seed_preview(point_id)
+ is_counterclockwise = direction == "counterclockwise"
+ preview = (
+ self.document.counterclockwise_block_from_seed_preview(point_id)
+ if is_counterclockwise
+ else self.document.clockwise_block_from_seed_preview(point_id)
+ )
if not preview:
show_information(self, "Нет блока", "Не удалось найти связанный блок от выбранной LED.")
return
@@ -1060,8 +1162,10 @@ class MainWindow(QMainWindow):
block = self.document.add_clockwise_block_from_group(line_id, point_id, preview)
self._dirty = True
self.canvas.select_points(block.point_ids)
+ direction_text = "против часовой" if is_counterclockwise else "по часовой"
self._after_mapping_change(
- f"Автоматически добавлен {block.name}: {len(block.point_ids)} LED, стартовая точка {point_id}. "
+ f"Автоматически добавлен {block.name} {direction_text}: {len(block.point_ids)} LED, "
+ f"стартовая точка {point_id}. "
f"{self._unfolding_summary(line_id)}",
selection_already_updated=True,
)
@@ -1611,7 +1715,6 @@ class MainWindow(QMainWindow):
"-" if point.line_index is None else point.line_index,
self._format_coordinate(point.x),
self._format_coordinate(point.y),
- point.source_id,
point.row,
point.col,
"-" if point.line_id is None else point.line_id,
@@ -1863,6 +1966,7 @@ class MainWindow(QMainWindow):
"add_block": has_points and has_active_line,
"auto_blocks": has_points and has_active_line and has_selection,
"clockwise": has_active_line,
+ "counterclockwise": has_points and len(point_ids) == 1,
"rebuild_route": has_active_line,
"brush": has_points,
"delete": (has_selection and selected_has_assignment) or self._tree_has_focus_with_item(),
diff --git a/models.py b/models.py
index 112013e..5e52ffe 100644
--- a/models.py
+++ b/models.py
@@ -822,6 +822,12 @@ class MappingDocument:
group_ids = self.clockwise_block_from_seed_preview(start_point_id)
return self.add_clockwise_block_from_group(line_id, start_point_id, group_ids)
+ def add_counterclockwise_block_from_seed(self, line_id: int, start_point_id: int) -> LedBlock:
+ if not 0 <= start_point_id < len(self.points):
+ raise ValueError(f"Unknown LED point id: {start_point_id}")
+ group_ids = self.counterclockwise_block_from_seed_preview(start_point_id)
+ return self.add_clockwise_block_from_group(line_id, start_point_id, group_ids)
+
def add_clockwise_block_from_group(self, line_id: int, start_point_id: int, group_ids: list[int]) -> LedBlock:
if not 0 <= start_point_id < len(self.points):
raise ValueError(f"Unknown LED point id: {start_point_id}")
@@ -831,6 +837,12 @@ class MappingDocument:
return block
def clockwise_block_from_seed_preview(self, start_point_id: int) -> list[int]:
+ return self.route_block_from_seed_preview(start_point_id, direction="clockwise")
+
+ def counterclockwise_block_from_seed_preview(self, start_point_id: int) -> list[int]:
+ return self.route_block_from_seed_preview(start_point_id, direction="counterclockwise")
+
+ def route_block_from_seed_preview(self, start_point_id: int, *, direction: str = "clockwise") -> list[int]:
if not 0 <= start_point_id < len(self.points):
return []
candidate_points = [
@@ -846,11 +858,12 @@ class MappingDocument:
adjacency=adjacency,
allowed_ids={point.uid for point in candidate_points},
min_neighbor_distance=threshold,
+ direction=direction,
)
if not route:
return [start_point_id]
return rotate_ids_to_start(
- [point.uid for point in normalize_route_clockwise(route, threshold)],
+ [point.uid for point in normalize_route_direction(route, threshold, direction=direction)],
start_point_id,
)
@@ -917,11 +930,23 @@ def rotate_ids_to_start(values: list[int], start_value: int) -> list[int]:
def normalize_route_clockwise(route: list[LedPoint], min_neighbor_distance: float) -> list[LedPoint]:
+ return normalize_route_direction(route, min_neighbor_distance, direction="clockwise")
+
+
+def normalize_route_direction(
+ route: list[LedPoint],
+ min_neighbor_distance: float,
+ *,
+ direction: str = "clockwise",
+) -> list[LedPoint]:
if len(route) < 3:
return route[:]
if not is_valid_route_neighbor(route[-1], route[0], min_neighbor_distance):
return route[:]
- return list(reversed(route)) if route_signed_area(route) > 0 else route[:]
+ signed_area = route_signed_area(route)
+ if direction == "counterclockwise":
+ return list(reversed(route)) if signed_area < 0 else route[:]
+ return list(reversed(route)) if signed_area > 0 else route[:]
def route_signed_area(route: list[LedPoint]) -> float:
@@ -1185,6 +1210,7 @@ def trace_single_route_like_grid_svg(
adjacency: dict[int, list[LedPoint]] | None = None,
allowed_ids: set[int] | None = None,
min_neighbor_distance: float | None = None,
+ direction: str = "clockwise",
) -> list[LedPoint]:
if not points:
return []
@@ -1216,7 +1242,7 @@ def trace_single_route_like_grid_svg(
)
if not neighbors:
break
- next_point = choose_next_route_point(route, neighbors, min_neighbor_distance)
+ next_point = choose_next_route_point(route, neighbors, min_neighbor_distance, direction=direction)
route.append(next_point)
remaining.remove(next_point)
remaining_ids.remove(next_point.uid)
@@ -1251,9 +1277,11 @@ def choose_next_route_point(
route: list[LedPoint],
neighbors: list[LedPoint],
min_neighbor_distance: float,
+ *,
+ direction: str = "clockwise",
) -> LedPoint:
if len(route) < 2:
- return min(neighbors, key=lambda point: route_path_preference(route, point, min_neighbor_distance))
+ return min(neighbors, key=lambda point: route_path_preference(route, point, min_neighbor_distance, direction=direction))
neighbors = nearest_neighbor_band(route[-1], neighbors, min_neighbor_distance)
previous = route[-2]
@@ -1264,7 +1292,10 @@ def choose_next_route_point(
if same_direction_vector(previous, current, point)
]
if same_vector_direction:
- return min(same_vector_direction, key=lambda point: route_path_preference(route, point, min_neighbor_distance))
+ return min(
+ same_vector_direction,
+ key=lambda point: route_path_preference(route, point, min_neighbor_distance, direction=direction),
+ )
forward_direction = [
point
@@ -1272,9 +1303,12 @@ def choose_next_route_point(
if forward_direction_vector(previous, current, point)
]
if forward_direction:
- return min(forward_direction, key=lambda point: route_path_preference(route, point, min_neighbor_distance))
+ return min(
+ forward_direction,
+ key=lambda point: route_path_preference(route, point, min_neighbor_distance, direction=direction),
+ )
- return min(neighbors, key=lambda point: route_path_preference(route, point, min_neighbor_distance))
+ return min(neighbors, key=lambda point: route_path_preference(route, point, min_neighbor_distance, direction=direction))
def nearest_neighbor_band(
@@ -1538,6 +1572,8 @@ def route_path_preference(
route: list[LedPoint],
candidate: LedPoint,
min_distance: float,
+ *,
+ direction: str = "clockwise",
) -> tuple[int, float, int, int, float, tuple[int, int, float, float, int]]:
current = route[-1]
if len(route) < 2:
@@ -1552,7 +1588,7 @@ def route_path_preference(
)
previous = route[-2]
- turn_class, turn_rank = route_clockwise_turn_preference(previous, current, candidate)
+ turn_class, turn_rank = route_turn_preference(previous, current, candidate, direction=direction)
return (
turn_class,
turn_rank,
@@ -1565,6 +1601,16 @@ def route_path_preference(
def route_clockwise_turn_preference(previous: LedPoint, current: LedPoint, candidate: LedPoint) -> tuple[int, float]:
+ return route_turn_preference(previous, current, candidate, direction="clockwise")
+
+
+def route_turn_preference(
+ previous: LedPoint,
+ current: LedPoint,
+ candidate: LedPoint,
+ *,
+ direction: str = "clockwise",
+) -> tuple[int, float]:
incoming_x = current.x - previous.x
incoming_y = current.y - previous.y
outgoing_x = candidate.x - current.x
@@ -1576,6 +1622,10 @@ def route_clockwise_turn_preference(previous: LedPoint, current: LedPoint, candi
return 0, 0.0
if angle < math.radians(35):
return 0, angle
+ if direction == "counterclockwise":
+ if cross > epsilon:
+ return 1, -angle
+ return 2, angle
if cross < -epsilon:
return 1, -angle
return 2, angle
diff --git a/qa_smoke.py b/qa_smoke.py
index d0c3fe8..066aa1e 100644
--- a/qa_smoke.py
+++ b/qa_smoke.py
@@ -26,6 +26,7 @@ from app.models import (
from app.panel_mapping_generator import generate_panel_mapping_from_document
from app.unfolding import MAX_LINES
try:
+ import app.canvas as canvas_module
from app.canvas import LedCanvas
from app.background_dialog import BackgroundDialog
from app.icons import LUCIDE_PATHS
@@ -38,6 +39,7 @@ try:
from PySide6.QtGui import QColor, QImage, QKeySequence
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QGraphicsItem, QMessageBox, QToolBar
except ImportError:
+ canvas_module = None
LedCanvas = None
BackgroundDialog = None
LUCIDE_PATHS = {}
@@ -55,7 +57,7 @@ except ImportError:
light_stylesheet = None
SIDEBAR_COLLAPSED_WIDTH = 34
SIDEBAR_MIN_WIDTH = 180
- UID_COLUMN = 8
+ UID_COLUMN = 7
QPoint = None
QPointF = None
QRectF = None
@@ -108,6 +110,8 @@ def main() -> None:
test_clockwise_block_from_seed_starts_at_selected_point,
test_clockwise_block_from_seed_reverses_counterclockwise_corner,
test_clockwise_block_from_seed_uses_direction_priority,
+ test_counterclockwise_block_from_seed_reverses_clockwise_route,
+ test_counterclockwise_block_from_point_handler,
test_far_neighbor_is_not_valid_route_neighbor,
test_diagonal_grid_neighbor_is_valid_route_neighbor,
test_rebuild_line_blocks_splits_far_segments,
@@ -164,6 +168,11 @@ def main() -> None:
test_sidebar_action_toggle_updates_state_and_constraints,
test_sidebar_collapsed_tab_click_and_drag_expand,
test_canvas_select_points_skips_duplicate_selection_emit,
+ test_canvas_context_menu_can_request_edit_and_counterclockwise_block,
+ test_canvas_alt_drag_emits_point_move,
+ test_canvas_alt_drag_ignores_line_index_label_hit,
+ test_main_window_updates_point_from_canvas_drag,
+ test_main_window_edit_point_dialog_imports_localized_buttons,
test_canvas_pan_mode_helpers_follow_settings,
test_canvas_brush_cursor_survives_pan_cursor_reset,
test_canvas_brush_zone_is_visible_and_collects_points,
@@ -851,6 +860,62 @@ def test_clockwise_block_from_seed_uses_direction_priority() -> None:
assert doc.points[492].line_index == 0
+def test_counterclockwise_block_from_seed_reverses_clockwise_route() -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = Path(temp_dir) / "loop.json"
+ points = [
+ {"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
+ {"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1},
+ {"id": 2, "x": 68.0, "y": 0.0, "row": 0, "col": 2},
+ {"id": 3, "x": 68.0, "y": 34.0, "row": 1, "col": 2},
+ {"id": 4, "x": 68.0, "y": 68.0, "row": 2, "col": 2},
+ {"id": 5, "x": 34.0, "y": 68.0, "row": 2, "col": 1},
+ {"id": 6, "x": 0.0, "y": 68.0, "row": 2, "col": 0},
+ {"id": 7, "x": 0.0, "y": 34.0, "row": 1, "col": 0},
+ ]
+ path.write_text(json.dumps({"circles": points}), encoding="utf-8")
+ doc = MappingDocument()
+ doc.load(path)
+ line = doc.add_line()
+
+ clockwise = doc.add_clockwise_block_from_seed(line.id, 0).point_ids
+ doc.clear_all_assignments()
+ line = doc.add_line()
+ counterclockwise = doc.add_counterclockwise_block_from_seed(line.id, 0).point_ids
+
+ assert clockwise == [0, 7, 6, 5, 4, 3, 2, 1]
+ assert counterclockwise == [0, 1, 2, 3, 4, 5, 6, 7]
+
+
+def test_counterclockwise_block_from_point_handler() -> None:
+ if MainWindow is None or QApplication is None:
+ return
+ app = QApplication.instance() or QApplication([])
+ window = MainWindow()
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = Path(temp_dir) / "counter-handler.json"
+ points = [
+ {"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
+ {"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1},
+ {"id": 2, "x": 34.0, "y": 34.0, "row": 1, "col": 1},
+ {"id": 3, "x": 0.0, "y": 34.0, "row": 1, "col": 0},
+ ]
+ path.write_text(json.dumps({"circles": points}), encoding="utf-8")
+ doc = MappingDocument()
+ doc.load(path)
+ line = doc.add_line()
+ window.document = doc
+ window.active_line_id = line.id
+ window.canvas.set_document(doc)
+ window.canvas.set_active_line(line.id)
+ window._confirm_reassignment = lambda _point_ids: True
+
+ window.auto_block_from_point_counterclockwise(0)
+
+ assert doc.lines[0].blocks[0].point_ids == [0, 1, 2, 3]
+ window.close()
+
+
def test_far_neighbor_is_not_valid_route_neighbor() -> None:
current = LedPoint(0, 0, 0.0, 0.0, 0, 0)
far = LedPoint(1, 1, 100.0, 0.0, 1, 0)
@@ -1683,7 +1748,7 @@ def test_classic_menu_contains_expected_actions() -> None:
"Подложка",
"Скрыть боковую панель",
],
- "Инструменты": ["Новая линия", "Новый светодиод", "Сгенерировать скрипт", "Автоблоки", "Кисть"],
+ "Инструменты": ["Новая линия", "Новый светодиод", "Сгенерировать скрипт", "Автоблоки", "Против часовой", "Кисть"],
}
for top_action in list(window.menuBar().actions()):
menu = top_action.menu()
@@ -1780,6 +1845,7 @@ def test_main_window_actions_use_lucide_icons() -> None:
"add_block",
"auto_blocks",
"clockwise",
+ "counterclockwise",
"rebuild_route",
"brush",
"undo",
@@ -1832,6 +1898,7 @@ def test_main_window_action_shortcuts_are_assigned() -> None:
"add_block": "Ctrl+Shift+L",
"auto_blocks": "Ctrl+Shift+B",
"clockwise": "Ctrl+Alt+R",
+ "counterclockwise": "Ctrl+Alt+Shift+R",
"rebuild_route": "Ctrl+Shift+R",
"brush": "B",
}
@@ -2017,6 +2084,7 @@ def test_lucide_icon_names_are_declared() -> None:
"paintbrush",
"plus",
"redo-2",
+ "rotate-ccw",
"rotate-cw",
"save",
"save-all",
@@ -2036,7 +2104,6 @@ def test_table_uses_single_line_index_column() -> None:
"ID",
"X",
"Y",
- "Источник",
"Ряд",
"Кол.",
"Линия",
@@ -2056,7 +2123,7 @@ def test_table_uses_single_line_index_column() -> None:
app.processEvents()
assert window.table.item(0, 0).text() == "0"
assert window.table.item(1, 0).text() == "1"
- assert window.table.item(0, 8).text() == "0"
+ assert window.table.item(0, UID_COLUMN).text() == "0"
window._on_selection_changed([0, 4])
app.processEvents()
@@ -2364,6 +2431,172 @@ def test_canvas_select_points_skips_duplicate_selection_emit() -> None:
assert emitted == [[0, 1]]
+def test_canvas_context_menu_can_request_edit_and_counterclockwise_block() -> None:
+ if LedCanvas is None or QApplication is None or canvas_module is None:
+ return
+ app = QApplication.instance() or QApplication([])
+
+ class FakeAction:
+ def __init__(self, text: str) -> None:
+ self.text = text
+ self.enabled = True
+
+ def setEnabled(self, enabled: bool) -> None:
+ self.enabled = enabled
+
+ class FakeMenu:
+ next_choice = ""
+
+ def __init__(self, _parent=None) -> None:
+ self.actions: list[FakeAction] = []
+
+ def addAction(self, text: str) -> FakeAction:
+ action = FakeAction(text)
+ self.actions.append(action)
+ return action
+
+ def exec(self, _global_pos): # noqa: ANN001
+ return next(action for action in self.actions if action.text == self.next_choice)
+
+ class FakeContextEvent:
+ def pos(self) -> QPoint:
+ return QPoint(0, 0)
+
+ def globalPos(self) -> QPoint:
+ return QPoint(0, 0)
+
+ with temp_document_path() as path:
+ doc = MappingDocument()
+ doc.load(path)
+ canvas = LedCanvas()
+ canvas.set_document(doc)
+ canvas.select_points([0])
+ edited: list[int] = []
+ counterclockwise: list[int] = []
+ canvas.editPointRequested.connect(lambda point_id: edited.append(point_id))
+ canvas.counterclockwiseBlockFromPointRequested.connect(lambda point_id: counterclockwise.append(point_id))
+ original_menu = canvas_module.QMenu
+ canvas_module.QMenu = FakeMenu
+ try:
+ FakeMenu.next_choice = "Редактировать координаты"
+ canvas.contextMenuEvent(FakeContextEvent())
+ FakeMenu.next_choice = "Автоматически добавить блок против часовой"
+ canvas.contextMenuEvent(FakeContextEvent())
+ finally:
+ canvas_module.QMenu = original_menu
+ canvas.close()
+
+ assert edited == [0]
+ assert counterclockwise == [0]
+
+
+def test_canvas_alt_drag_emits_point_move() -> None:
+ if LedCanvas is None or QApplication is None:
+ return
+ app = QApplication.instance() or QApplication([])
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = Path(temp_dir) / "single-point.json"
+ path.write_text(
+ json.dumps({"circles": [{"id": 1, "x": 100.0, "y": 200.0, "row": 0, "col": 0}]}),
+ encoding="utf-8",
+ )
+ doc = MappingDocument()
+ doc.load(path)
+ canvas = LedCanvas()
+ canvas.resize(320, 240)
+ canvas.set_document(doc)
+ canvas.show()
+ app.processEvents()
+ start_pos = canvas.mapFromScene(canvas.items_by_id[0].pos())
+ moved: list[tuple[int, float, float]] = []
+ canvas.pointMoveFinished.connect(lambda point_id, x, y: moved.append((point_id, x, y)))
+
+ assert canvas._start_point_drag(start_pos)
+ canvas._finish_point_drag(start_pos + QPoint(30, -20))
+
+ assert moved
+ assert moved[0][0] == 0
+ assert abs(moved[0][1] - doc.points[0].x) > 1e-6 or abs(moved[0][2] - doc.points[0].y) > 1e-6
+ canvas.close()
+
+
+def test_canvas_alt_drag_ignores_line_index_label_hit() -> None:
+ if LedCanvas is None or QApplication is None:
+ return
+ app = QApplication.instance() or QApplication([])
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = Path(temp_dir) / "labeled-point.json"
+ path.write_text(
+ json.dumps({"circles": [{"id": 1, "x": 100.0, "y": 200.0, "row": 0, "col": 0}]}),
+ encoding="utf-8",
+ )
+ doc = MappingDocument()
+ doc.load(path)
+ line = doc.add_line()
+ doc.add_block(line.id, [0])
+ canvas = LedCanvas()
+ canvas.resize(320, 240)
+ canvas.set_document(doc)
+ canvas.set_active_line(line.id)
+ canvas.select_points([0])
+ canvas.show()
+ app.processEvents()
+
+ label = canvas.line_index_label_items[0]
+ assert not label.isEnabled()
+ assert label.acceptedMouseButtons() == Qt.NoButton
+
+ label_pos = canvas.mapFromScene(canvas.items_by_id[0].pos())
+ assert canvas._led_item_at(label_pos) is canvas.items_by_id[0]
+ assert canvas._start_point_drag(label_pos)
+ assert canvas._point_drag["point_id"] == 0
+ canvas._finish_point_drag(label_pos + QPoint(15, 0))
+ canvas.close()
+
+
+def test_main_window_updates_point_from_canvas_drag() -> None:
+ if MainWindow is None or QApplication is None:
+ return
+ app = QApplication.instance() or QApplication([])
+ window = MainWindow()
+ with temp_document_path() as path:
+ doc = MappingDocument()
+ doc.load(path)
+ window.document = doc
+ window.canvas.set_document(doc)
+ source_id = doc.points[0].source_id
+
+ assert window.move_point_from_canvas(0, doc.points[0].x + 12.5, doc.points[0].y + 7.25) is None
+
+ assert doc.points[0].source_id == source_id
+ assert abs(doc.points[0].x - (fixture_points()[0]["x"] + 12.5)) < 1e-6
+ assert abs(doc.points[0].y - (fixture_points()[0]["y"] + 7.25)) < 1e-6
+ assert window._dirty
+ assert window._undo_stack
+ window.close()
+
+
+def test_main_window_edit_point_dialog_imports_localized_buttons() -> None:
+ if MainWindow is None or QApplication is None:
+ return
+ import app.main_window as main_window_module
+
+ app = QApplication.instance() or QApplication([])
+ window = MainWindow()
+ with temp_document_path() as path:
+ doc = MappingDocument()
+ doc.load(path)
+ window.document = doc
+
+ original_exec = main_window_module.QDialog.exec
+ main_window_module.QDialog.exec = lambda _dialog: main_window_module.QDialog.Rejected
+ try:
+ window.edit_point_coordinates(0)
+ finally:
+ main_window_module.QDialog.exec = original_exec
+ window.close()
+
+
def test_canvas_pan_mode_helpers_follow_settings() -> None:
if LedCanvas is None or QApplication is None:
return
diff --git a/settings.py b/settings.py
index 6c662dd..d2d27a0 100644
--- a/settings.py
+++ b/settings.py
@@ -35,6 +35,7 @@ SHORTCUT_DEFINITIONS: list[tuple[str, str, str]] = [
("add_block", "Блок из выбора", "Ctrl+Shift+L"),
("auto_blocks", "Автоблоки", "Ctrl+Shift+B"),
("clockwise", "По часовой", "Ctrl+Alt+R"),
+ ("counterclockwise", "Против часовой", "Ctrl+Alt+Shift+R"),
("rebuild_route", "Пересобрать", "Ctrl+Shift+R"),
("brush", "Кисть", "B"),
]