LED-mapper/panel_mapping_generator.py
Danil Iarosh f2108e053f release: prepare LED Mapper 3.1.0
- исправить точное и атомарное сохранение схем
- добавить настройку шага сетки и абсолютного размера LED
- добавить пакетное удаление выбранных блоков
- исключить генерацию устаревшего INO-файла
- синхронизировать сетку обычного режима и режима редактирования
- устранить движение схемы при переключении режима редактирования
- расширить автоматические проверки до 158 сценариев
2026-09-08 14:13:00 +03:00

615 lines
26 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,
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"