LED-mapper/panel_mapping_generator.py
Danil Iarosh 8f814bc1fc Add block renaming in GUI and support block name-based addressing in generated scripts
- Реализовано переименование блоков по двойному клику и в контекстном меню дерева.
- Добавлена функция транслитерации и санитарной очистки имён блоков для C++ идентификаторов.
- Реализован массив строк имён блоков в flash-памяти (PROGMEM) и метод поиска getBlockIndexByName.
- Добавлены перегрузки функций управления светодиодами, принимающие оригинальные строковые названия блоков.
- Добавлены интеграционные тесты для проверки санитации имён, генерации макросов и функций поиска.
2026-07-31 11:07:12 +03:00

829 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
MappingDocument,
contour_groups_like_grid_svg as route_groups_like_grid_svg,
coordinate_display_scale,
min_neighbor_distance_for_points,
normalize_route_clockwise,
order_points_like_grid_svg,
)
except ImportError:
from models import (
LedPoint,
MappingDocument,
contour_groups_like_grid_svg as route_groups_like_grid_svg,
coordinate_display_scale,
min_neighbor_distance_for_points,
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"
@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 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",
"legacy_ino": output_dir / "virtual_matrix_legacy.ino.txt",
"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_legacy_ino(
generated_files["legacy_ino"],
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)
write_readme(generated_files["readme"])
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)
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)
route = normalize_route_clockwise(route, min_neighbor_distance_for_points(route))
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 {CELL_SIZE_MM:.1f}f\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_legacy_ino(
path: Path,
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(points)
# 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 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("// ============================================================================\n")
file.write("// ARCHIVE COPY - VIRTUAL MATRIX GENERATED BY LED Mapper GUI\n")
file.write("// Saved as .ino.txt so Arduino IDE will not compile it automatically.\n")
file.write("// ============================================================================\n\n")
file.write("#include <initializer_list>\n\n")
file.write("#ifndef ARDUINO\n#include <Arduino.h>\n#endif\n\n")
file.write("#include <Adafruit_NeoPixel.h>\n")
file.write("#ifndef NUM_STRIPS\n")
file.write(f"#define NUM_STRIPS {NUM_STRIPS}\n")
file.write("#endif\n")
file.write("extern Adafruit_NeoPixel hwStrips[NUM_STRIPS];\n\n")
if generate_grid:
file.write("#ifndef VM_MAX_PHYS_LEDS\n")
file.write(f"#define VM_MAX_PHYS_LEDS {max((point.led_id for point in points if point.led_id is not None), default=0) + 1}\n")
file.write("#endif\n")
file.write("#ifndef VM_MAX_IDS_PER_CELL\n")
file.write(f"#define VM_MAX_IDS_PER_CELL {MAX_LEDS_PER_CELL}\n")
file.write("#endif\n")
file.write("#ifndef MATRIX_ROWS\n")
file.write(f"#define MATRIX_ROWS {vm_rows}\n")
file.write("#endif\n")
file.write("#ifndef MATRIX_COLS\n")
file.write(f"#define MATRIX_COLS {vm_cols}\n")
file.write("#endif\n\n")
file.write("struct VMPhys { uint16_t idx; uint8_t strip; };\n\n")
file.write("static uint8_t vmCount[MATRIX_ROWS][MATRIX_COLS];\n")
file.write("static VMPhys vmCell[MATRIX_ROWS][MATRIX_COLS][VM_MAX_IDS_PER_CELL];\n")
file.write("static int16_t vmRow[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n")
file.write("static int16_t vmCol[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n")
file.write("static uint8_t vmUsed[NUM_STRIPS][VM_MAX_PHYS_LEDS];\n")
file.write("static uint16_t vmMaxId[NUM_STRIPS];\n\n")
file.write("void clearVirtualMatrix();\n")
file.write("void setLEDs(uint16_t row, uint16_t col, uint8_t strip1based, std::initializer_list<uint16_t> ids);\n")
file.write("void vmSetByPhys(uint8_t strip, uint16_t led, uint32_t color);\n")
file.write("void initVirtualMatrix();\n\n")
if generate_grid:
file.write("void clearVirtualMatrix() {\n")
file.write(" for (int r = 0; r < MATRIX_ROWS; ++r)\n")
file.write(" for (int c = 0; c < MATRIX_COLS; ++c) vmCount[r][c] = 0;\n")
file.write(" for (int s = 0; s < NUM_STRIPS; ++s) {\n")
file.write(" vmMaxId[s] = 0;\n")
file.write(" for (int i = 0; i < VM_MAX_PHYS_LEDS; ++i) {\n")
file.write(" vmRow[s][i] = -1;\n")
file.write(" vmCol[s][i] = -1;\n")
file.write(" vmUsed[s][i] = 0;\n")
file.write(" }\n")
file.write(" }\n")
file.write("}\n\n")
file.write("void setLEDs(uint16_t row, uint16_t col, uint8_t strip1based, std::initializer_list<uint16_t> ids) {\n")
file.write(" if (row >= MATRIX_ROWS || col >= MATRIX_COLS) return;\n")
file.write(" uint8_t strip = (strip1based == 0) ? 0 : (uint8_t)(strip1based - 1);\n")
file.write(" if (strip >= NUM_STRIPS) return;\n")
file.write(" for (uint16_t id : ids) {\n")
file.write(" if (id >= VM_MAX_PHYS_LEDS) continue;\n")
file.write(" uint8_t &count = vmCount[row][col];\n")
file.write(" if (count < VM_MAX_IDS_PER_CELL) {\n")
file.write(" vmCell[row][col][count].idx = id;\n")
file.write(" vmCell[row][col][count].strip = strip;\n")
file.write(" ++count;\n")
file.write(" }\n")
file.write(" vmRow[strip][id] = (int16_t)row;\n")
file.write(" vmCol[strip][id] = (int16_t)col;\n")
file.write(" vmUsed[strip][id] = 1;\n")
file.write(" if (id > vmMaxId[strip]) vmMaxId[strip] = id;\n")
file.write(" }\n")
file.write("}\n\n")
file.write("void vmSetByPhys(uint8_t strip, uint16_t led, uint32_t color) {\n")
file.write(" if (strip >= NUM_STRIPS || led >= VM_MAX_PHYS_LEDS) return;\n")
file.write(" int16_t row = vmRow[strip][led];\n")
file.write(" int16_t col = vmCol[strip][led];\n")
file.write(" if (row < 0 || col < 0) {\n")
file.write(" if (led < hwStrips[strip].numPixels()) hwStrips[strip].setPixelColor(led, color);\n")
file.write(" return;\n")
file.write(" }\n")
file.write(" uint8_t count = vmCount[row][col];\n")
file.write(" for (uint8_t i = 0; i < count; ++i) {\n")
file.write(" const VMPhys &phys = vmCell[row][col][i];\n")
file.write(" if (phys.strip < NUM_STRIPS && phys.idx < hwStrips[phys.strip].numPixels()) {\n")
file.write(" hwStrips[phys.strip].setPixelColor(phys.idx, color);\n")
file.write(" }\n")
file.write(" }\n")
file.write("}\n\n")
if generate_blocks:
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("#ifndef LED_BLOCK_COUNT\n")
file.write(f"#define LED_BLOCK_COUNT {block_count}\n")
file.write("#endif\n")
file.write("#ifndef TOTAL_BLOCK_LEDS\n")
file.write(f"#define TOTAL_BLOCK_LEDS {len(flat_block_leds)}\n")
file.write("#endif\n\n")
if not generate_grid:
file.write("struct VMPhys { uint16_t idx; uint8_t strip; };\n\n")
file.write("struct BlockRange { uint16_t offset; uint16_t count; };\n\n")
file.write("static const VMPhys 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.led_id}, {point.strip}}}{suffix} // block point\n")
file.write("};\n\n")
file.write("static 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"static const char BLOCK_NAME_{index}[] PROGMEM = \"{escaped_name}\";\n")
file.write("\n")
file.write("static 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")
file.write("void setBlockLEDsCount(uint16_t blockIdx, uint16_t count, uint32_t color);\n")
file.write("void setBlockLED(uint16_t blockIdx, uint16_t ledIdxInBlock, uint32_t color);\n")
file.write("int16_t getBlockIndexByName(const char* name);\n")
file.write("void setBlockLEDsCount(const char* name, uint16_t count, uint32_t color);\n")
file.write("void setBlockLED(const char* name, uint16_t ledIdxInBlock, uint32_t color);\n\n")
file.write("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(" 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].idx);\n")
file.write(" if (strip < NUM_STRIPS) {\n")
file.write(" hwStrips[strip].setPixelColor(led_id, color);\n")
file.write(" }\n")
file.write(" }\n")
file.write("}\n\n")
file.write("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].idx);\n")
file.write(" if (strip < NUM_STRIPS) {\n")
file.write(" hwStrips[strip].setPixelColor(led_id, color);\n")
file.write(" }\n")
file.write("}\n\n")
file.write("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")
file.write("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("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\n")
file.write("void initVirtualMatrix() {\n")
if generate_grid:
file.write(" clearVirtualMatrix();\n")
last_row = None
for row in range(vm_rows):
row_cells = [(col, cells[(row, col)]) for col in range(vm_cols) if (row, col) in cells]
if not row_cells:
continue
if last_row != row:
file.write(f"\n // Row {row}\n")
last_row = row
for col, cell_points in row_cells:
by_strip: dict[int, list[int]] = defaultdict(list)
for point in cell_points:
if point.strip is not None and point.led_id is not None:
by_strip[point.strip + 1].append(point.led_id)
for strip1based, led_ids_list in sorted(by_strip.items()):
led_ids = ", ".join(str(led_id) for led_id in led_ids_list)
file.write(f" setLEDs({row}, {col:2d}, {strip1based}, {{ {led_ids} }});\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,
"cell_size_mm": CELL_SIZE_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]) -> 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)
if gap > CELL_SIZE_MM + 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 > {CELL_SIZE_MM:g} mm: {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]) -> None:
lines = [
"Panel mapping validation",
"========================",
*collect_validation(points),
"",
]
path.write_text("\n".join(lines), encoding="utf-8")
def write_readme(path: Path) -> None:
path.write_text(
"\n".join(
[
"# Panel Mapping Build",
"",
"This folder contains files generated by the LED Mapper GUI:",
"- `vm_map.h` contains the PROGMEM virtual matrix mapping.",
"- `virtual_matrix_legacy.ino.txt` keeps the readable `setLEDs(...)` mapping.",
"- `ordered_circles.json` stores generated ids plus source row/col data.",
"- `validation_report.txt` records sequence and cell checks.",
"",
]
),
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"