main
Rithesh03 Update 3: PCB design completed - routed board, manufacturing package (prototype, do not order yet), WLED guide, final checklist, Hydrogen feedback 1ed072d 13d ago
"""Create the board file: 300 x 70 mm outline + JLCPCB design rules (no parts placed yet).

Usage: python3 make_board.py
Writes hardware/rithesh-led-display.kicad_pcb and the board rules/net classes into
hardware/rithesh-led-display.kicad_pro. Values and their JLCPCB sources are listed in RULES.
"""
import json
import os

import pcbnew

HW = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PCB = os.path.join(HW, "rithesh-led-display.kicad_pcb")
PRO = os.path.join(HW, "rithesh-led-display.kicad_pro")
MM = pcbnew.FromMM

W, H, R = 300.0, 70.0, 2.0          # board size and corner radius (mm)
X0, Y0 = 50.0, 50.0                 # top-left corner on the drawing sheet

# our value, JLCPCB published minimum (2-layer, 1 oz), why we chose more
RULES = {
    "min_track_width":        (0.15, 0.10, "margin over JLCPCB 0.10 mm"),
    "min_clearance":          (0.15, 0.10, "margin over JLCPCB 0.10 mm"),
    "min_via_diameter":       (0.60, 0.25, "0.3 mm hole + 0.15 mm ring: JLCPCB preferred, no extra cost"),
    "min_through_drill":      (0.30, 0.15, "holes under 0.25 mm cost extra at JLCPCB"),
    "min_via_annular_width":  (0.15, 0.05, "JLCPCB: via 0.1 mm (0.15 preferred) larger than hole"),
    "min_hole_to_hole":       (0.50, 0.45, "JLCPCB pad hole-to-hole 0.45 mm"),
    "min_hole_clearance":     (0.25, 0.20, "JLCPCB via hole to track 0.2 mm"),
    "min_copper_edge_clearance": (0.50, 0.20, "JLCPCB >= 0.2 mm from routed edge"),
    "min_silk_clearance":     (0.15, None, "keeps silkscreen items apart"),
    "min_text_height":        (1.00, 1.00, "JLCPCB min silkscreen text height 1.0 mm"),
    "min_text_thickness":     (0.15, 0.15, "JLCPCB min silkscreen line width 0.15 mm"),
    "solder_mask_min_width":  (0.10, 0.10, "JLCPCB min solder mask bridge 0.10 mm"),
}

NETCLASSES = [
    # name, clearance, track width, via diameter, via drill, nets
    ("Default", 0.20, 0.25, 0.60, 0.30, []),
    ("Power", 0.25, 1.00, 0.80, 0.40, ["VBUS", "+5V", "GND"]),   # USB-C pins: local rule, see DRU
    ("Supply3V3", 0.20, 0.50, 0.60, 0.30, ["+3V3"]),
    ("USB", 0.20, 0.30, 0.60, 0.30, ["USB_DP", "USB_DN", "USB_DP_MCU", "USB_DN_MCU"]),
]


def shape(board, kind, layer, width):
    s = pcbnew.PCB_SHAPE(board)
    s.SetShape(kind)
    s.SetLayer(layer)
    s.SetWidth(MM(width))
    board.Add(s)
    return s


def pt(x, y):
    return pcbnew.VECTOR2I(MM(X0 + x), MM(Y0 + y))


def outline(board):
    """Rounded rectangle on Edge.Cuts."""
    segs = [((R, 0), (W - R, 0)), ((W, R), (W, H - R)), ((W - R, H), (R, H)), ((0, H - R), (0, R))]
    for (a, b) in segs:
        s = shape(board, pcbnew.SHAPE_T_SEGMENT, pcbnew.Edge_Cuts, 0.05)
        s.SetStart(pt(*a))
        s.SetEnd(pt(*b))
    k = R * (1 - 0.5 ** 0.5)
    arcs = [((0, R), (k, k), (R, 0)), ((W - R, 0), (W - k, k), (W, R)),
            ((W, H - R), (W - k, H - k), (W - R, H)), ((R, H), (k, H - k), (0, H - R))]
    for (a, m, b) in arcs:
        s = shape(board, pcbnew.SHAPE_T_ARC, pcbnew.Edge_Cuts, 0.05)
        s.SetArcGeometry(pt(*a), pt(*m), pt(*b))


def dimension(board, a, b, height, text=None):
    d = pcbnew.PCB_DIM_ALIGNED(board, pcbnew.PCB_DIM_ALIGNED_T)
    d.SetLayer(pcbnew.Dwgs_User)
    d.SetStart(pt(*a))
    d.SetEnd(pt(*b))
    d.SetHeight(MM(height))
    d.SetUnitsMode(pcbnew.DIM_UNITS_MODE_MILLIMETRES)
    d.SetPrecision(pcbnew.DIM_PRECISION_X_X)
    d.SetLineThickness(MM(0.15))
    board.Add(d)


def note(board, text, x, y, size=2.0):
    t = pcbnew.PCB_TEXT(board)
    t.SetText(text)
    t.SetLayer(pcbnew.Cmts_User)
    t.SetPosition(pt(x, y))
    t.SetTextSize(pcbnew.VECTOR2I(MM(size), MM(size)))
    t.SetTextThickness(MM(size * 0.15))
    t.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_LEFT)
    board.Add(t)


# ---- planning geometry (board coordinates, mm from the top-left corner) -------------
PITCH = 7.0
COL0_X, ROW0_Y = 10.0, 7.8          # LED1 (top-left LED of "R") centre (7.8: ESP32 spacing, see notes)
LETTERS = {  # 5 x 7 patterns from requirements.md
    "R": ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
    "I": ["01110", "00100", "00100", "00100", "00100", "00100", "01110"],
    "T": ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
    "H": ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
    "E": ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
    "S": ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
}
WORD = "RITHESH"
LED_CY_X, LED_CY_Y = 3.1, 2.8       # LED footprint courtyard half-sizes

HOLE_INSET = 3.2                    # hole centre from each board edge (hole edge 1.6 mm = board thickness)
HOLE_KEEPOUT_R = 3.5                # no copper/parts: fits a 7 mm M3 washer / 5.5-6 mm screw head
HOLES = [(HOLE_INSET, HOLE_INSET), (W - HOLE_INSET, HOLE_INSET),
         (HOLE_INSET, H - HOLE_INSET), (W - HOLE_INSET, H - HOLE_INSET)]

ESP_X = 40.0                        # planned ESP32 centre x (bottom edge, antenna at the edge)
ESP_W, ESP_H, ANT_H = 13.2, 16.6, 5.4
ANT_SIDE = 15.0                     # Espressif: >= 15 mm clearance each side of the antenna
USB_X = 90.0                        # planned USB-C centre x (bottom edge, flush)
USB_W, USB_DEPTH = 10.64, 9.42      # HRO TYPE-C-31-M-12 courtyard (5.32 each side, 5.27 + 4.15)
RAIL = 5.0                          # JLCPCB recommended process-edge (rail) width


def planned_leds():
    """(x, y) centres of the 107 planned LED positions, in chain-letter order."""
    pos = []
    for i, ch in enumerate(WORD):
        for r, row in enumerate(LETTERS[ch]):
            for c, bit in enumerate(row):
                if bit == "1":
                    pos.append((COL0_X + (i * 6 + c) * PITCH, ROW0_Y + r * PITCH))
    return pos


def rect(board, x0, y0, x1, y1, layer, width=0.15):
    for (a, b) in [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))]:
        s = shape(board, pcbnew.SHAPE_T_SEGMENT, layer, width)
        s.SetStart(pt(*a))
        s.SetEnd(pt(*b))


def label(board, text, x, y, layer=pcbnew.Dwgs_User, size=1.5, thick=None, align="left"):
    t = pcbnew.PCB_TEXT(board)
    t.SetText(text)
    t.SetLayer(layer)
    t.SetPosition(pt(x, y))
    t.SetTextSize(pcbnew.VECTOR2I(MM(size), MM(size)))
    t.SetTextThickness(MM(thick or size * 0.15))
    t.SetHorizJustify({"left": pcbnew.GR_TEXT_H_ALIGN_LEFT, "center": pcbnew.GR_TEXT_H_ALIGN_CENTER}[align])
    board.Add(t)
    return t


def keepout_circle(board, cx, cy, r, name):
    """Rule area on both copper layers: no copper pour, tracks or vias.
    (Pads stay allowed because KiCad counts the hole itself as a pad; the hole's courtyard
    keeps other parts out, which DRC checks.)"""
    z = pcbnew.ZONE(board)
    z.SetIsRuleArea(True)
    z.SetZoneName(name)
    layers = pcbnew.LSET()
    layers.AddLayer(pcbnew.F_Cu)
    layers.AddLayer(pcbnew.B_Cu)
    z.SetLayerSet(layers)
    z.SetDoNotAllowCopperPour(True)
    z.SetDoNotAllowTracks(True)
    z.SetDoNotAllowVias(True)
    z.SetDoNotAllowPads(False)
    z.SetDoNotAllowFootprints(False)   # the hole's own footprint sits inside it
    outline_ = z.Outline()
    outline_.NewOutline()
    import math
    for k in range(48):
        a = 2 * math.pi * k / 48
        p = pt(cx + r * math.cos(a), cy + r * math.sin(a))
        outline_.Append(p.x, p.y)
    board.Add(z)


def add_mounting_holes(board):
    for n, (x, y) in enumerate(HOLES, 1):
        fp = pcbnew.FootprintLoad(os.path.join(HW, "rithesh_fp.pretty"), "MountingHole_3.2mm_M3")
        fp.SetFPIDAsString("rithesh_fp:MountingHole_3.2mm_M3")
        fp.SetReference(f"H{n}")
        fp.SetValue("M3 hole")
        fp.Reference().SetVisible(False)
        fp.SetPosition(pt(x, y))
        board.Add(fp)
        keepout_circle(board, x, y, HOLE_KEEPOUT_R, f"H{n} screw-head keep-out")


def add_led1_marker(board):
    """Silkscreen: 'LED1' above the first LED and an arrow showing the chain starts to the right."""
    x, y = COL0_X, ROW0_Y
    label(board, "LED1", x - 2.6, y - 5.5, layer=pcbnew.F_SilkS, size=1.5, thick=0.22)
    ay = y - 5.5
    ax0, ax1 = x + 4.0, x + 8.0         # arrow in the top margin, pointing along the first row
    for (a, b) in [((ax0, ay), (ax1, ay)), ((ax1, ay), (ax1 - 0.9, ay - 0.6)), ((ax1, ay), (ax1 - 0.9, ay + 0.6))]:
        s = shape(board, pcbnew.SHAPE_T_SEGMENT, pcbnew.F_SilkS, 0.22)
        s.SetStart(pt(*a))
        s.SetEnd(pt(*b))


def add_plan_layer(board):
    """Planning drawings on User.Drawings (not manufactured): LED spots, edge areas, rails."""
    L = pcbnew.Dwgs_User
    # (LED, ESP32 and USB-C outlines were planning drawings; the real parts are now placed
    #  by tools/place_parts.py, so only the JLCPCB rails remain on this layer.)
    # JLCPCB edge rails (removed after assembly)
    for (y0, y1) in [(-RAIL, 0), (H, H + RAIL)]:
        rect(board, 0, y0, W, y1, L, 0.1)
    label(board, "5 mm edge rail added by JLCPCB (removed after assembly)", 110, -2.0, size=1.2)
    label(board, "5 mm edge rail added by JLCPCB (removed after assembly)", 110, H + 3.0, size=1.2)


def main():
    if os.path.exists(PCB):
        os.remove(PCB)
    board = pcbnew.NewBoard(PCB)
    tb = board.GetTitleBlock()
    tb.SetTitle("RITHESH LED Display - PCB")
    tb.SetRevision("0.1 (prototype)")
    tb.SetCompany("RITHESH LED Display")
    tb.SetDate("2026-09-24")
    tb.SetComment(0, "300 x 70 mm, 2 layers, 1.6 mm FR4, 1 oz copper, JLCPCB")
    tb.SetComment(1, "PROTOTYPE - DO NOT ORDER until the XL LED timing test and JLCPCB review pass")
    tb.SetComment(2, "Routed and checked: DRC/ERC clean. See hardware/pcb-layout-notes.md")
    board.SetCopperLayerCount(2)

    ds = board.GetDesignSettings()
    ds.SetBoardThickness(MM(1.6))
    ds.m_TrackMinWidth = MM(RULES["min_track_width"][0])
    ds.m_MinClearance = MM(RULES["min_clearance"][0])
    ds.m_ViasMinSize = MM(RULES["min_via_diameter"][0])
    ds.m_MinThroughDrill = MM(RULES["min_through_drill"][0])
    ds.m_ViasMinAnnularWidth = MM(RULES["min_via_annular_width"][0])
    ds.m_HoleToHoleMin = MM(RULES["min_hole_to_hole"][0])
    ds.m_HoleClearance = MM(RULES["min_hole_clearance"][0])
    ds.m_CopperEdgeClearance = MM(RULES["min_copper_edge_clearance"][0])
    ds.m_SilkClearance = MM(RULES["min_silk_clearance"][0])
    ds.m_MinSilkTextHeight = MM(RULES["min_text_height"][0])
    ds.m_MinSilkTextThickness = MM(RULES["min_text_thickness"][0])
    ds.m_SolderMaskMinWidth = MM(RULES["solder_mask_min_width"][0])
    ds.SetAuxOrigin(pt(0, 0))
    ds.SetGridOrigin(pt(0, 0))

    outline(board)
    add_mounting_holes(board)
    add_led1_marker(board)
    add_plan_layer(board)
    dimension(board, (0, 0), (W, 0), -12)
    dimension(board, (W, 0), (W, H), -8)
    dimension(board, (0, 0), (HOLE_INSET, 0), -8)             # hole inset from the left edge
    dimension(board, (0, 0), (0, HOLE_INSET), 8)              # hole inset from the top edge
    note(board, "RITHESH LED Display - board outline 300 x 70 mm, corner radius 2 mm", 0, H + 12)
    note(board, "4 x M3 holes (3.2 mm), centres 3.5 mm from each edge; 7 mm keep-out circles. "
                "Outer rectangles = 5 mm JLCPCB rails", 0, H + 16)
    note(board, "Thin lines on User.Comments = LED data chain path (drawing only). Notes: hardware/pcb-layout-notes.md",
         0, H + 20)
    pcbnew.SaveBoard(PCB, board)
    # A 300 mm board does not fit the default A4 drawing sheet; KiCad 7's Python API
    # cannot set the page size, so switch it in the saved file.
    text = open(PCB).read()
    assert '(paper "A4")' in text
    open(PCB, "w").write(text.replace('(paper "A4")', '(paper "A3")', 1))

    # Net classes and pattern assignments live in the project file (KiCad 7 format).
    pro = json.load(open(PRO))
    ns = pro.setdefault("net_settings", {})
    base = dict(ns.get("classes", [{}])[0]) if ns.get("classes") else {}
    classes = []
    for name, clr, tw, vd, vdr, _ in NETCLASSES:
        c = dict(base)
        c.update({"name": name, "clearance": clr, "track_width": tw, "via_diameter": vd,
                  "via_drill": vdr, "microvia_diameter": 0.3, "microvia_drill": 0.1,
                  "diff_pair_width": 0.3, "diff_pair_gap": 0.25, "wire_width": 6, "bus_width": 12,
                  "line_style": 0, "schematic_color": "rgba(0, 0, 0, 0.000)", "pcb_color": "rgba(0, 0, 0, 0.000)"})
        classes.append(c)
    ns["classes"] = classes
    ns["netclass_patterns"] = [{"netclass": name, "pattern": net}
                               for name, *_rest, nets in NETCLASSES for net in nets]
    ns.setdefault("meta", {"version": 3})
    dsj = pro["board"]["design_settings"]
    dsj["track_widths"] = [0.0, 0.25, 0.5, 1.0, 1.5]
    dsj["via_dimensions"] = [{"diameter": 0.0, "drill": 0.0}, {"diameter": 0.6, "drill": 0.3},
                             {"diameter": 0.8, "drill": 0.4}]
    json.dump(pro, open(PRO, "w"), indent=2)
    # Local rule: the USB-C connector's own pins are only 0.20 mm apart (fixed by the part), so
    # items inside J1's courtyard may be 0.20 mm apart; the rest of the board keeps its rules.
    with open(os.path.join(HW, "rithesh-led-display.kicad_dru"), "w") as f:
        f.write('(version 1)\n'
                '(rule "USB-C J1: 0.20 mm between items inside the connector footprint"\n'
                '  (condition "A.intersectsCourtyard(\'J1\') && B.intersectsCourtyard(\'J1\')")\n'
                '  (constraint clearance (min 0.2mm)))\n'
                # Small ground pads in the front ground fill use thermal reliefs (even solder heating, no
                # tombstoning); where only one spoke fits, that is enough because each such pad also has its
                # own via / track to the ground plane.
                '(rule "thermal reliefs: one spoke is enough (pads also have their own via or track)"\n'
                '  (constraint min_resolved_spokes (min 1)))\n')
    print("wrote", PCB)


if __name__ == "__main__":
    main()