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 \n") file.write("#include \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 \n\n") file.write("#ifndef ARDUINO\n#include \n#endif\n\n") file.write("#include \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 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 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"