From 28329143430735aea050cb04a4f7e32a808c3faa Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Thu, 11 Jun 2026 08:49:58 +0300 Subject: [PATCH] feat: Add counterclockwise routing and LED editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлено автоматическое построение блока против часовой стрелки. - Добавлено редактирование координат LED через контекстное меню. - Добавлено перемещение светодиодов через Alt + ЛКМ. - Убран лишний столбец "Источник" из таблицы выбранных LED. - Индексы LED сделаны визуальной подсказкой, которая не мешает кликам и перетаскиванию точек. - Добавлены проверки для новых сценариев редактирования, маршрутизации, таблицы и сборочного имени. --- canvas.py | 83 ++++++++++++++++- icons.py | 1 + main_window.py | 132 ++++++++++++++++++++++++--- models.py | 66 ++++++++++++-- qa_smoke.py | 241 ++++++++++++++++++++++++++++++++++++++++++++++++- settings.py | 1 + 6 files changed, 495 insertions(+), 29 deletions(-) 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"), ]