board
RITHESH LED Display
Public Unreviewedby Rithesh03
A 300 x 70 mm PCB that spells RITHESH with 107 WS2812B RGB LEDs, controlled over Wi-Fi.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
"""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()