Add portal web simulator, SPI bridges, and Pico firmware updates.

Bring the five-panel hex portal online with a browser 3D/schematic preview, Pi SPI backends, and renamed multi-panel Pico UDP firmware.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-30 14:54:51 +12:00
parent d5cab2efdf
commit 5094c7bcee
78 changed files with 62251 additions and 832 deletions

View File

@@ -1,7 +1,8 @@
"""5×7 bitmap text for LED matrices."""
"""5×7 bitmap text and optional TrueType (cursive) rendering for LED matrices."""
from __future__ import annotations
from pathlib import Path
from typing import Tuple
from leds.array_config import MATRIX_HEIGHT, MATRIX_WIDTH, matrix_pixel_index
@@ -53,6 +54,15 @@ CHAR_WIDTH = 5
CHAR_HEIGHT = 7
CHAR_SPACING = 1
CURSIVE_FONT_CANDIDATES = (
# Prefer readable italics at 9px; true script fonts turn to mush.
"/usr/share/fonts/truetype/liberation/LiberationSerif-Italic.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSerif-Italic.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf",
"/usr/share/fonts/opentype/dancingscript/DancingScript-Bold.otf",
"/usr/share/fonts/opentype/dancingscript/DancingScript-Regular.otf",
)
def text_width(text: str, spacing: int = CHAR_SPACING) -> int:
text = text.upper()
@@ -122,3 +132,160 @@ def draw_text_centered(
x = max(0, (w - tw) // 2)
y = max(0, (h - th) // 2)
draw_text(surface, text, x, y, color, scale=scale, spacing=spacing)
def resolve_cursive_font(path: str | None = None) -> str:
if path:
if not Path(path).is_file():
raise FileNotFoundError(f"font not found: {path}")
return path
for candidate in CURSIVE_FONT_CANDIDATES:
if Path(candidate).is_file():
return candidate
raise FileNotFoundError(
"No cursive font found. Install with: sudo apt install fonts-dancingscript"
)
def render_font_mask(
text: str,
*,
height: int = MATRIX_HEIGHT,
font_path: str | None = None,
size: int | None = None,
threshold: int = 100,
letter_spacing: int = 1,
) -> tuple[int, int, list[tuple[int, int]]]:
"""Rasterize ``text`` into on-pixels for an LED row height.
Letters are drawn one-by-one with spacing so names stay readable on
short matrices. Returns ``(width, height, [(x, y), ...])``.
"""
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError as exc:
raise ImportError(
"Pillow is required for cursive text (pipenv install pillow)"
) from exc
path = resolve_cursive_font(font_path)
# Slightly larger than panel height reads better after vertical fit.
point_size = size if size is not None else max(10, height + 3)
font = ImageFont.truetype(path, point_size)
glyphs: list[Image.Image] = []
for ch in text:
bbox = font.getbbox(ch)
gw = max(1, bbox[2] - bbox[0])
gh = max(1, bbox[3] - bbox[1])
g = Image.new("L", (gw + 2, height), 0)
draw = ImageDraw.Draw(g)
y = (height - gh) // 2 - bbox[1]
draw.text((1 - bbox[0], y), ch, font=font, fill=255)
glyphs.append(g)
total_w = sum(g.width for g in glyphs)
if glyphs:
total_w += letter_spacing * (len(glyphs) - 1)
canvas = Image.new("L", (max(1, total_w), height), 0)
x = 0
for i, g in enumerate(glyphs):
canvas.paste(g.point(lambda v: 255 if v > threshold else 0), (x, 0))
x += g.width + (letter_spacing if i < len(glyphs) - 1 else 0)
pixels = canvas.load()
xs = [
xx
for xx in range(canvas.width)
for yy in range(height)
if pixels[xx, yy] > 0
]
if not xs:
return 0, height, []
left, right = min(xs), max(xs) + 1
ons: list[tuple[int, int]] = []
for yy in range(height):
for xx in range(left, right):
if pixels[xx, yy] > 0:
ons.append((xx - left, yy))
return right - left, height, ons
def blit_mask(
surface,
ons: list[tuple[int, int]],
x: int,
y: int,
color: RGB,
) -> None:
for px, py in ons:
_set(surface, x + px, y + py, color)
def blit_mask_gradient(
surface,
ons: list[tuple[int, int]],
x: int,
y: int,
*,
width: int,
phase: float = 0.0,
cycles: float = 2.0,
) -> None:
"""Blit mask with a horizontal rainbow (hue shifts with ``phase``).
``cycles`` = how many full rainbows span the text width (higher = more colorful).
"""
from leds.colors import hsv_to_rgb
span = max(1, width)
for px, py in ons:
hue = ((px / span) * cycles + phase) % 1.0
_set(surface, x + px, y + py, hsv_to_rgb(hue, 1.0, 1.0))
def draw_font_text(
surface,
text: str,
x: int,
y: int,
color: RGB,
*,
font_path: str | None = None,
size: int | None = None,
letter_spacing: int = 1,
) -> int:
"""Draw TrueType text; returns rendered width in pixels."""
h = getattr(surface, "height", MATRIX_HEIGHT)
width, _, ons = render_font_mask(
text,
height=h,
font_path=font_path,
size=size,
letter_spacing=letter_spacing,
)
blit_mask(surface, ons, x, y, color)
return width
def draw_font_text_centered(
surface,
text: str,
color: RGB,
*,
font_path: str | None = None,
size: int | None = None,
letter_spacing: int = 1,
) -> int:
w = getattr(surface, "width", MATRIX_WIDTH)
h = getattr(surface, "height", MATRIX_HEIGHT)
width, _, ons = render_font_mask(
text,
height=h,
font_path=font_path,
size=size,
letter_spacing=letter_spacing,
)
x = max(0, (w - width) // 2)
blit_mask(surface, ons, x, 0, color)
return width