600 lines
26 KiB
Python
600 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""Create the placed eight-layer KiCad PCB from the audited design database.
|
|
|
|
The script is deterministic so placement, net assignments, stackup, and rules
|
|
can be regenerated and independently audited. Routing uses the staged
|
|
BGA/plane/Freerouting flow in scripts/README.md; finalize_routing.py replaces
|
|
autorouted RF/USB copper with audited deterministic geometry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
import pcbnew
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "scripts"))
|
|
from design_data import components # noqa: E402
|
|
|
|
HW = ROOT / "hardware"
|
|
BOARD_FILE = HW / "board.kicad_pcb"
|
|
STOCK = Path("/usr/share/kicad/footprints")
|
|
|
|
# The distributed SMT test pads and removal of the former bottom test-point
|
|
# field allow a compact 155 x 90 mm outline.
|
|
X0, Y0, X1, Y1 = 20.0, 20.0, 175.0, 110.0
|
|
|
|
|
|
def mm(value: float) -> int:
|
|
return pcbnew.FromMM(value)
|
|
|
|
|
|
def point(x: float, y: float) -> pcbnew.VECTOR2I:
|
|
return pcbnew.VECTOR2I_MM(x, y)
|
|
|
|
|
|
def footprint_path(identifier: str) -> tuple[Path, str]:
|
|
library, name = identifier.split(":", 1)
|
|
if library == "board":
|
|
return HW / "board.pretty", name
|
|
return STOCK / f"{library}.pretty", name
|
|
|
|
|
|
MAJOR = {
|
|
# FPGA/configuration. All four PMODs are on the north edge, close to the
|
|
# BGA side carrying their I/O banks. Rotation makes the connector field
|
|
# shallow and keeps all modules clear of the RF and IMU regions.
|
|
"U1": (108, 72, 0), "U26": (97, 54, 0),
|
|
"JPMOD1": (78, 24, 0), "JPMOD2": (96, 24, 0),
|
|
"JPMOD3": (114, 24, 0), "JPMOD4": (132, 24, 0),
|
|
|
|
# USB-C and debug block.
|
|
"J1": (23.1, 48, 270), "U5": (30.5, 44.5, 0), "U6": (30.5, 51.5, 0),
|
|
"U2": (43, 55, 0), "U25": (35, 62, 0), "U3": (51, 63, 0),
|
|
"U29": (60, 54.5, 0), "U30": (67, 58.5, 0), "U31": (62, 63.5, 0),
|
|
|
|
# Mechanically quiet IMU region.
|
|
"U22": (51, 84, 0),
|
|
|
|
# Protected input and switching-power block.
|
|
"J3": (24, 100, 90), "U7": (34, 96, 0), "U8": (42, 104, 0), "U9": (46, 96, 0),
|
|
"U10": (54, 104, 0), "U11": (59, 104, 0), "U12": (64, 94, 0),
|
|
# High-current FPGA rails sit directly south of the BGA. This keeps the
|
|
# 15-A core path and both 6-A I/O-rail paths short enough to enter their
|
|
# local plane patches without long high-current traces.
|
|
"U13": (100, 93, 0), "U14": (115, 97, 0), "U15": (130, 97, 0), "U16": (90, 98, 0),
|
|
"U17": (143, 63, 0), "U18": (136, 67, 0), "U19": (152, 67, 0), "U20": (163, 63, 0),
|
|
"U28": (141, 72.5, 0), "U32": (136, 74.5, 0), "U34": (88, 88, 0),
|
|
"Q1": (70, 62, 0),
|
|
|
|
# User reset controls remain edge-accessible; only the tiny protection and
|
|
# open-drain circuitry sit inward from the connector/button.
|
|
"J4": (73, 106.5, 180), "SW1": (82, 106, 0), "D2": (78, 100.5, 0),
|
|
"U33": (87, 103, 0),
|
|
|
|
# RF front end. J2's launch reference is exactly on the right PCB edge.
|
|
"J2": (175, 42, 0), "D1": (170.45, 40.75, 0), "FL1": (164.0, 42.1, 180),
|
|
"J5": (171, 56, 0), "U27": (165, 52, 0), "U21": (155.5, 45, 0),
|
|
"U23": (149, 53, 0), "U24": (137, 55, 0),
|
|
}
|
|
|
|
# Parts whose exact physical location materially affects signal integrity,
|
|
# power-loop area, or mechanical performance.
|
|
SPECIAL = {
|
|
# RF series parts are collinear. L4 pad 2 lands directly on the SMA feed
|
|
# rather than creating a bias-tee stub; the optional shunt-tuning pad 1s
|
|
# likewise land on the two controlled-impedance sections.
|
|
"C83": (167.5, 42.1, 180), "C84": (162.15, 41.45, 90), "R39": (160.5, 42.1, 180),
|
|
"C85": (159.0, 40.70, 90), "L4": (169.5, 42.585, 90),
|
|
"L1": (105.0, 91.0, 0), "L2": (115.0, 91.0, 0), "L3": (130.0, 91.0, 0),
|
|
"FB1": (60.0, 88.5, 0), "FB2": (36.0, 49.0, 0), "FB3": (39.0, 46.5, 0),
|
|
"R63": (37.0, 55.50, 0), "R64": (37.0, 54.50, 0),
|
|
# MAX2771 output damping sits at the receiver; FPGA-driven 3-wire control
|
|
# damping sits at the BGA source. R55 holds SHDN low at the receiver.
|
|
"R45": (151.0, 44.5, 0),
|
|
"R46": (154.6, 50.2, 90), "R47": (155.8, 50.2, 90),
|
|
"R48": (157.0, 50.2, 90), "R49": (158.2, 50.2, 90),
|
|
"R50": (160.5, 47.5, 90),
|
|
"R51": (121.5, 65.5, 0), "R52": (121.5, 66.7, 0),
|
|
"R53": (121.5, 67.9, 0), "R54": (121.5, 69.1, 0),
|
|
"R55": (161.7, 44.5, 90), "C89": (146.5, 53.0, 0),
|
|
# 40-MHz termination is at U24. SafeSPI controller outputs are terminated
|
|
# beside the FPGA; MISO and EXTRESN bias remain beside the IMU.
|
|
"R56": (140.2, 54.35, 0),
|
|
"R57": (102.0, 58.8, 0), "R58": (104.0, 58.8, 0),
|
|
"R59": (106.0, 58.8, 0), "R60": (60.0, 83.6, 0),
|
|
"R61": (41.0, 83.6, 0),
|
|
# Buffered FTDI interface-1 hard JTAG. Output-series parts sit directly
|
|
# after the power-domain buffer; the TDO part is retained at the FPGA side.
|
|
"R70": (65.0, 52.0, 0), "R71": (65.0, 53.2, 0),
|
|
"R72": (65.0, 54.4, 0), "R73": (96.0, 70.0, 0),
|
|
"R74": (96.0, 71.2, 0),
|
|
}
|
|
|
|
# Small SMT bring-up pads live beside the circuit they measure; there is no
|
|
# connector-like test-point bank along the board edge.
|
|
SPECIAL.update({
|
|
"TP1": (37.5, 94.0, 0), "TP2": (45.5, 104.0, 0),
|
|
"TP3": (166.5, 54.0, 0), "TP4": (29.0, 39.0, 0),
|
|
"TP5": (22.0, 89.0, 0), "TP6": (49.5, 98.5, 0),
|
|
"TP7": (66.5, 90.0, 0), "TP8": (105.0, 98.0, 0),
|
|
"TP9": (118.0, 102.0, 0), "TP10": (133.0, 102.0, 0),
|
|
"TP11": (146.0, 61.0, 0), "TP12": (137.0, 70.0, 0),
|
|
"TP13": (97.0, 58.0, 0), "TP14": (99.0, 58.0, 0),
|
|
"TP15": (91.5, 103.0, 0),
|
|
# Added configuration/JTAG pulls and reset-domain support.
|
|
"R89": (96.0, 65.0, 0),
|
|
"R90": (96.0, 66.2, 0), "R91": (96.0, 67.4, 0), "R92": (96.0, 68.6, 0),
|
|
"R93": (96.0, 72.4, 0), "R94": (96.0, 73.6, 0),
|
|
"R95": (96.0, 74.8, 0), "R96": (96.0, 76.0, 0),
|
|
"R97": (59.0, 65.5, 0), "R98": (145.0, 74.5, 0),
|
|
"R99": (73.0, 60.0, 0), "R100": (73.0, 61.2, 0), "R101": (73.0, 62.4, 0),
|
|
"R102": (84.0, 100.5, 0), "R103": (80.0, 102.0, 0),
|
|
"C120": (145.0, 72.5, 0), "C121": (82.0, 102.0, 0),
|
|
"C122": (142.0, 76.5, 0), "C123": (64.0, 48.5, 0),
|
|
"C124": (69.5, 56.0, 0), "C125": (62.0, 66.5, 0),
|
|
"C126": (136.0, 78.0, 0), "C127": (90.0, 100.5, 0),
|
|
"R104": (83.0, 86.8, 0), "R105": (83.0, 88.0, 0),
|
|
"R106": (83.0, 89.2, 0), "C128": (93.0, 88.0, 0),
|
|
})
|
|
|
|
|
|
# Critical placement references must never silently drift when parts are added
|
|
# earlier in design_data.py. A mismatch here would corrupt RF geometry or put
|
|
# a source-series resistor at the receiver end of a long digital trace.
|
|
CRITICAL_REF_NETS = {
|
|
"C83": {"RF_SMA", "RF_PRE_SAW"},
|
|
"C84": {"RF_POST_SAW", "GND"},
|
|
"R39": {"RF_POST_SAW", "MAX_MIXIN_HI"},
|
|
"C85": {"MAX_MIXIN_HI", "GND"},
|
|
"R56": {"OSC40_OUT", "CLK_40M_FPGA"},
|
|
"R57": {"IMU_SCK", "IMU_SCK_DEV"},
|
|
"R58": {"IMU_MOSI", "IMU_MOSI_DEV"},
|
|
"R59": {"IMU_CS", "IMU_CS_DEV"},
|
|
"R60": {"IMU_MISO", "IMU_MISO_DEV"},
|
|
}
|
|
_component_by_ref = {component.ref: component for component in components}
|
|
for _ref, _expected_nets in CRITICAL_REF_NETS.items():
|
|
_actual_nets = {pin.net for pin in _component_by_ref[_ref].pins if pin.net}
|
|
assert _actual_nets == _expected_nets, (_ref, _actual_nets, _expected_nets)
|
|
|
|
|
|
def local_offsets(count: int, clearance_radius: float) -> list[tuple[float, float]]:
|
|
"""Return dense candidate rings for collision-aware group placement."""
|
|
result: list[tuple[float, float]] = []
|
|
ring = 0
|
|
while len(result) < count:
|
|
r = clearance_radius + ring * 1.20
|
|
slots = max(12, int(2 * math.pi * r / 0.80))
|
|
for idx in range(slots):
|
|
angle = -math.pi / 2 + idx * 2 * math.pi / slots
|
|
result.append((r * math.cos(angle), r * math.sin(angle)))
|
|
if len(result) == count:
|
|
return result
|
|
ring += 1
|
|
return result
|
|
|
|
|
|
def courtyard_box(fp: pcbnew.FOOTPRINT, margin: float = 0.12) -> tuple[float, float, float, float]:
|
|
layers = pcbnew.LSET()
|
|
layers.AddLayer(pcbnew.F_CrtYd)
|
|
layers.AddLayer(pcbnew.B_CrtYd)
|
|
box = fp.GetLayerBoundingBox(layers)
|
|
return (pcbnew.ToMM(box.GetX()) - margin, pcbnew.ToMM(box.GetY()) - margin,
|
|
pcbnew.ToMM(box.GetRight()) + margin, pcbnew.ToMM(box.GetBottom()) + margin)
|
|
|
|
|
|
def boxes_overlap(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> bool:
|
|
return a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]
|
|
|
|
|
|
def add_text(board: pcbnew.BOARD, value: str, x: float, y: float, size: float = 1.0,
|
|
layer: int = pcbnew.F_SilkS, angle: float = 0) -> None:
|
|
text = pcbnew.PCB_TEXT(board)
|
|
text.SetText(value)
|
|
text.SetPosition(point(x, y))
|
|
text.SetTextSize(pcbnew.VECTOR2I(mm(size), mm(size)))
|
|
text.SetTextThickness(mm(max(0.15, size * 0.15)))
|
|
text.SetLayer(layer)
|
|
text.SetTextAngle(pcbnew.EDA_ANGLE(angle, pcbnew.DEGREES_T))
|
|
board.Add(text)
|
|
|
|
|
|
def add_outline(board: pcbnew.BOARD) -> None:
|
|
for start, end in (((X0, Y0), (X1, Y0)), ((X1, Y0), (X1, Y1)),
|
|
((X1, Y1), (X0, Y1)), ((X0, Y1), (X0, Y0))):
|
|
seg = pcbnew.PCB_SHAPE(board)
|
|
seg.SetShape(pcbnew.SHAPE_T_SEGMENT)
|
|
seg.SetStart(point(*start))
|
|
seg.SetEnd(point(*end))
|
|
seg.SetWidth(mm(0.05))
|
|
seg.SetLayer(pcbnew.Edge_Cuts)
|
|
board.Add(seg)
|
|
|
|
|
|
def configure_rules(board: pcbnew.BOARD) -> None:
|
|
ds = board.GetDesignSettings()
|
|
ds.SetBoardThickness(mm(1.6))
|
|
ds.m_TrackMinWidth = mm(0.10)
|
|
ds.m_MinClearance = mm(0.10)
|
|
ds.m_ViasMinSize = mm(0.45)
|
|
ds.m_MinThroughDrill = mm(0.225)
|
|
ds.m_ViasMinAnnularWidth = mm(0.10)
|
|
ds.m_CopperEdgeClearance = mm(0.25)
|
|
ds.m_HoleClearance = mm(0.19)
|
|
|
|
settings = ds.m_NetSettings
|
|
default = settings.GetDefaultNetclass()
|
|
default.SetClearance(mm(0.10))
|
|
default.SetTrackWidth(mm(0.12))
|
|
default.SetViaDiameter(mm(0.45))
|
|
default.SetViaDrill(mm(0.225))
|
|
default.SetDiffPairWidth(mm(0.135))
|
|
default.SetDiffPairGap(mm(0.15))
|
|
|
|
def netclass(name: str, clearance: float, width: float, via: float = 0.45, drill: float = 0.225,
|
|
diff_width: float | None = None, diff_gap: float | None = None) -> None:
|
|
nc = pcbnew.NETCLASS(name)
|
|
nc.SetClearance(mm(clearance))
|
|
nc.SetTrackWidth(mm(width))
|
|
nc.SetViaDiameter(mm(via))
|
|
nc.SetViaDrill(mm(drill))
|
|
if diff_width is not None:
|
|
nc.SetDiffPairWidth(mm(diff_width))
|
|
if diff_gap is not None:
|
|
nc.SetDiffPairGap(mm(diff_gap))
|
|
settings.SetNetclass(name, nc)
|
|
|
|
netclass("BGA_ESCAPE", 0.10, 0.10)
|
|
netclass("RF50_CPWG", 0.20, 0.164)
|
|
netclass("USB90_DIFF", 0.15, 0.135, diff_width=0.135, diff_gap=0.15)
|
|
netclass("CLOCK", 0.15, 0.15)
|
|
# A 0.45/0.225-mm dogbone via with 0.10-mm clearance fits the Ti375's
|
|
# 0.8-mm BGA pitch. The 0.40-mm trunk width is retained; Freerouting's
|
|
# automatic neck-down is allowed only inside the escape field.
|
|
netclass("POWER", 0.10, 0.40, 0.45, 0.225)
|
|
|
|
for pattern in ("RF_SMA", "RF_PRE_SAW", "RF_POST_SAW", "MAX_MIXIN_HI"):
|
|
settings.SetNetclassPatternAssignment(pattern, "RF50_CPWG")
|
|
for pattern in ("USB_D_P", "USB_D_N", "FTDI_D_P", "FTDI_D_N"):
|
|
settings.SetNetclassPatternAssignment(pattern, "USB90_DIFF")
|
|
# Use exact clock families so VCC3V0_CLK does not acquire an accidental
|
|
# aggregate CLOCK,POWER effective class.
|
|
for pattern in ("MAX_CLKOUT*", "MAX_SCLK*", "MAX_ADC_CLKIN", "QSPI_SCLK",
|
|
"CLK_40M_FPGA", "OSC40_OUT", "TCXO16_OUT", "FTDI_OSC12",
|
|
"FTDI_OSCI", "IMU_SCK*", "*TCK*"):
|
|
settings.SetNetclassPatternAssignment(pattern, "CLOCK")
|
|
for pattern in ("VCC*", "SYS5", "VBUS_RAW", "USB5_PROT", "EXT5_*", "GND", "CORE_SW",
|
|
"BUCK1V8_SW", "BUCK3V3_SW", "ANT_BIAS_SEL", "ANT_BIAS_SW",
|
|
"FTDI_1V8", "FTDI_VPHY", "FTDI_VPLL", "IMU_3V3",
|
|
"IMU_VREGA2", "IMU_VREGD", "USB_SHIELD"):
|
|
settings.SetNetclassPatternAssignment(pattern, "POWER")
|
|
settings.SetNetclassPatternAssignment("EXP_*", "BGA_ESCAPE")
|
|
settings.SetNetclassPatternAssignment("PMOD*", "BGA_ESCAPE")
|
|
|
|
|
|
def add_stackup(board: pcbnew.BOARD) -> None:
|
|
board.SetCopperLayerCount(8)
|
|
board.SetLayerName(pcbnew.In1_Cu, "L2_GND")
|
|
board.SetLayerName(pcbnew.In2_Cu, "L3_SIG")
|
|
board.SetLayerName(pcbnew.In3_Cu, "L4_PWR")
|
|
board.SetLayerName(pcbnew.In4_Cu, "L5_SIG")
|
|
board.SetLayerName(pcbnew.In5_Cu, "L6_SIG")
|
|
board.SetLayerName(pcbnew.In6_Cu, "L7_GND")
|
|
board.SetLayerType(pcbnew.In1_Cu, pcbnew.LT_POWER)
|
|
board.SetLayerType(pcbnew.In3_Cu, pcbnew.LT_POWER)
|
|
board.SetLayerType(pcbnew.In6_Cu, pcbnew.LT_POWER)
|
|
|
|
|
|
def place_components(board: pcbnew.BOARD) -> dict[str, pcbnew.FOOTPRINT]:
|
|
net_items: dict[str, pcbnew.NETINFO_ITEM] = {}
|
|
for comp in components:
|
|
for pin in comp.pins:
|
|
if pin.net and pin.net not in net_items:
|
|
item = pcbnew.NETINFO_ITEM(board, pin.net)
|
|
board.Add(item)
|
|
net_items[pin.net] = item
|
|
|
|
loaded: dict[str, pcbnew.FOOTPRINT] = {}
|
|
current_group = "U1"
|
|
groups: dict[str, list] = defaultdict(list)
|
|
for comp in components:
|
|
if comp.ref in MAJOR:
|
|
current_group = comp.ref
|
|
if comp.ref not in MAJOR and comp.ref not in SPECIAL:
|
|
if comp.description.startswith("Ti375 ") or "Efinix mandatory" in comp.description:
|
|
groups["U1"].append(comp)
|
|
else:
|
|
groups[current_group].append(comp)
|
|
|
|
group_positions: dict[str, list[tuple[float, float]]] = {}
|
|
for group, members in groups.items():
|
|
radius = 4.0
|
|
if group == "U1":
|
|
radius = 11.5
|
|
elif group in {"U2", "U13", "U21", "U22"}:
|
|
radius = 5.5
|
|
# Extra candidates let the actual courtyard collision pass skip places
|
|
# occupied by adjacent functional blocks and larger bulk capacitors.
|
|
group_positions[group] = local_offsets(max(1000, len(members) * 40), radius)
|
|
|
|
automatic: list[tuple[object, str]] = []
|
|
current_group = "U1"
|
|
for comp in components:
|
|
path, name = footprint_path(comp.footprint)
|
|
fp = pcbnew.FootprintLoad(str(path), name)
|
|
if fp is None:
|
|
raise RuntimeError(f"Cannot load {comp.footprint} for {comp.ref}")
|
|
fp.SetReference(comp.ref)
|
|
fp.SetValue(comp.value)
|
|
fp.Reference().SetVisible(False)
|
|
fp.Value().SetVisible(False)
|
|
fp.Reference().SetLayer(pcbnew.F_Fab)
|
|
fp.Value().SetLayer(pcbnew.F_Fab)
|
|
# Dense components retain complete assembly/Fab graphics, while the
|
|
# production silkscreen is limited to functional labels and the IMU
|
|
# orientation mark so it cannot print on fine-pitch copper.
|
|
for graphic in fp.GraphicalItems():
|
|
if graphic.GetLayer() == pcbnew.F_SilkS:
|
|
graphic.SetLayer(pcbnew.F_Fab)
|
|
if comp.ref in MAJOR:
|
|
current_group = comp.ref
|
|
x, y, rotation = MAJOR[comp.ref]
|
|
elif comp.ref in SPECIAL:
|
|
x, y, rotation = SPECIAL[comp.ref]
|
|
else:
|
|
group = "U1" if (comp.description.startswith("Ti375 ") or "Efinix mandatory" in comp.description) else current_group
|
|
ax, ay, _ = MAJOR[group]
|
|
x, y, rotation = ax, ay, 0
|
|
automatic.append((comp, group))
|
|
fp.SetPosition(point(x, y))
|
|
fp.SetOrientationDegrees(rotation)
|
|
if comp.dnp:
|
|
fp.SetDNP(True)
|
|
fp.SetExcludedFromPosFiles(True)
|
|
fp.SetField("MPN", comp.mpn)
|
|
fp.SetField("Manufacturer", comp.manufacturer)
|
|
for field_name in ("MPN", "Manufacturer"):
|
|
field = fp.GetField(field_name)
|
|
field.SetVisible(False)
|
|
field.SetLayer(pcbnew.F_Fab)
|
|
board.Add(fp)
|
|
loaded[comp.ref] = fp
|
|
|
|
pad_map: dict[str, list[pcbnew.PAD]] = defaultdict(list)
|
|
for physical_pad in fp.Pads():
|
|
if physical_pad.GetNumber():
|
|
pad_map[physical_pad.GetNumber()].append(physical_pad)
|
|
for pin in comp.pins:
|
|
for physical_pad in pad_map[str(pin.number)]:
|
|
if pin.net:
|
|
physical_pad.SetNet(net_items[pin.net])
|
|
|
|
# Place all automatic local parts after the fixed functional floorplan is
|
|
# known, using their actual KiCad courtyards rather than nominal body size.
|
|
occupied = [courtyard_box(loaded[ref]) for ref in (*MAJOR.keys(), *SPECIAL.keys()) if ref in loaded]
|
|
search_start: dict[str, int] = defaultdict(int)
|
|
for comp, group in automatic:
|
|
fp = loaded[comp.ref]
|
|
ax, ay, _ = MAJOR[group]
|
|
candidates = group_positions[group]
|
|
placed = False
|
|
for idx in range(search_start[group], len(candidates)):
|
|
dx, dy = candidates[idx]
|
|
x, y = ax + dx, ay + dy
|
|
fp.SetPosition(point(x, y))
|
|
candidate = courtyard_box(fp)
|
|
# Keep assembly courtyards on-board; edge connectors are all fixed
|
|
# members and are intentionally permitted to protrude.
|
|
if candidate[0] < X0 + 0.5 or candidate[2] > X1 - 0.5 or candidate[1] < Y0 + 0.5 or candidate[3] > Y1 - 0.5:
|
|
continue
|
|
if any(boxes_overlap(candidate, used) for used in occupied):
|
|
continue
|
|
occupied.append(candidate)
|
|
search_start[group] = idx + 1
|
|
placed = True
|
|
break
|
|
if not placed:
|
|
raise RuntimeError(f"Could not place {comp.ref} near {group} without courtyard overlap")
|
|
board.BuildListOfNets()
|
|
return loaded
|
|
|
|
|
|
def add_mounting_holes(board: pcbnew.BOARD) -> None:
|
|
path = STOCK / "MountingHole.pretty"
|
|
# The lower-left power connector owns the corner; offset H3 so its screw
|
|
# head and courtyard cannot collide with J3.
|
|
for idx, (x, y) in enumerate(((24, 24), (171, 24), (31, 106), (171, 106)), 1):
|
|
fp = pcbnew.FootprintLoad(str(path), "MountingHole_3.2mm_M3")
|
|
if fp is None:
|
|
raise RuntimeError("Missing KiCad M3 mounting-hole footprint")
|
|
fp.SetReference(f"H{idx}")
|
|
fp.Reference().SetVisible(False)
|
|
fp.SetPosition(point(x, y))
|
|
board.Add(fp)
|
|
|
|
|
|
def add_zone(board: pcbnew.BOARD, net_name: str, layer: int,
|
|
bounds: tuple[float, float, float, float], priority: int = 0) -> None:
|
|
"""Add a retained solid inner-layer plane region.
|
|
|
|
These regions are retained even before fanout vias exist so the Specctra
|
|
export contains real plane targets for the router. The final refill then
|
|
clears anti-pads around every unrelated via and plated hole.
|
|
"""
|
|
net = board.FindNet(net_name)
|
|
if net is None:
|
|
raise RuntimeError(f"Cannot create plane for missing net {net_name}")
|
|
x0, y0, x1, y1 = bounds
|
|
zone = pcbnew.ZONE(board)
|
|
zone.SetLayer(layer)
|
|
zone.SetNet(net)
|
|
zone.SetLocalClearance(mm(0.20))
|
|
zone.SetMinThickness(mm(0.10))
|
|
zone.SetThermalReliefGap(mm(0.20))
|
|
zone.SetThermalReliefSpokeWidth(mm(0.25))
|
|
zone.SetPadConnection(pcbnew.ZONE_CONNECTION_FULL)
|
|
zone.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_NEVER)
|
|
zone.SetAssignedPriority(priority)
|
|
outline = zone.Outline()
|
|
outline.NewOutline()
|
|
for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1)):
|
|
outline.Append(point(x, y))
|
|
board.Add(zone)
|
|
|
|
|
|
def add_power_planes(board: pcbnew.BOARD) -> None:
|
|
# Continuous reference planes immediately below the two outer signal
|
|
# layers. A 0.50-mm inset exceeds JLCPCB's copper-to-edge requirement.
|
|
for layer in (pcbnew.In1_Cu, pcbnew.In6_Cu):
|
|
add_zone(board, "GND", layer, (X0 + 0.50, Y0 + 0.50, X1 - 0.50, Y1 - 0.50))
|
|
|
|
# L4 is a deliberately split DC plane. Islands follow the functional
|
|
# floorplan; no high-speed route uses L4 and no signal crosses a split on
|
|
# either adjacent routing layer without a nearby return transition.
|
|
islands = (
|
|
("VCC3V3_AUX", (20.50, 38.00, 67.00, 72.00)),
|
|
("VCC3V3", (39.00, 73.00, 68.00, 85.10)),
|
|
("SYS5", (20.50, 85.50, 90.00, 109.50)),
|
|
("SYS5", (90.40, 106.40, 140.00, 109.50)),
|
|
# Core balls are interspersed with GND/auxiliary balls, so a complete
|
|
# local patch (with automatic anti-pads) replaces the invalid left/right
|
|
# voltage split that previously crossed the BGA field.
|
|
("VCC0V95", (95.00, 60.50, 119.00, 94.00)),
|
|
("VCC3V3", (119.50, 87.00, 140.00, 106.00)),
|
|
("VCC3V0_CLK", (126.00, 50.00, 148.50, 72.00)),
|
|
("VCC3V0_DIG", (119.50, 60.50, 125.50, 72.00)),
|
|
("VCC3V0_DIG", (148.90, 50.40, 164.00, 72.00)),
|
|
("VCC3V3_ANT", (164.40, 50.40, 174.50, 70.00)),
|
|
("VCC3V0_RF", (145.00, 36.00, 174.50, 50.00)),
|
|
)
|
|
for priority, (net_name, bounds) in enumerate(islands, 1):
|
|
add_zone(board, net_name, pcbnew.In3_Cu, bounds, priority)
|
|
|
|
# Local BGA supply patches. L2/L7 remain uninterrupted ground planes;
|
|
# only the area below and immediately south of U1 is reserved on these
|
|
# otherwise routable signal layers.
|
|
add_zone(board, "VCC1V8", pcbnew.In4_Cu, (97.50, 60.50, 122.00, 101.00), 50)
|
|
add_zone(board, "VCC3V3", pcbnew.In5_Cu, (97.50, 60.50, 140.00, 101.00), 50)
|
|
|
|
|
|
def add_temporary_critical_keepouts(board: pcbnew.BOARD) -> None:
|
|
"""Reserve the layers below RF and USB while Freerouting is running.
|
|
|
|
F.Cu deliberately remains available so the autorouted critical nets reserve
|
|
their own top-layer corridors. The rule areas prevent unrelated signal
|
|
tracks and through vias from crossing underneath. finalize_routing.py
|
|
removes these temporary areas before installing the audited copper.
|
|
"""
|
|
layers = pcbnew.LSET()
|
|
for layer in (pcbnew.In2_Cu, pcbnew.In4_Cu, pcbnew.In5_Cu, pcbnew.B_Cu):
|
|
layers.AddLayer(layer)
|
|
for name, bounds in (
|
|
("TEMP_RF_NO_DIGITAL_BELOW", (152.0, 36.0, 174.5, 49.5)),
|
|
("TEMP_USB_NO_CROSSING_BELOW", (22.0, 41.5, 44.5, 57.5))):
|
|
x0, y0, x1, y1 = bounds
|
|
area = pcbnew.ZONE(board)
|
|
area.SetZoneName(name)
|
|
area.SetIsRuleArea(True)
|
|
area.SetLayerSet(layers)
|
|
area.SetDoNotAllowTracks(True)
|
|
area.SetDoNotAllowVias(True)
|
|
area.SetDoNotAllowPads(False)
|
|
area.SetDoNotAllowFootprints(False)
|
|
area.SetDoNotAllowZoneFills(False)
|
|
outline = area.Outline()
|
|
outline.NewOutline()
|
|
for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1)):
|
|
outline.Append(point(x, y))
|
|
board.Add(area)
|
|
|
|
|
|
def add_documentation_graphics(board: pcbnew.BOARD) -> None:
|
|
add_text(board, "Ti375 GNSS / SCH16T", 105, 33.0, 1.1)
|
|
add_text(board, "REV A1 JLC08161H-3313 8L", 105, 35.2, 0.8)
|
|
add_text(board, "USB-C 5V", 31, 35.5, 0.9)
|
|
add_text(board, "EXT_5V", 28, 108.0, 0.9)
|
|
add_text(board, "GPS L1/L1C ACTIVE", 162, 34, 0.9)
|
|
add_text(board, "BIAS J5: 3V3-OFF-5V", 163, 72, 0.8)
|
|
add_text(board, "RF - NO DIGITAL ROUTING BELOW", 151, 69, 0.8)
|
|
add_text(board, "IMU PIN1", 42, 78, 0.8)
|
|
add_text(board, "X+ -> Y+ ^", 51, 84, 0.8)
|
|
add_text(board, "USB JTAG / UART", 55, 38.5, 0.8)
|
|
add_text(board, "EXT_RST", 73, 101.5, 0.8)
|
|
add_text(board, "FPGA RESET", 82, 109.0, 0.8)
|
|
add_text(board, "PMOD1 3V3", 78, 30, 0.8)
|
|
add_text(board, "PMOD2 3V3", 96, 30, 0.8)
|
|
add_text(board, "PMOD3 1V8", 114, 30, 0.8)
|
|
add_text(board, "PMOD4 1V8", 132, 30, 0.8)
|
|
|
|
|
|
STACKUP = '''\t\t(stackup
|
|
\t\t\t(layer "F.SilkS" (type "Top Silk Screen"))
|
|
\t\t\t(layer "F.Paste" (type "Top Solder Paste"))
|
|
\t\t\t(layer "F.Mask" (type "Top Solder Mask") (thickness 0.01))
|
|
\t\t\t(layer "F.Cu" (type "copper") (thickness 0.035))
|
|
\t\t\t(layer "dielectric 1" (type "prepreg") (thickness 0.0994) (material "JLC 3313") (epsilon_r 4.1) (loss_tangent 0.02))
|
|
\t\t\t(layer "L2_GND" (type "copper") (thickness 0.0152))
|
|
\t\t\t(layer "dielectric 2" (type "core") (thickness 0.1) (material "JLC FR-4 core") (epsilon_r 4.36) (loss_tangent 0.02))
|
|
\t\t\t(layer "L3_SIG" (type "copper") (thickness 0.0152))
|
|
\t\t\t(layer "dielectric 3" (type "prepreg") (thickness 0.3568) (material "JLC 3x2116") (epsilon_r 4.16) (loss_tangent 0.02))
|
|
\t\t\t(layer "L4_PWR" (type "copper") (thickness 0.0152))
|
|
\t\t\t(layer "dielectric 4" (type "core") (thickness 0.3) (material "JLC FR-4 core") (epsilon_r 4.41) (loss_tangent 0.02))
|
|
\t\t\t(layer "L5_SIG" (type "copper") (thickness 0.0152))
|
|
\t\t\t(layer "dielectric 5" (type "prepreg") (thickness 0.3568) (material "JLC 3x2116") (epsilon_r 4.16) (loss_tangent 0.02))
|
|
\t\t\t(layer "L6_SIG" (type "copper") (thickness 0.0152))
|
|
\t\t\t(layer "dielectric 6" (type "core") (thickness 0.1) (material "JLC FR-4 core") (epsilon_r 4.36) (loss_tangent 0.02))
|
|
\t\t\t(layer "L7_GND" (type "copper") (thickness 0.0152))
|
|
\t\t\t(layer "dielectric 7" (type "prepreg") (thickness 0.0994) (material "JLC 3313") (epsilon_r 4.1) (loss_tangent 0.02))
|
|
\t\t\t(layer "B.Cu" (type "copper") (thickness 0.035))
|
|
\t\t\t(layer "B.Mask" (type "Bottom Solder Mask") (thickness 0.01))
|
|
\t\t\t(layer "B.Paste" (type "Bottom Solder Paste"))
|
|
\t\t\t(layer "B.SilkS" (type "Bottom Silk Screen"))
|
|
\t\t\t(copper_finish "ENIG")
|
|
\t\t\t(dielectric_constraints yes)
|
|
\t\t)'''
|
|
|
|
|
|
def inject_stackup(path: Path) -> None:
|
|
data = path.read_text()
|
|
if "\n\t\t(stackup\n" in data:
|
|
data = re.sub(r"\n\t\t\(stackup\n.*?\n\t\t\)\n", "\n" + STACKUP + "\n", data, count=1, flags=re.S)
|
|
else:
|
|
data = data.replace("\n\t(setup\n", "\n\t(setup\n" + STACKUP + "\n", 1)
|
|
data = data.replace("(allow_soldermask_bridges_in_footprints no)",
|
|
"(allow_soldermask_bridges_in_footprints yes)")
|
|
path.write_text(data)
|
|
|
|
|
|
def main() -> None:
|
|
board = pcbnew.BOARD()
|
|
add_stackup(board)
|
|
configure_rules(board)
|
|
add_outline(board)
|
|
loaded = place_components(board)
|
|
add_mounting_holes(board)
|
|
add_power_planes(board)
|
|
add_temporary_critical_keepouts(board)
|
|
add_documentation_graphics(board)
|
|
title = board.GetTitleBlock()
|
|
title.SetTitle("Ti375 C529 GPS L1/L1C + SCH16T acquisition board")
|
|
title.SetRevision("A0")
|
|
title.SetCompany("JLCPCB manufacturing design")
|
|
title.SetComment(0, "USB-C 5 V primary; protected EXT_5V; no back-feed")
|
|
title.SetComment(1, "JLC08161H-3313 exact eight-layer construction")
|
|
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
|
|
raise RuntimeError("Initial inner-plane fill failed")
|
|
pcbnew.SaveBoard(str(BOARD_FILE), board)
|
|
inject_stackup(BOARD_FILE)
|
|
print(f"Placed {len(loaded)} schematic components plus four NPTH mounting holes")
|
|
print(f"Saved {BOARD_FILE}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|