From a7362dc14bca1233eb34b41aefebe9cfb22684ac Mon Sep 17 00:00:00 2001 From: "info@mode42.com" Date: Fri, 7 Aug 2026 18:09:58 +0000 Subject: Initial push --- .../t-modem-s1224/scripts/render_docs_images.py | 610 +++++++++++++++++++++ 1 file changed, 610 insertions(+) create mode 100644 hardware/kicad/t-modem-s1224/scripts/render_docs_images.py (limited to 'hardware/kicad/t-modem-s1224/scripts/render_docs_images.py') diff --git a/hardware/kicad/t-modem-s1224/scripts/render_docs_images.py b/hardware/kicad/t-modem-s1224/scripts/render_docs_images.py new file mode 100644 index 0000000..7fd75f3 --- /dev/null +++ b/hardware/kicad/t-modem-s1224/scripts/render_docs_images.py @@ -0,0 +1,610 @@ +#!/usr/bin/env python3 +"""Render T-Modem docs images from the gold KiCad PCB. + +Outputs: + /t-modem-c12s24-complete.png + Assembled 3D (kicad-cli pcb render) — Pico H seated, top view, USB/DC at bottom. + /docs/images/explode/t-modem-explode-0N-*.png + Teaching explode panels from 2D SVG layers (not 3D). + +Requires: kicad-cli, kicad-packages3d (KICAD10_3DMODEL_DIR), PyGObject (Rsvg), +cairo, Pillow. + +Usage: + python3 render_docs_images.py # complete 3D only (default) + python3 render_docs_images.py --explode # teaching set only + python3 render_docs_images.py --all # both +""" + +from __future__ import annotations + +import argparse +import math +import os +import subprocess +import tempfile +from pathlib import Path + +import cairo +import gi +from PIL import Image, ImageDraw, ImageEnhance, ImageFont + +gi.require_version("Rsvg", "2.0") +from gi.repository import Rsvg # noqa: E402 + +REPO = Path(__file__).resolve().parents[4] +PCB = REPO / "hardware/kicad/t-modem/t-modem.kicad_pcb" +OUT_COMPLETE = REPO / "t-modem-c12s24-complete.png" +OUT_EXPLODE = REPO / "docs/images/explode" + +BOARD_W_MM = 190.0 +BOARD_H_MM = 110.0 +# Builder-facing: front (USB/DC, KiCad Y≈0) at bottom of image — matches docs ASCII. +FLIP_Y = True + +DEFAULT_3DMODEL_DIR = "/usr/share/kicad/3dmodels" + +# Footprint centres from gold PCB (mm, KiCad coords). +POS = { + "U1": (100.0, 36.0), + "SKT_MODEM": (22.0, 52.0), + "Y1": (40.0, 42.0), + "J_AF": (182.0, 78.0), + "J_USB": (22.0, 8.0), + "J_DC": (74.0, 12.0), + "J_LCD_I2C": (100.0, 101.45), + "U_LVL": (72.0, 101.45), + "U_REG": (140.0, 14.0), + "SW1": (98.0, 12.0), + "LED_PWR": (178.0, 28.0), + "LED_PTT": (178.0, 40.0), + "LED_DCD": (178.0, 52.0), + "LED_TXRX": (178.0, 64.0), + "RV1": (58.0, 80.0), + "RV2": (128.0, 100.0), + "RV_CDL": (58.0, 94.0), + "Q1": (148.0, 58.0), + "R_TRS": (42.0, 54.0), + "R_TXR1": (42.0, 62.0), + "R_TXR2": (27.9, 68.0), + "J_USB_PICO": (48.0, 22.0), + "TP_BOOTSEL": (112.0, 12.0), + "F1": (154.0, 14.0), + "H1": (6.0, 6.0), + "H2": (184.0, 6.0), + "H3": (6.0, 104.0), + "H4": (184.0, 104.0), +} + +# Highlight boxes (cx, cy, w, h) in KiCad mm. +BOX = { + "outline": (95.0, 55.0, 188.0, 108.0), + "pico": (100.0, 36.0, 28.0, 58.0), + "modem": (22.0, 52.0, 22.0, 28.0), + "crystal": (40.0, 42.0, 18.0, 20.0), + "radio": (175.0, 70.0, 28.0, 40.0), + "lcd": (90.0, 100.0, 70.0, 18.0), + "usb": (35.0, 14.0, 55.0, 24.0), + "straps_leds": (110.0, 55.0, 140.0, 70.0), + "power": (125.0, 16.0, 90.0, 28.0), +} + + +def find_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + for path in ( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + ): + p = Path(path) + if p.is_file(): + return ImageFont.truetype(str(p), size) + return ImageFont.load_default() + + +def mm_to_xy(x_mm: float, y_mm: float, ox: float, oy: float, scale: float) -> tuple[float, float]: + y = (BOARD_H_MM - y_mm) if FLIP_Y else y_mm + return ox + x_mm * scale, oy + y * scale + + +def model_dir() -> str: + return os.environ.get("KICAD10_3DMODEL_DIR", DEFAULT_3DMODEL_DIR) + + +def pcb_for_complete_render(tmp: Path) -> Path: + """Copy gold PCB and seat a DIP-16 body in SKT_MODEM for the built look.""" + text = PCB.read_text(encoding="utf-8") + needle = ( + '\t\t(model "${KICAD10_3DMODEL_DIR}/Package_DIP.3dshapes/DIP-16_W7.62mm_Socket.step"\n' + "\t\t\t(offset\n" + "\t\t\t\t(xyz 0 0 0)\n" + "\t\t\t)\n" + "\t\t\t(scale\n" + "\t\t\t\t(xyz 1 1 1)\n" + "\t\t\t)\n" + "\t\t\t(rotate\n" + "\t\t\t\t(xyz 0 0 0)\n" + "\t\t\t)\n" + "\t\t)" + ) + insert = ( + needle + + "\n" + + '\t\t(model "${KICAD10_3DMODEL_DIR}/Package_DIP.3dshapes/DIP-16_W7.62mm.step"\n' + "\t\t\t(offset\n" + "\t\t\t\t(xyz 0 0 4.5)\n" + "\t\t\t)\n" + "\t\t\t(scale\n" + "\t\t\t\t(xyz 1 1 1)\n" + "\t\t\t)\n" + "\t\t\t(rotate\n" + "\t\t\t\t(xyz 0 0 0)\n" + "\t\t\t)\n" + "\t\t)" + ) + if needle not in text: + raise SystemExit("SKT_MODEM socket 3D model block not found in gold PCB") + out = tmp / "t-modem-complete-render.kicad_pcb" + out.write_text(text.replace(needle, insert, 1), encoding="utf-8") + return out + + +def render_complete_3d() -> Image.Image: + """Raytraced assembled top view; yaw 0 so silkscreen reads upright (front edge at top).""" + md = model_dir() + pico = Path(md) / "Module.3dshapes/RaspberryPi_Pico_H.step" + if not pico.is_file(): + raise SystemExit( + f"missing Pico 3D model: {pico}\n" + "Install kicad-packages3d or set KICAD10_3DMODEL_DIR." + ) + with tempfile.TemporaryDirectory(prefix="tmodem-complete-3d-") as td: + tmp = Path(td) + pcb = pcb_for_complete_render(tmp) + raw = tmp / "raw.png" + env = os.environ.copy() + env["KICAD10_3DMODEL_DIR"] = md + cmd = [ + "kicad-cli", + "pcb", + "render", + "-o", + str(raw), + "-w", + "1920", + "-h", + "1280", + "--side", + "top", + "--background", + "opaque", + "--quality", + "high", + "--floor", + "--zoom", + "0.92", + "--rotate", + "0,0,0", + "--define-var", + f"KICAD10_3DMODEL_DIR={md}", + str(pcb), + ] + subprocess.run(cmd, check=True, env=env) + return Image.open(raw).convert("RGB") + + +def compose_complete(raw: Image.Image) -> Image.Image: + """Title + crop → 1536×1024 README companion (matches prior complete size).""" + pixels = raw.load() + w, h = raw.size + xs: list[int] = [] + ys: list[int] = [] + for y in range(0, h, 2): + for x in range(0, w, 2): + r, g, b = pixels[x, y] + if not (r > 175 and g > 175 and b > 185 and abs(r - g) < 25 and abs(g - b) < 30): + xs.append(x) + ys.append(y) + if xs and ys: + pad = 40 + box = ( + max(0, min(xs) - pad), + max(0, min(ys) - pad), + min(w, max(xs) + pad), + min(h, max(ys) + pad), + ) + cropped = raw.crop(box) + else: + cropped = raw + + W, H = 1536, 1024 + canvas = Image.new("RGB", (W, H), (250, 250, 248)) + title_h = 110 + avail_w, avail_h = W - 80, H - title_h - 50 + cw, ch = cropped.size + scale = min(avail_w / cw, avail_h / ch) + nw, nh = int(cw * scale), int(ch * scale) + board = cropped.resize((nw, nh), Image.Resampling.LANCZOS) + ox = (W - nw) // 2 + oy = title_h + (avail_h - nh) // 2 + canvas.paste(board, (ox, oy)) + + draw = ImageDraw.Draw(canvas) + lines = ( + ("T-Modem-c12s24", find_font(36), 22, (20, 20, 20)), + ("chip 1200 · software 2400", find_font(20), 64, (55, 55, 55)), + ("GPLv3 · AS-IS · assembled 3D from gold PCB", find_font(16), 90, (90, 90, 90)), + ) + for text, font, y, fill in lines: + bbox = draw.textbbox((0, 0), text, font=font) + tw = bbox[2] - bbox[0] + draw.text(((W - tw) // 2, y), text, fill=fill, font=font) + + note = "Front edge (USB / DC / BOOTSEL) at top · Pico H seated · 190×110 mm · v0.25" + fn = find_font(13) + bbox = draw.textbbox((0, 0), note, font=fn) + draw.text(((W - (bbox[2] - bbox[0])) // 2, H - 32), note, fill=(100, 100, 100), font=fn) + return canvas + + +def export_svgs(tmp: Path) -> tuple[Path, Path]: + top = tmp / "top.svg" + bot = tmp / "bottom.svg" + layers_top = "F.Cu,F.SilkS,F.Mask,F.Fab,Edge.Cuts,User.Drawings,User.Comments" + layers_bot = "B.Cu,B.SilkS,B.Mask,B.Fab,Edge.Cuts" + common = [ + "kicad-cli", + "pcb", + "export", + "svg", + "--mode-single", + "--page-size-mode", + "2", + "--exclude-drawing-sheet", + "--fit-page-to-board", + "--drill-shape-opt", + "2", + "--subtract-soldermask", + ] + subprocess.run( + common + ["-o", str(top), "--layers", layers_top, str(PCB)], + check=True, + ) + subprocess.run( + common + ["-o", str(bot), "--layers", layers_bot, "--mirror", str(PCB)], + check=True, + ) + return top, bot + + +def svg_to_png(svg: Path, scale_px_per_mm: float) -> Image.Image: + handle = Rsvg.Handle.new_from_file(str(svg)) + w = int(math.ceil(BOARD_W_MM * scale_px_per_mm)) + h = int(math.ceil(BOARD_H_MM * scale_px_per_mm)) + surf = cairo.ImageSurface(cairo.FORMAT_ARGB32, w, h) + ctx = cairo.Context(surf) + ctx.set_source_rgb(1, 1, 1) + ctx.paint() + ctx.scale(scale_px_per_mm, scale_px_per_mm) + vp = Rsvg.Rectangle() + vp.x = 0 + vp.y = 0 + vp.width = BOARD_W_MM + vp.height = BOARD_H_MM + handle.render_document(ctx, vp) + buf = surf.get_data() + im = Image.frombuffer("RGBA", (w, h), bytes(buf), "raw", "BGRA", 0, 1).convert("RGB") + if FLIP_Y: + im = im.transpose(Image.FLIP_TOP_BOTTOM) + return im + + +def board_canvas( + board: Image.Image, + canvas_w: int, + canvas_h: int, + margin: int = 48, + title_h: int = 72, +) -> tuple[Image.Image, float, float, float]: + """Place board on canvas; return image, origin_x, origin_y, scale_px_per_mm.""" + avail_w = canvas_w - 2 * margin + avail_h = canvas_h - title_h - margin - 36 + scale = min(avail_w / BOARD_W_MM, avail_h / BOARD_H_MM) + bw = int(BOARD_W_MM * scale) + bh = int(BOARD_H_MM * scale) + board_r = board.resize((bw, bh), Image.Resampling.LANCZOS) + canvas = Image.new("RGB", (canvas_w, canvas_h), (248, 248, 246)) + ox = (canvas_w - bw) // 2 + oy = title_h + (avail_h - bh) // 2 + canvas.paste(board_r, (ox, oy)) + return canvas, float(ox), float(oy), scale + + +def draw_title(draw: ImageDraw.ImageDraw, text: str, sub: str, w: int) -> None: + font_t = find_font(28) + font_s = find_font(16) + draw.text((24, 16), text, fill=(20, 20, 20), font=font_t) + draw.text((24, 48), sub, fill=(70, 70, 70), font=font_s) + draw.line((24, 68, w - 24, 68), fill=(180, 180, 180), width=1) + + +def draw_box( + draw: ImageDraw.ImageDraw, + ox: float, + oy: float, + scale: float, + cx: float, + cy: float, + bw: float, + bh: float, + color: tuple[int, int, int] = (220, 40, 40), + width: int = 3, +) -> None: + x0, y0 = mm_to_xy(cx - bw / 2, cy + bh / 2, ox, oy, scale) + x1, y1 = mm_to_xy(cx + bw / 2, cy - bh / 2, ox, oy, scale) + draw.rectangle([x0, y0, x1, y1], outline=color, width=width) + + +def draw_callout( + draw: ImageDraw.ImageDraw, + ox: float, + oy: float, + scale: float, + x_mm: float, + y_mm: float, + label: str, + side: str = "right", + color: tuple[int, int, int] = (30, 30, 30), +) -> None: + font = find_font(15) + px, py = mm_to_xy(x_mm, y_mm, ox, oy, scale) + if side == "right": + tx, ty = px + 28, py - 8 + draw.line([(px, py), (px + 18, py), (tx - 4, ty + 8)], fill=color, width=2) + elif side == "left": + tx, ty = px - 160, py - 8 + draw.line([(px, py), (px - 18, py), (tx + 140, ty + 8)], fill=color, width=2) + elif side == "up": + tx, ty = px + 8, py - 36 + draw.line([(px, py), (px, py - 18), (tx, ty + 14)], fill=color, width=2) + else: + tx, ty = px + 8, py + 18 + draw.line([(px, py), (px, py + 14), (tx, ty)], fill=color, width=2) + bbox = draw.textbbox((tx, ty), label, font=font) + pad = 4 + draw.rectangle( + [bbox[0] - pad, bbox[1] - pad, bbox[2] + pad, bbox[3] + pad], + fill=(255, 255, 255), + outline=color, + ) + draw.text((tx, ty), label, fill=color, font=font) + + +def dim_except_boxes( + base: Image.Image, + ox: float, + oy: float, + scale: float, + boxes: list[tuple[float, float, float, float]], + dim: float = 0.38, +) -> Image.Image: + """Dim whole image then restore rectangular highlight regions.""" + faded = ImageEnhance.Brightness(base).enhance(dim) + faded = ImageEnhance.Color(faded).enhance(0.55) + out = faded.copy() + for cx, cy, bw, bh in boxes: + x0, y0 = mm_to_xy(cx - bw / 2, cy + bh / 2, ox, oy, scale) + x1, y1 = mm_to_xy(cx + bw / 2, cy - bh / 2, ox, oy, scale) + box = (int(min(x0, x1)), int(min(y0, y1)), int(max(x0, x1)), int(max(y0, y1))) + region = base.crop(box) + out.paste(region, box) + return out + + +def make_panel( + board: Image.Image, + step: int, + title: str, + subtitle: str, + highlight: list[str], + callouts: list[tuple[str, str, str]], + footer: str, +) -> Image.Image: + W, H = 1536, 1024 + base, ox, oy, scale = board_canvas(board, W, H, margin=56, title_h=78) + boxes = [BOX[k] for k in highlight if k in BOX] + if boxes: + base = dim_except_boxes(base, ox, oy, scale, boxes) + draw = ImageDraw.Draw(base) + draw_title(draw, f"{step}/10 · {title}", subtitle, W) + for key in highlight: + if key in BOX: + cx, cy, bw, bh = BOX[key] + draw_box(draw, ox, oy, scale, cx, cy, bw, bh, color=(200, 30, 30), width=3) + for ref, label, side in callouts: + if ref not in POS: + continue + x, y = POS[ref] + draw_callout(draw, ox, oy, scale, x, y, label, side=side, color=(25, 25, 25)) + draw.text((24, H - 28), footer, fill=(90, 90, 90), font=find_font(13)) + draw.text((W - 200, H - 28), f"Panel {step}/10", fill=(90, 90, 90), font=find_font(13)) + return base + + +def write_complete() -> None: + raw = render_complete_3d() + complete = compose_complete(raw) + complete.save(OUT_COMPLETE, "PNG", optimize=True) + print(f"wrote {OUT_COMPLETE}") + + +def write_explode() -> None: + OUT_EXPLODE.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="tmodem-render-") as td: + tmp = Path(td) + top_svg, _bot_svg = export_svgs(tmp) + top = svg_to_png(top_svg, scale_px_per_mm=14.0) + + panels: list[tuple[str, str, str, list[str], list[tuple[str, str, str]], str]] = [ + ( + "t-modem-explode-01-board-outline.png", + "Board outline", + "190 × 110 mm · M3 holes · Edge.Cuts — empty teaching frame from gold PCB", + ["outline"], + [ + ("H1", "H1 M3", "down"), + ("H2", "H2 M3", "down"), + ("H3", "H3 M3", "up"), + ("H4", "H4 M3", "up"), + ], + "Step 1 — know the board size and mounting before stuffing parts", + ), + ( + "t-modem-explode-02-pico.png", + "Raspberry Pi Pico", + "U1 · RaspberryPi_Pico_Common_THT · Pico H plugs into female headers", + ["pico"], + [ + ("U1", "U1 Pico H", "right"), + ("SW1", "SW1 BOOTSEL", "down"), + ("TP_BOOTSEL", "TP_BOOTSEL flying lead", "down"), + ], + "Step 2 — MCU centre · USB end of Pico toward front edge", + ), + ( + "t-modem-explode-03-modem-socket.png", + "Modem socket · TCM3105", + "SKT_MODEM DIP-16 · do not solder the IC directly — socket first", + ["modem"], + [ + ("SKT_MODEM", "SKT_MODEM DIP-16", "right"), + ], + "Step 3 — PHY chip class: TCM3105 (c12) · socketed for service", + ), + ( + "t-modem-explode-04-crystal.png", + "Crystal · load caps", + "Y1 HC-49-U 4.433619 MHz · C_Y1 / C_Y2 22 pF beside modem", + ["crystal"], + [ + ("Y1", "Y1 4.433619", "right"), + ], + "Step 4 — short analog path: crystal next to TCM3105", + ), + ( + "t-modem-explode-05-radio-af.png", + "Radio AF · PTT", + "J_AF Phoenix 1×4 · AF_TX AF_RX PTT GND · Q1 2N2222A driver", + ["radio"], + [ + ("J_AF", "J_AF screw terminal", "left"), + ("Q1", "Q1 2N2222A PTT", "left"), + ], + "Step 5 — radio interconnect on the right edge", + ), + ( + "t-modem-explode-06-lcd-i2c.png", + "LCD · I²C · level shift", + "J_LCD_I2C GND VCC SDA SCL · U_LVL BSS138 · RV2 contrast optional", + ["lcd"], + [ + ("J_LCD_I2C", "J_LCD_I2C", "down"), + ("U_LVL", "U_LVL BSS138", "down"), + ("RV2", "RV2 contrast", "left"), + ], + "Step 6 — default display path: I²C backpack via level shifter", + ), + ( + "t-modem-explode-07-usb.png", + "USB Path B", + "J_USB Type-A device · J_USB_PICO cable to Pico Micro-USB · D+/D− + VBUS", + ["usb"], + [ + ("J_USB", "J_USB USB-A", "up"), + ("J_USB_PICO", "J_USB_PICO → Pico", "right"), + ], + "Step 7 — USB DEVICE on board; Path B data cable to Pico", + ), + ( + "t-modem-explode-08-straps-leds.png", + "Straps · LEDs · trimmers", + "0 Ω straps R_TRS / R_TXR* · LEDs PWR PTT DCD TXRX · RV1 TX · RV_CDL", + ["straps_leds"], + [ + ("R_TRS", "0 Ω straps", "right"), + ("LED_PWR", "LED row", "left"), + ("RV1", "RV1 / RV_CDL", "right"), + ], + "Step 8 — confirm strap silk vs TCM3105 datasheet before power", + ), + ( + "t-modem-explode-09-power-dc.png", + "12 V · buck · OR-ing", + "J_DC 12 V CENTRE+ · U_REG MP1584 · F1 · Schottky OR into +5V_OR", + ["power"], + [ + ("J_DC", "J_DC 12 V", "down"), + ("U_REG", "U_REG MP1584", "up"), + ("F1", "F1 fuse", "up"), + ], + "Step 9 — bring up 12 V alone → measure +5V_OR ≈ 5 V before USB-only path", + ), + ( + "t-modem-explode-10-complete-stack.png", + "Complete stack", + "All subsystems on one board — insert IC · Pico · LCD after soldering sockets", + ["outline", "pico", "modem", "crystal", "radio", "lcd", "usb", "power"], + [ + ("U1", "Pico", "right"), + ("SKT_MODEM", "TCM3105", "left"), + ("J_AF", "Radio", "left"), + ("J_USB", "USB", "down"), + ("J_LCD_I2C", "LCD I²C", "up"), + ("J_DC", "12 V", "down"), + ], + "Step 10 — RX before TX when RF is attached · AS-IS standard reference", + ), + ] + + expected = {p[0] for p in panels} + # Drop only superseded filenames; never wipe the whole dir blindly. + for old in OUT_EXPLODE.glob("t-modem-explode-*.png"): + if old.name not in expected: + old.unlink() + + for i, (fname, title, sub, hl, calls, foot) in enumerate(panels, start=1): + img = make_panel(top, i, title, sub, hl, calls, foot) + path = OUT_EXPLODE / fname + img.save(path, "PNG", optimize=True) + print(f"wrote {path}") + + +def main() -> None: + if not PCB.is_file(): + raise SystemExit(f"missing PCB: {PCB}") + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--explode", + action="store_true", + help="Regenerate 2D teaching explode panels only", + ) + ap.add_argument( + "--all", + action="store_true", + help="Regenerate complete 3D and explode set", + ) + args = ap.parse_args() + + do_complete = args.all or not args.explode + do_explode = args.all or args.explode + + if do_complete: + write_complete() + if do_explode: + write_explode() + + +if __name__ == "__main__": + main() -- cgit v1.3.1