feat: Add counterclockwise routing and LED editing

- Добавлено автоматическое построение блока против часовой стрелки.
- Добавлено редактирование координат LED через контекстное меню.
- Добавлено перемещение светодиодов через Alt + ЛКМ.
- Убран лишний столбец "Источник" из таблицы выбранных LED.
- Индексы LED сделаны визуальной подсказкой, которая не мешает кликам и перетаскиванию точек.
- Добавлены проверки для новых сценариев редактирования, маршрутизации, таблицы и сборочного имени.
This commit is contained in:
Данил Ярош 2026-06-11 08:49:58 +03:00
parent 207d6ca86c
commit 2832914343
6 changed files with 495 additions and 29 deletions

View File

@ -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()

View File

@ -23,6 +23,7 @@ LUCIDE_PATHS = {
"paintbrush": '<path d="m14.62 17.9-1.48 1.48a2.12 2.12 0 1 1-3-3l1.48-1.48"/><path d="m16.1 16.42 5.15-5.15a4.95 4.95 0 0 0-7-7L9.1 9.42"/><path d="m7.5 11 5.5 5.5"/><path d="m9 9-6 6v4h4l6-6"/>',
"plus": '<path d="M5 12h14"/><path d="M12 5v14"/>',
"redo-2": '<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5 5.5 5.5 0 0 0 9.5 20H13"/>',
"rotate-ccw": '<path d="M3 12a9 9 0 1 0 2.64-6.36L3 8"/><path d="M3 3v5h5"/>',
"rotate-cw": '<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8"/><path d="M21 3v5h-5"/>',
"save": '<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>',
"save-all": '<path d="M10 2v3a1 1 0 0 0 1 1h5"/><path d="M18 18v-6a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6"/><path d="M18 22H6a2 2 0 0 1-2-2V6"/><path d="M8 2h8l4 4v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2"/>',

View File

@ -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(),

View File

@ -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

View File

@ -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

View File

@ -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"),
]