- исправить точное и атомарное сохранение схем - добавить настройку шага сетки и абсолютного размера LED - добавить пакетное удаление выбранных блоков - исключить генерацию устаревшего INO-файла - синхронизировать сетку обычного режима и режима редактирования - устранить движение схемы при переключении режима редактирования - расширить автоматические проверки до 158 сценариев
615 lines
26 KiB
Python
615 lines
26 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
from collections import defaultdict
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Iterable
|
||
|
||
try:
|
||
from .models import (
|
||
LedPoint,
|
||
MAX_NEIGHBOR_TOLERANCE_MM,
|
||
MappingDocument,
|
||
contour_groups_like_grid_svg as route_groups_like_grid_svg,
|
||
coordinate_display_scale,
|
||
normalize_route_clockwise,
|
||
order_points_like_grid_svg,
|
||
)
|
||
except ImportError:
|
||
from models import (
|
||
LedPoint,
|
||
MAX_NEIGHBOR_TOLERANCE_MM,
|
||
MappingDocument,
|
||
contour_groups_like_grid_svg as route_groups_like_grid_svg,
|
||
coordinate_display_scale,
|
||
normalize_route_clockwise,
|
||
order_points_like_grid_svg,
|
||
)
|
||
|
||
|
||
OUTPUT_UNIT = "mm"
|
||
CELL_SIZE_MM = 34.0
|
||
MAX_LEDS_PER_CELL = 3
|
||
NUM_STRIPS = 4
|
||
EMPTY_STRIP = "0xFF"
|
||
EMPTY_LED = "0xFFFF"
|
||
OBSOLETE_LEGACY_FILENAME = "virtual_matrix_legacy.ino.txt"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PanelLedPoint:
|
||
generated_id: int
|
||
source_id: int
|
||
input_index: int
|
||
strip: int | None
|
||
led_id: int | None
|
||
x_mm: float
|
||
y_mm: float
|
||
row: int
|
||
col: int
|
||
matrix_row: int
|
||
matrix_col: int
|
||
route_group: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class BuildResult:
|
||
output_dir: Path
|
||
input_unit: str
|
||
led_count: int
|
||
block_count: int
|
||
vm_rows: int
|
||
vm_cols: int
|
||
grid_rows: int
|
||
grid_cols: int
|
||
generated_files: dict[str, Path]
|
||
assigned_count: int
|
||
unassigned_count: int
|
||
|
||
|
||
def remove_obsolete_legacy_output(output_dir: Path) -> None:
|
||
"""Удалить только legacy-файл, созданный прежними версиями генератора."""
|
||
legacy_path = output_dir / OBSOLETE_LEGACY_FILENAME
|
||
if legacy_path.is_file() or legacy_path.is_symlink():
|
||
legacy_path.unlink()
|
||
|
||
|
||
def cpp_float_literal(value: float) -> str:
|
||
text = format(float(value), ".9g")
|
||
if "." not in text and "e" not in text.lower():
|
||
text += ".0"
|
||
return f"{text}f"
|
||
|
||
|
||
def sanitize_cpp_identifier(name: str) -> str:
|
||
# Transliteration dictionary for Russian Cyrillic
|
||
cyrillic_translit = {
|
||
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
|
||
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
|
||
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
|
||
'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'shch',
|
||
'ы': 'y', 'э': 'e', 'ю': 'yu', 'я': 'ya',
|
||
'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo',
|
||
'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'Y', 'К': 'K', 'Л': 'L', 'М': 'M',
|
||
'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U',
|
||
'Ф': 'F', 'Х': 'Kh', 'Ц': 'Ts', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Shch',
|
||
'Ы': 'Y', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya',
|
||
'ь': '', 'Ь': '', 'ъ': '', 'Ъ': ''
|
||
}
|
||
|
||
# Transliterate Cyrillic to Latin
|
||
transliterated = "".join(cyrillic_translit.get(c, c) for c in name)
|
||
|
||
# Replace non-alphanumeric chars with underscore
|
||
sanitized = ""
|
||
for c in transliterated:
|
||
if c.isalnum() or c == '_':
|
||
sanitized += c
|
||
else:
|
||
sanitized += '_'
|
||
|
||
# Remove multiple consecutive underscores
|
||
while "__" in sanitized:
|
||
sanitized = sanitized.replace("__", "_")
|
||
|
||
# Strip leading/trailing underscores
|
||
sanitized = sanitized.strip("_")
|
||
|
||
# Prepend underscore if starts with a digit
|
||
if sanitized and sanitized[0].isdigit():
|
||
sanitized = "_" + sanitized
|
||
|
||
# If empty, return a default prefix
|
||
if not sanitized:
|
||
sanitized = "BLOCK"
|
||
|
||
return sanitized
|
||
|
||
|
||
def generate_panel_mapping_from_document(
|
||
document: MappingDocument,
|
||
output_dir: str | Path,
|
||
*,
|
||
generate_grid: bool = True,
|
||
generate_blocks: bool = False,
|
||
) -> BuildResult:
|
||
if not document.points:
|
||
raise ValueError("Нет точек для генерации скрипта.")
|
||
|
||
output_dir = Path(output_dir).resolve()
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
input_unit = _input_unit_for_document(document)
|
||
points = normalize_document_points(document)
|
||
mapping_points = [point for point in points if point.strip is not None and point.led_id is not None]
|
||
if not mapping_points:
|
||
raise ValueError("Нет назначенных LED: перед генерацией распределите точки по линиям.")
|
||
cells = build_cells(mapping_points)
|
||
block_count = sum(len(line.blocks) for line in document.lines)
|
||
generated_files = {
|
||
"vm_map": output_dir / "vm_map.h",
|
||
"ordered_json": output_dir / "ordered_circles.json",
|
||
"validation_report": output_dir / "validation_report.txt",
|
||
"readme": output_dir / "README.md",
|
||
}
|
||
|
||
write_vm_map_header(
|
||
generated_files["vm_map"],
|
||
points,
|
||
mapping_points,
|
||
cells,
|
||
document,
|
||
generate_grid=generate_grid,
|
||
generate_blocks=generate_blocks,
|
||
)
|
||
write_ordered_json(generated_files["ordered_json"], points, document)
|
||
write_validation_report(generated_files["validation_report"], points, document.grid_step_mm)
|
||
write_readme(generated_files["readme"])
|
||
remove_obsolete_legacy_output(output_dir)
|
||
|
||
vm_rows, vm_cols, grid_rows, grid_cols = get_dimensions(points)
|
||
return BuildResult(
|
||
output_dir=output_dir,
|
||
input_unit=input_unit,
|
||
led_count=len(points),
|
||
block_count=block_count,
|
||
vm_rows=vm_rows,
|
||
vm_cols=vm_cols,
|
||
grid_rows=grid_rows,
|
||
grid_cols=grid_cols,
|
||
generated_files=generated_files,
|
||
assigned_count=len(mapping_points),
|
||
unassigned_count=len(points) - len(mapping_points),
|
||
)
|
||
|
||
|
||
def normalize_document_points(document: MappingDocument) -> list[PanelLedPoint]:
|
||
scale = _coordinate_scale_to_mm(document)
|
||
route_groups = route_groups_like_grid_svg(document.points)
|
||
min_neighbor_distance = document.grid_step_in_document_units()
|
||
ordered: list[tuple[LedPoint, int]] = []
|
||
for route_group_index, route_group in enumerate(route_groups, start=1):
|
||
route = order_points_like_grid_svg(
|
||
route_group,
|
||
min_neighbor_distance=min_neighbor_distance,
|
||
)
|
||
route = normalize_route_clockwise(route, min_neighbor_distance)
|
||
ordered.extend((point, route_group_index) for point in route)
|
||
|
||
min_row = min(point.row for point, _group in ordered)
|
||
max_col = max(point.col for point, _group in ordered)
|
||
return [
|
||
PanelLedPoint(
|
||
generated_id=index,
|
||
source_id=point.source_id,
|
||
input_index=point.uid,
|
||
strip=(point.line_id - 1) if point.line_id is not None else None,
|
||
led_id=point.line_index,
|
||
x_mm=point.x * scale,
|
||
y_mm=point.y * scale,
|
||
row=point.row,
|
||
col=point.col,
|
||
matrix_row=max_col - point.col,
|
||
matrix_col=point.row - min_row,
|
||
route_group=route_group_index,
|
||
)
|
||
for index, (point, route_group_index) in enumerate(ordered)
|
||
]
|
||
|
||
|
||
def build_cells(points: Iterable[PanelLedPoint]) -> dict[tuple[int, int], list[PanelLedPoint]]:
|
||
cells: dict[tuple[int, int], list[PanelLedPoint]] = defaultdict(list)
|
||
for point in points:
|
||
cells[(point.matrix_row, point.matrix_col)].append(point)
|
||
for cell_points in cells.values():
|
||
cell_points.sort(key=lambda point: point.generated_id)
|
||
return dict(cells)
|
||
|
||
|
||
def get_dimensions(points: Iterable[PanelLedPoint]) -> tuple[int, int, int, int]:
|
||
points = list(points)
|
||
vm_rows = max(point.matrix_row for point in points) + 1
|
||
vm_cols = max(point.matrix_col for point in points) + 1
|
||
grid_rows = max(point.row for point in points) - min(point.row for point in points) + 1
|
||
grid_cols = max(point.col for point in points) - min(point.col for point in points) + 1
|
||
return vm_rows, vm_cols, grid_rows, grid_cols
|
||
|
||
|
||
def get_source_bounds(points: Iterable[PanelLedPoint]) -> tuple[int, int, int, int]:
|
||
points = list(points)
|
||
return (
|
||
min(point.row for point in points),
|
||
max(point.row for point in points),
|
||
min(point.col for point in points),
|
||
max(point.col for point in points),
|
||
)
|
||
|
||
|
||
def format_int_row(values: Iterable[int]) -> str:
|
||
return "{ " + ", ".join(str(value) for value in values) + " }"
|
||
|
||
|
||
def write_vm_map_header(
|
||
path: Path,
|
||
all_points: list[PanelLedPoint],
|
||
mapping_points: list[PanelLedPoint],
|
||
cells: dict[tuple[int, int], list[PanelLedPoint]],
|
||
document: MappingDocument,
|
||
generate_grid: bool = True,
|
||
generate_blocks: bool = False,
|
||
) -> None:
|
||
vm_rows, vm_cols, grid_rows, grid_cols = get_dimensions(all_points)
|
||
min_row, max_row, min_col, max_col = get_source_bounds(all_points)
|
||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
# Collect blocks info
|
||
all_blocks = []
|
||
for line in document.lines:
|
||
all_blocks.extend(line.blocks)
|
||
block_count = len(all_blocks)
|
||
|
||
point_by_input_index = {p.input_index: p for p in all_points}
|
||
flat_block_leds = []
|
||
block_ranges = []
|
||
for block in all_blocks:
|
||
block_points = []
|
||
for point_uid in block.point_ids:
|
||
p = point_by_input_index.get(point_uid)
|
||
if p is not None and p.strip is not None and p.led_id is not None:
|
||
block_points.append(p)
|
||
|
||
offset = len(flat_block_leds)
|
||
count = len(block_points)
|
||
flat_block_leds.extend(block_points)
|
||
block_ranges.append((offset, count, block.name))
|
||
|
||
with path.open("w", encoding="utf-8", newline="\n") as file:
|
||
file.write("#pragma once\n")
|
||
file.write("// Auto-generated by LED Mapper GUI\n")
|
||
file.write(f"// Date: {timestamp}\n")
|
||
file.write("// Virtual matrix mapping plus physical LED grid-cell metadata.\n\n")
|
||
file.write("#include <stdint.h>\n")
|
||
file.write("#include <Arduino.h>\n\n")
|
||
|
||
file.write(f"#define VM_NUM_STRIPS {NUM_STRIPS}\n")
|
||
file.write(f"#define LED_COUNT {len(mapping_points)}\n")
|
||
file.write(f"#define SOURCE_POINT_COUNT {len(all_points)}\n")
|
||
file.write(f"#define LED_BLOCK_COUNT {block_count}\n\n")
|
||
|
||
if generate_grid:
|
||
file.write(f"#define VM_ROWS {vm_rows}\n")
|
||
file.write(f"#define VM_COLS {vm_cols}\n")
|
||
file.write("#define VM_CELLS (VM_ROWS * VM_COLS)\n")
|
||
file.write(f"#define VM_MAX_IDS_PER_CELL {MAX_LEDS_PER_CELL}\n")
|
||
file.write(f"#define GRID_ROWS {grid_rows}\n")
|
||
file.write(f"#define GRID_COLS {grid_cols}\n")
|
||
file.write(f"#define GRID_ROW_MIN {min_row}\n")
|
||
file.write(f"#define GRID_ROW_MAX {max_row}\n")
|
||
file.write(f"#define GRID_COL_MIN {min_col}\n")
|
||
file.write(f"#define GRID_COL_MAX {max_col}\n")
|
||
file.write(f"#define GRID_CELL_SIZE_MM {cpp_float_literal(document.grid_step_mm)}\n\n")
|
||
file.write("// Source grid -> virtual matrix orientation used by this file.\n")
|
||
file.write("// VM row is the source column flipped from right to left.\n")
|
||
file.write("// VM column is the source row shifted to zero-based coordinates.\n")
|
||
file.write("#define VM_ROW_FROM_GRID_COL(col) (GRID_COL_MAX - (col))\n")
|
||
file.write("#define VM_COL_FROM_GRID_ROW(row) ((row) - GRID_ROW_MIN)\n")
|
||
file.write("#define VM_GRID_ROW_FROM_VM_COL(col) ((col) + GRID_ROW_MIN)\n")
|
||
file.write("#define VM_GRID_COL_FROM_VM_ROW(row) (GRID_COL_MAX - (row))\n\n")
|
||
|
||
file.write("struct LEDMap {\n")
|
||
file.write(" uint8_t strip;\n")
|
||
file.write(" uint16_t led_id;\n")
|
||
file.write("};\n\n")
|
||
|
||
if generate_grid:
|
||
file.write("struct GridCellRef {\n")
|
||
file.write(" uint8_t row;\n")
|
||
file.write(" uint8_t col;\n")
|
||
file.write("};\n\n")
|
||
|
||
file.write("// Number of LEDs in each virtual cell VM[row][col].\n")
|
||
file.write("const uint8_t PROGMEM VM_LED_COUNT[VM_ROWS][VM_COLS] = {\n")
|
||
for row in range(vm_rows):
|
||
counts = [len(cells.get((row, col), [])) for col in range(vm_cols)]
|
||
suffix = "," if row < vm_rows - 1 else ""
|
||
file.write(f" {format_int_row(counts)}{suffix}\n")
|
||
file.write("};\n\n")
|
||
|
||
file.write("// LED mappings for all virtual cells. Empty slots use {0xFF, 0xFFFF}.\n")
|
||
file.write("const LEDMap PROGMEM VM_MAPPINGS[VM_ROWS][VM_COLS][3] = {\n")
|
||
for row in range(vm_rows):
|
||
file.write(" {\n")
|
||
for col in range(vm_cols):
|
||
cell_points = cells.get((row, col), [])
|
||
entries = []
|
||
for slot in range(MAX_LEDS_PER_CELL):
|
||
if slot < len(cell_points):
|
||
point = cell_points[slot]
|
||
entries.append(f"{{{point.strip}, {point.led_id}}}")
|
||
else:
|
||
entries.append(f"{{{EMPTY_STRIP}, {EMPTY_LED}}}")
|
||
suffix = "," if col < vm_cols - 1 else ""
|
||
file.write(f" {{ {', '.join(entries)} }}{suffix}\n")
|
||
suffix = "," if row < vm_rows - 1 else ""
|
||
file.write(f" }}{suffix}\n")
|
||
file.write("};\n\n")
|
||
|
||
file.write("// Original 1-based grid cell for each source point id.\n")
|
||
file.write("const GridCellRef PROGMEM LED_GRID_CELL_1BASED[SOURCE_POINT_COUNT] = {\n")
|
||
for index, point in enumerate(all_points):
|
||
suffix = "," if index < len(all_points) - 1 else ""
|
||
file.write(f" {{{point.row}, {point.col}}}{suffix} // id {point.generated_id}\n")
|
||
file.write("};\n\n")
|
||
|
||
file.write("// Zero-based virtual matrix cell for each source point id.\n")
|
||
file.write("const GridCellRef PROGMEM LED_GRID_CELL_ZERO_BASED[SOURCE_POINT_COUNT] = {\n")
|
||
for index, point in enumerate(all_points):
|
||
suffix = "," if index < len(all_points) - 1 else ""
|
||
file.write(f" {{{point.matrix_row}, {point.matrix_col}}}{suffix} // id {point.generated_id}\n")
|
||
file.write("};\n\n")
|
||
|
||
file.write("// Source ids from the input JSON, indexed by source point id.\n")
|
||
file.write("const uint16_t PROGMEM LED_SOURCE_ID[SOURCE_POINT_COUNT] = {\n")
|
||
for start in range(0, len(all_points), 16):
|
||
values = [point.source_id for point in all_points[start : start + 16]]
|
||
suffix = "," if start + 16 < len(all_points) else ""
|
||
file.write(f" {', '.join(str(value) for value in values)}{suffix}\n")
|
||
file.write("};\n\n")
|
||
|
||
if generate_blocks:
|
||
# Generate compile-time block macros/constants
|
||
file.write("// Compile-time block indices\n")
|
||
for index, (offset, count, name) in enumerate(block_ranges):
|
||
identifier = sanitize_cpp_identifier(name)
|
||
file.write(f"#define BLOCK_{identifier} {index}\n")
|
||
file.write("\n")
|
||
|
||
file.write(f"#define TOTAL_BLOCK_LEDS {len(flat_block_leds)}\n\n")
|
||
file.write("struct BlockRange {\n")
|
||
file.write(" uint16_t offset;\n")
|
||
file.write(" uint16_t count;\n")
|
||
file.write("};\n\n")
|
||
|
||
file.write("// Flat list of all LEDs in blocks, ordered by block, and within block\n")
|
||
file.write("const LEDMap PROGMEM BLOCK_LEDS[TOTAL_BLOCK_LEDS] = {\n")
|
||
for index, point in enumerate(flat_block_leds):
|
||
suffix = "," if index < len(flat_block_leds) - 1 else ""
|
||
file.write(f" {{{point.strip}, {point.led_id}}}{suffix} // block point\n")
|
||
file.write("};\n\n")
|
||
|
||
file.write("// Start offset and length for each block\n")
|
||
file.write("const BlockRange PROGMEM BLOCK_RANGES[LED_BLOCK_COUNT] = {\n")
|
||
for index, (offset, count, name) in enumerate(block_ranges):
|
||
suffix = "," if index < len(block_ranges) - 1 else ""
|
||
file.write(f" {{{offset}, {count}}}{suffix} // {name}\n")
|
||
file.write("};\n\n")
|
||
|
||
# String names array in PROGMEM
|
||
file.write("// Original string names of blocks in flash memory\n")
|
||
for index, (offset, count, name) in enumerate(block_ranges):
|
||
escaped_name = name.replace('"', '\\"')
|
||
file.write(f"const char BLOCK_NAME_{index}[] PROGMEM = \"{escaped_name}\";\n")
|
||
file.write("\n")
|
||
|
||
file.write("const char* const BLOCK_NAMES[LED_BLOCK_COUNT] PROGMEM = {\n")
|
||
for index in range(len(block_ranges)):
|
||
suffix = "," if index < len(block_ranges) - 1 else ""
|
||
file.write(f" BLOCK_NAME_{index}{suffix}\n")
|
||
file.write("};\n\n")
|
||
|
||
# Inline helpers for blocks
|
||
file.write("// Control block LEDs by count inside a block\n")
|
||
file.write("inline void setBlockLEDsCount(uint16_t blockIdx, uint16_t count, uint32_t color) {\n")
|
||
file.write(" if (blockIdx >= LED_BLOCK_COUNT) return;\n")
|
||
file.write(" uint16_t offset = pgm_read_word(&BLOCK_RANGES[blockIdx].offset);\n")
|
||
file.write(" uint16_t total_count = pgm_read_word(&BLOCK_RANGES[blockIdx].count);\n")
|
||
file.write(" if (count > total_count) count = total_count;\n")
|
||
file.write(f" extern Adafruit_NeoPixel hwStrips[VM_NUM_STRIPS];\n")
|
||
file.write(" for (uint16_t i = 0; i < count; i++) {\n")
|
||
file.write(" uint8_t strip = pgm_read_byte(&BLOCK_LEDS[offset + i].strip);\n")
|
||
file.write(" uint16_t led_id = pgm_read_word(&BLOCK_LEDS[offset + i].led_id);\n")
|
||
file.write(" if (strip < VM_NUM_STRIPS) {\n")
|
||
file.write(" hwStrips[strip].setPixelColor(led_id, color);\n")
|
||
file.write(" }\n")
|
||
file.write(" }\n")
|
||
file.write("}\n\n")
|
||
|
||
file.write("// Control single block LED by 0-based index inside a block\n")
|
||
file.write("inline void setBlockLED(uint16_t blockIdx, uint16_t ledIdxInBlock, uint32_t color) {\n")
|
||
file.write(" if (blockIdx >= LED_BLOCK_COUNT) return;\n")
|
||
file.write(" uint16_t offset = pgm_read_word(&BLOCK_RANGES[blockIdx].offset);\n")
|
||
file.write(" uint16_t count = pgm_read_word(&BLOCK_RANGES[blockIdx].count);\n")
|
||
file.write(" if (ledIdxInBlock >= count) return;\n")
|
||
file.write(" uint8_t strip = pgm_read_byte(&BLOCK_LEDS[offset + ledIdxInBlock].strip);\n")
|
||
file.write(" uint16_t led_id = pgm_read_word(&BLOCK_LEDS[offset + ledIdxInBlock].led_id);\n")
|
||
file.write(f" extern Adafruit_NeoPixel hwStrips[VM_NUM_STRIPS];\n")
|
||
file.write(" if (strip < VM_NUM_STRIPS) {\n")
|
||
file.write(" hwStrips[strip].setPixelColor(led_id, color);\n")
|
||
file.write(" }\n")
|
||
file.write("}\n\n")
|
||
|
||
# Add getBlockIndexByName
|
||
file.write("// Find block index by its string name (case-sensitive, original name)\n")
|
||
file.write("inline int16_t getBlockIndexByName(const char* name) {\n")
|
||
file.write(" for (uint16_t i = 0; i < LED_BLOCK_COUNT; i++) {\n")
|
||
file.write(" const char* blockNamePtr = (const char*)pgm_read_word(&BLOCK_NAMES[i]);\n")
|
||
file.write(" if (strcmp_P(name, blockNamePtr) == 0) {\n")
|
||
file.write(" return (int16_t)i;\n")
|
||
file.write(" }\n")
|
||
file.write(" }\n")
|
||
file.write(" return -1;\n")
|
||
file.write("}\n\n")
|
||
|
||
# Add string-based control overloads
|
||
file.write("// Control block LEDs by string name and count\n")
|
||
file.write("inline void setBlockLEDsCount(const char* name, uint16_t count, uint32_t color) {\n")
|
||
file.write(" int16_t idx = getBlockIndexByName(name);\n")
|
||
file.write(" if (idx >= 0) {\n")
|
||
file.write(" setBlockLEDsCount((uint16_t)idx, count, color);\n")
|
||
file.write(" }\n")
|
||
file.write("}\n\n")
|
||
|
||
file.write("// Control single block LED by block string name and 0-based index\n")
|
||
file.write("inline void setBlockLED(const char* name, uint16_t ledIdxInBlock, uint32_t color) {\n")
|
||
file.write(" int16_t idx = getBlockIndexByName(name);\n")
|
||
file.write(" if (idx >= 0) {\n")
|
||
file.write(" setBlockLED((uint16_t)idx, ledIdxInBlock, color);\n")
|
||
file.write(" }\n")
|
||
file.write("}\n")
|
||
|
||
|
||
def write_ordered_json(path: Path, points: list[PanelLedPoint], document: MappingDocument) -> None:
|
||
point_id_map = {point.input_index: point.generated_id for point in points}
|
||
point_payloads = []
|
||
for point in points:
|
||
source_point = document.points[point.input_index]
|
||
payload = document.point_to_payload(source_point, id_value=point.generated_id)
|
||
payload.update(
|
||
{
|
||
"source_id": point.source_id,
|
||
"input_index": point.input_index,
|
||
"strip": point.strip,
|
||
"led_id": point.led_id,
|
||
"x": round(point.x_mm, 6),
|
||
"y": round(point.y_mm, 6),
|
||
"row": point.row,
|
||
"col": point.col,
|
||
"matrix_row": point.matrix_row,
|
||
"matrix_col": point.matrix_col,
|
||
"route_group": point.route_group,
|
||
}
|
||
)
|
||
point_payloads.append(payload)
|
||
|
||
payload = document.to_payload(
|
||
point_payloads=point_payloads,
|
||
lines_payload=document.lines_to_payload(point_id_map=point_id_map),
|
||
collection_name=document.collection_name,
|
||
)
|
||
payload.update(
|
||
{
|
||
"source": str(document.path) if document.path is not None else "LED Mapper GUI",
|
||
"unit": OUTPUT_UNIT,
|
||
"coordinate_unit": OUTPUT_UNIT,
|
||
"cell_size_mm": document.grid_step_mm,
|
||
"ordered_id_mode": "generated_uid",
|
||
}
|
||
)
|
||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
|
||
|
||
def collect_validation(
|
||
points: list[PanelLedPoint],
|
||
cell_size_mm: float = CELL_SIZE_MM,
|
||
) -> list[str]:
|
||
lines = []
|
||
distance_violations = []
|
||
cell_violations = []
|
||
for current, next_point in zip(points, points[1:]):
|
||
if current.route_group != next_point.route_group:
|
||
continue
|
||
gap = math.hypot(current.x_mm - next_point.x_mm, current.y_mm - next_point.y_mm)
|
||
diagonal = abs(current.row - next_point.row) == 1 and abs(current.col - next_point.col) == 1
|
||
distance_limit = cell_size_mm * (math.sqrt(2.0) if diagonal else 1.0) + MAX_NEIGHBOR_TOLERANCE_MM
|
||
if gap > distance_limit + 1e-6:
|
||
distance_violations.append((current, next_point, gap))
|
||
if abs(current.row - next_point.row) > 1 or abs(current.col - next_point.col) > 1:
|
||
cell_violations.append((current, next_point))
|
||
|
||
duplicate_ids = len({point.generated_id for point in points}) != len(points)
|
||
max_per_cell = max(len(cell_points) for cell_points in build_cells(points).values())
|
||
lines.append(f"LED count: {len(points)}")
|
||
lines.append(f"Generated ids contiguous: {not duplicate_ids}")
|
||
lines.append(f"Max LEDs per cell: {max_per_cell}")
|
||
lines.append(f"Same-route distance violations beyond grid step + tolerance: {len(distance_violations)}")
|
||
lines.append(f"Same-route 8-neighbor cell violations: {len(cell_violations)}")
|
||
if max_per_cell > MAX_LEDS_PER_CELL:
|
||
lines.append(f"ERROR: max LEDs per cell exceeds {MAX_LEDS_PER_CELL}")
|
||
if distance_violations:
|
||
lines.append("")
|
||
lines.append("First distance violations:")
|
||
for current, next_point, gap in distance_violations[:20]:
|
||
lines.append(f" {current.generated_id}->{next_point.generated_id}: {gap:.2f} mm, cells ({current.row},{current.col})->({next_point.row},{next_point.col})")
|
||
if cell_violations:
|
||
lines.append("")
|
||
lines.append("First cell-neighbor violations:")
|
||
for current, next_point in cell_violations[:20]:
|
||
lines.append(f" {current.generated_id}->{next_point.generated_id}: cells ({current.row},{current.col})->({next_point.row},{next_point.col})")
|
||
return lines
|
||
|
||
|
||
def write_validation_report(
|
||
path: Path,
|
||
points: list[PanelLedPoint],
|
||
cell_size_mm: float = CELL_SIZE_MM,
|
||
) -> None:
|
||
lines = [
|
||
"Panel mapping validation",
|
||
"========================",
|
||
*collect_validation(points, cell_size_mm),
|
||
"",
|
||
]
|
||
path.write_text("\n".join(lines), encoding="utf-8")
|
||
|
||
|
||
def write_readme(path: Path) -> None:
|
||
path.write_text(
|
||
"\n".join(
|
||
[
|
||
"# Результат генерации Panel Mapping",
|
||
"",
|
||
"LED Mapper создал следующие файлы:",
|
||
"- `vm_map.h` — маппинг виртуальной матрицы в формате PROGMEM.",
|
||
"- `ordered_circles.json` — нормализованные LED и координаты матрицы.",
|
||
"- `validation_report.txt` — результаты проверки маршрутов и ячеек.",
|
||
"- `README.md` — описание набора файлов.",
|
||
"",
|
||
"Устаревший `virtual_matrix_legacy.ino.txt` больше не создаётся.",
|
||
"",
|
||
]
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
|
||
def _coordinate_scale_to_mm(document: MappingDocument) -> float:
|
||
unit = _input_unit_for_document(document)
|
||
if unit == "m":
|
||
return 1000.0
|
||
if unit == "cm":
|
||
return 10.0
|
||
if unit == "mm":
|
||
return 1.0
|
||
return coordinate_display_scale(document.points)
|
||
|
||
|
||
def _input_unit_for_document(document: MappingDocument) -> str:
|
||
normalized = str(document.coordinate_unit).lower()
|
||
if normalized in {"m", "cm", "mm"}:
|
||
return normalized
|
||
if hasattr(document, "_coordinate_unit_for_save"):
|
||
return document._coordinate_unit_for_save()
|
||
return "m" if coordinate_display_scale(document.points) == 1000.0 else "mm"
|