summaryrefslogtreecommitdiff
path: root/hardware/kicad/t-modem-s1224/scripts/render_docs_images.py
blob: 7fd75f33b3a42401342bf7989b385f6fff676413 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
#!/usr/bin/env python3
"""Render T-Modem docs images from the gold KiCad PCB.

Outputs:
  <repo>/t-modem-c12s24-complete.png
      Assembled 3D (kicad-cli pcb render) — Pico H seated, top view, USB/DC at bottom.
  <repo>/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()
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com