#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.9"
# dependencies = [
#     "fonttools>=4.50",
# ]
# ///
"""
Generate SVGs for laser cutting a control-panel front plate.

The full plate carries 10 indicator lights. Because the laser bed (or the
acrylic stock) is not big enough for one tall plate, it is split into TWO
panels of 5 rows each that butt together vertically to form the big panel.

Layout (per panel):
  * 5 rows x 2 columns of 22 mm holes for indicator lights.
  * Holes are spaced 48.55 mm centre-to-centre in both X and Y.
  * To the right of each row, an engraved room name.

Tileability:
  The vertical gap between the first/last hole row and the panel edge is
  exactly HALF the gap between two inner rows. So when the two panels are
  stacked, the seam gap (half + half) equals a full inner gap and the
  hole pitch stays a perfect 48.55 mm across the join.

Text:
  Engraved room names are converted from the Ubuntu font into vector
  <path> outlines, so the target/laser machine does not need the font
  installed. Each name is vertically centred on its hole row.

Colour convention (LightBurn/Visicut style):
  * RED   strokes -> CUT
  * BLUE  fills   -> ENGRAVE

All coordinates and sizes are in millimetres (1 SVG user unit == 1 mm).
"""

import os
import sys

from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.ttLib import TTFont

# ---------------------------------------------------------------------------
# Parameters
# ---------------------------------------------------------------------------
HOLE_DIAMETER       = 22.0      # mm, indicator-light hole
HOLE_SPACING        = 48.55     # mm, centre-to-centre, both X and Y
N_ROWS_PER_PANEL    = 5
N_COLS              = 2

TEXT_HEIGHT         = 15.0      # mm, engraved cap/text height
TEXT_GAP            = 20.0      # mm, gap between last hole edge and text start
TEXT_WIDTH_EST      = 145.0     # mm, reserved horizontal room for the longest name

MARGIN              = 10.0      # mm, horizontal (left/right) border margin

CUT_COLOR           = "#FF0000"  # red  -> cut
ENGRAVE_COLOR       = "#0000FF"  # blue -> engrave
CUT_STROKE_W        = 0.1        # mm, hairline cut

# Candidate font files; first one found wins. Ubuntu first to match the
# original design, with a Polish-capable fallback.
FONT_CANDIDATES = [
    "/usr/share/fonts/ubuntu/Ubuntu-R.ttf",
    "/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf",
    "/usr/share/fonts/TTF/DejaVuSans.ttf",
    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]

# Room names (Polish), top row first. Taken from temp-at/at2.yaml `rooms`.
# Split into two panels of N_ROWS_PER_PANEL each.
ROOM_NAMES = [
    "Wejście",
    "Audytorium",
    "Korytarz",
    "Elelab",
    "Arts & Crafts",
    "Łazienka",
    "Magazynek",
    "Kuźnia",
    "CNC",
    "Maszynownia",
]

OUTPUT_PREFIX = "panel"  # writes panel_1.svg, panel_2.svg, ...


# ---------------------------------------------------------------------------
# Font / text-to-path
# ---------------------------------------------------------------------------
def _find_font() -> str:
    for path in FONT_CANDIDATES:
        if os.path.isfile(path):
            return path
    raise FileNotFoundError(
        "No usable font found. Tried:\n  " + "\n  ".join(FONT_CANDIDATES)
    )


def _cap_height(font, glyph_set, cmap, units_per_em: float) -> float:
    """Cap height in font units. Prefer the OS/2 sCapHeight metric; fall
    back to the measured height of an uppercase 'H'."""
    os2 = font.get("OS/2")
    if os2 is not None and getattr(os2, "sCapHeight", 0):
        return float(os2.sCapHeight)
    h_glyph = cmap.get(ord("H"))
    if h_glyph is not None:
        bp = BoundsPen(glyph_set)
        glyph_set[h_glyph].draw(bp)
        if bp.bounds is not None:
            return float(bp.bounds[3])
    return units_per_em * 0.7


def _load_font(path: str):
    font = TTFont(path)
    glyph_set = font.getGlyphSet()
    cmap = font.getBestCmap()
    units_per_em = font["head"].unitsPerEm
    cap_height = _cap_height(font, glyph_set, cmap, units_per_em)
    return glyph_set, cmap, units_per_em, cap_height


def text_to_path(
    text: str,
    x: float,
    cy: float,
    font_size: float,
    glyph_set,
    cmap,
    units_per_em: float,
    cap_height: float,
) -> str:
    """Return an SVG <path> element for `text`, left-anchored at `x` and
    vertically centred on `cy`.

    Centring uses a FIXED cap-height band (baseline -> cap height), not the
    per-string bounding box. That keeps every row's baseline at the same
    offset from its hole centre, so diacritics (e.g. the accent on Ś) and
    descenders extend beyond the band without shifting the line — exactly
    what you want when the names should read on a common baseline grid.

    The glyph outlines are emitted in raw font units; a single SVG transform
    scales them to `font_size`, flips the (font y-up) axis to SVG's y-down,
    positions the baseline start at `x`, and shifts so the cap-height band is
    centred on `cy`.
    """
    scale = font_size / units_per_em

    # Accumulate outline commands in font units, advancing the pen by each
    # glyph's advance width.
    path_pen = SVGPathPen(glyph_set)

    pen_x = 0.0
    for ch in text:
        glyph_name = cmap.get(ord(ch))
        if glyph_name is None:
            # Unknown character: advance by a space-ish gap, skip outline.
            pen_x += units_per_em * 0.3
            continue
        offset = (1, 0, 0, 1, pen_x, 0)
        glyph = glyph_set[glyph_name]
        glyph.draw(TransformPen(path_pen, offset))
        pen_x += glyph.width

    d = path_pen.getCommands()
    if not d:
        return ""

    # point (px, py) -> ( px*scale + tx , -py*scale + ty )
    # baseline start at x        =>  tx = x
    # cap-band centre maps to cy =>  ty = cy + (cap_height/2)*scale
    tx = x
    ty = cy + (cap_height / 2.0) * scale
    transform = (
        f"translate({tx:.4f} {ty:.4f}) scale({scale:.6f} {-scale:.6f})"
    )
    return (
        f'    <path transform="{transform}" '
        f'fill="{ENGRAVE_COLOR}" stroke="none" d="{d}" />'
    )


# ---------------------------------------------------------------------------
# Geometry
# ---------------------------------------------------------------------------
def build_panel_svg(names, glyph_set, cmap, units_per_em, cap_height) -> str:
    if len(names) != N_ROWS_PER_PANEL:
        raise ValueError(
            f"panel has {len(names)} names but N_ROWS_PER_PANEL is "
            f"{N_ROWS_PER_PANEL}"
        )

    radius = HOLE_DIAMETER / 2.0

    # Horizontal: hole edge sits MARGIN from the left border.
    first_cx = MARGIN + radius
    # Vertical: edge-to-first-row gap is half an inner row gap, so panels tile.
    first_cy = HOLE_SPACING / 2.0

    col_xs = [first_cx + c * HOLE_SPACING for c in range(N_COLS)]
    row_ys = [first_cy + r * HOLE_SPACING for r in range(N_ROWS_PER_PANEL)]

    last_col_x = col_xs[-1]
    text_x = last_col_x + radius + TEXT_GAP

    total_width = text_x + TEXT_WIDTH_EST + MARGIN
    # Half-gap top and bottom => total height is an exact multiple of pitch.
    total_height = N_ROWS_PER_PANEL * HOLE_SPACING

    # Visible-text height ~= 0.72 of em box for Ubuntu's cap height.
    font_size = TEXT_HEIGHT / 0.72

    parts = []
    parts.append(
        f'<svg xmlns="http://www.w3.org/2000/svg" '
        f'width="{total_width:.3f}mm" height="{total_height:.3f}mm" '
        f'viewBox="0 0 {total_width:.3f} {total_height:.3f}">'
    )

    # --- Engraved text as paths (blue) ------------------------------------
    parts.append('  <g id="engrave">')
    for r, name in enumerate(names):
        cy = row_ys[r]
        path_el = text_to_path(
            name.upper(), text_x, cy, font_size,
            glyph_set, cmap, units_per_em, cap_height,
        )
        if path_el:
            parts.append(path_el)
    parts.append('  </g>')

    # --- Cut holes + border (red) -----------------------------------------
    parts.append(
        f'  <g id="cut" fill="none" stroke="{CUT_COLOR}" '
        f'stroke-width="{CUT_STROKE_W}">'
    )
    for cy in row_ys:
        for cx in col_xs:
            parts.append(
                f'    <circle cx="{cx:.3f}" cy="{cy:.3f}" r="{radius:.3f}" />'
            )
    parts.append(
        f'    <rect x="0" y="0" '
        f'width="{total_width:.3f}" height="{total_height:.3f}" />'
    )
    parts.append('  </g>')

    parts.append('</svg>')
    return "\n".join(parts)


def main() -> None:
    if len(ROOM_NAMES) % N_ROWS_PER_PANEL != 0:
        raise ValueError(
            f"ROOM_NAMES ({len(ROOM_NAMES)}) is not a multiple of "
            f"N_ROWS_PER_PANEL ({N_ROWS_PER_PANEL})"
        )

    font_path = _find_font()
    glyph_set, cmap, units_per_em, cap_height = _load_font(font_path)
    print(f"Using font: {font_path}", file=sys.stderr)

    n_panels = len(ROOM_NAMES) // N_ROWS_PER_PANEL
    for i in range(n_panels):
        chunk = ROOM_NAMES[i * N_ROWS_PER_PANEL:(i + 1) * N_ROWS_PER_PANEL]
        svg = build_panel_svg(chunk, glyph_set, cmap, units_per_em, cap_height)
        out = f"{OUTPUT_PREFIX}_{i + 1}.svg"
        with open(out, "w", encoding="utf-8") as f:
            f.write(svg)
        print(f"Wrote {out}")


if __name__ == "__main__":
    main()
