- Перед генерацией скрипта текущая схема теперь обязательно сохраняется. - Для несохраненной схемы перед генерацией открывается диалог сохранения, а отмена останавливает генерацию. - ordered_circles.json теперь сохраняет полный документ с метаданными, линиями, блоками, подложкой и unfolding. - UID точек и point_ids блоков в ordered_circles.json пересчитываются под новый порядок генерации, а исходный id сохраняется в source_id. - Неизвестные поля исходного JSON сохраняются при обычном сохранении и при генерации ordered_circles.json. - Диалог выбора папки генерации теперь по умолчанию открывается в папке приложения. - Добавлены smoke-проверки на сохранение полей, ремап UID и стартовую папку генерации.
506 lines
22 KiB
Python
506 lines
22 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,
|
|
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 generate_panel_mapping_from_document(document: MappingDocument, output_dir: str | Path) -> 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, block_count)
|
|
write_legacy_ino(generated_files["legacy_ino"], mapping_points, cells)
|
|
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]],
|
|
block_count: int,
|
|
) -> 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")
|
|
|
|
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_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 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 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")
|
|
file.write(f"#define LED_BLOCK_COUNT {block_count}\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")
|
|
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")
|
|
|
|
|
|
def write_legacy_ino(
|
|
path: Path,
|
|
points: list[PanelLedPoint],
|
|
cells: dict[tuple[int, int], list[PanelLedPoint]],
|
|
) -> None:
|
|
vm_rows, vm_cols, _grid_rows, _grid_cols = get_dimensions(points)
|
|
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")
|
|
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) + 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")
|
|
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")
|
|
file.write("void initVirtualMatrix() {\n")
|
|
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"
|