Files
pcb/scripts/generate_pcb.py
T
onur af7c43b2a8 Restack to four signal layers and route the board
The Ti375 fanout could not be resolved with three routing layers, so L5
stops being a dedicated 1.8-V plane and becomes a signal layer.  No rail
owns a whole layer any more: the supplies are split into zone islands on
L4 and L6, L2 and L7 stay solid and completely unrouted, and F.Cu, L3, L5
and B.Cu are all available for signals.

Routing is staged rather than handed to the autorouter wholesale.  The
BGA escape, the buck high-current loops, plane stitching and the local
power completion all run first and are locked; Freerouting then sees only
the ordinary digital classes, with everything else present as fixed
copper.  finalize_routing.py installs the audited RF and USB geometry and
closes whatever the autorouter left.

Four bugs behind the routing failures, all of them the same shape - a
layer list written down instead of read from the board, which was correct
while L5 was a plane and silently wrong afterwards:

- export_routing_stage.py emitted via keepouts for three layers only, so
  every existing via was invisible to the router on L5.  It drove traces
  straight through them: 102 shorting_items in DRC.
- route_local_power.py never used L5 at all, and picked up escape vias on
  two layers instead of all four.
- add_rf_ground() judged fence positions against vias and pads but not
  tracks.  It runs after the session import, when that region already has
  routed copper, and dropped a ground via on top of a signal trace.

route_local_power.py also grew a via cloud on every rail: reaching a
plane counted as progress even when it merged nothing, so the same pad
collected a via per pass, 0.4 mm further out each time.  Progress is now
measured by the group count actually falling, and groups already sitting
on their plane are skipped.  That removed 404 redundant vias.

The MAX2771 PLL loop filter was spread across three corners of the
package, which forced the charge-pump node onto an inner layer under the
GPS front end.  All three parts now sit in the pocket at U21's north-west
corner and the net stays on F.Cu.  A permanent RF_NO_DIGITAL area plus a
scoped rule keeps that region free of non-power copper on L3/L5/L6/B.Cu,
and the local router enforces the same restriction, since it is the one
stage that runs after the temporary keepout is deleted.

Also enables the solder-mask bridge check that was switched off
board-wide; the board passes it with no violations.

Final board: DRC clean - no shorts, clearance, disallowed-item or
dangling-copper violations - with 26 unconnected endpoints left.  Routing
is spread 37% L3, 27% F.Cu, 26% L5, 10% B.Cu over 6548 mm and 971 vias.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 01:41:43 +03:00

723 lines
33 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
# Anti-pad clearance for planes that have to stay continuous under the Ti375's
# 0.8-mm escape via grid. This is the board's declared minimum copper
# clearance; anything larger merges neighbouring anti-pads and erases the
# plane across the whole BGA footprint. Split L4 islands keep the wider
# default so the gaps between different DC rails stay generous.
PLANE_CLEARANCE = 0.10
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),
# Converter magnetics and capacitors form compact current loops. U13's
# SW bank faces south, so L1 is vertical; U14/U15 SW pins face east and
# their inductors/output capacitors continue directly to the right.
"L1": (100.0, 98.5, 270), "L2": (118.0, 97.0, 0), "L3": (133.0, 97.0, 0),
"C18": (95.7, 91.8, 180), "C19": (95.7, 94.4, 180),
"C20": (104.2, 91.8, 0), "C21": (104.2, 94.4, 0),
"C22": (95.5, 103.5, 270), "C23": (99.0, 103.5, 270),
"C24": (102.5, 103.5, 270), "C25": (106.0, 103.5, 270),
"C26": (112.5, 96.2, 180), "C27": (112.5, 97.8, 180),
"C28": (121.3, 96.0, 0), "C29": (121.3, 98.3, 0),
"C30": (127.5, 96.2, 180), "C31": (127.5, 97.8, 180),
"C32": (136.3, 96.0, 0), "C33": (136.3, 98.3, 0),
# Core remote-sense and compensation stay on the quiet north side of U13.
"C16": (96.2, 89.0, 180), "R29": (98.1, 89.0, 180),
"C17": (100.0, 89.0, 0), "R27": (104.0, 87.8, 180),
"R28": (104.0, 89.2, 180),
"R31": (115.0, 94.3, 0), "R32": (130.0, 94.3, 0),
# FB3 feeds the FT4232H PLL supply on pin 9, which sits south of the USB
# pair corridor. Keeping the bead on that side avoids a supply trace that
# would otherwise have to cross the reserved differential run.
"FB1": (60.0, 88.5, 0), "FB2": (36.0, 49.0, 0), "FB3": (36.5, 58.2, 0),
# The 12-MHz series damping resistor moves west with its oscillator to keep
# the space south of the FT4232H free for FB3.
"R67": (32.0, 58.0, 0),
# USB series pair and FT4232 REF resistor follow the device pin order.
"R62": (37.0, 55.50, 0), "R63": (37.0, 54.50, 0),
# R64 is the FT4232H reference resistor, not part of the pair; it moves off
# the terminator column so the reserved pair corridor stays empty.
"R64": (36.0, 51.50, 0),
# The shield network belongs at the receptacle shell, not east of the
# contacts: automatic placement filled the only corridor that carries
# USB_D_P/USB_D_N away from J1. Pin all three parts south of the shell,
# clear of the pair's diagonal run towards the series terminators.
"C2": (24.0, 55.5, 0), "R9": (26.5, 57.5, 0), "R10": (29.5, 57.5, 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),
# PLL loop filter. CPOUT is a high-impedance charge-pump node, so the
# whole branch sits in the free pocket at U21's north-west corner rather
# than being spread around the package: pin 1 is 2 mm from the 15-pF shunt
# and the entire net stays on F.Cu. Scattering these parts is what forced
# CPOUT onto an inner layer underneath the GPS front end. The column stops
# short of x = 154.4 so it never enters the reserved RF corridor.
"C88": (153.60, 40.80, 90),
"R43": (153.60, 38.85, 90),
"C89": (154.80, 38.85, 90),
# 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")
# L5 is a routing layer, not a rail. Declaring it signal is what puts
# it into the Specctra export as routable: a layer left as "power" is
# offered to the router as "not used for routing", which is exactly the
# congestion this stackup exists to avoid.
board.SetLayerName(pcbnew.In4_Cu, "L5_SIG")
board.SetLayerName(pcbnew.In5_Cu, "L6_PWR")
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.In4_Cu, pcbnew.LT_SIGNAL)
board.SetLayerType(pcbnew.In5_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,
clearance: float = 0.20) -> 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.
`clearance` decides whether a plane survives under the Ti375. Its escape
vias sit on a 0.8-mm grid, so at the default 0.20 mm every anti-pad
(0.225 + 0.20 = 0.425 mm radius) overlaps its neighbour and the plane is
erased across the whole BGA footprint. At the board minimum of 0.10 mm the
anti-pads are 0.325 mm and a 0.15-mm web survives between them, which is
what lets an isolated bank-supply ball reach its rail at all.
"""
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(clearance))
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:
"""Ground on L2/L7, and the supply rails split across L4 and L6.
No rail owns a whole layer. L4 carries the core, the 5-V input rail, the
always-on auxiliary rail and the four 3.0-V front-end rails; L6 carries the
two large FPGA I/O rails. That leaves L3, L5 and B.Cu free for routing,
which is where the Ti375's fanout has to go.
"""
# Continuous reference planes immediately below the two outer signal
# layers. A 0.50-mm inset exceeds JLCPCB's copper-to-edge requirement.
# These are the return path for every routed layer, so they use the tight
# anti-pad clearance and stay continuous through the Ti375 via field. L2
# in particular is the reference under the whole GPS front end and is never
# split between "RF ground" and "digital ground".
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),
clearance=PLANE_CLEARANCE)
# L4: everything except the two big I/O rails. 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 via.
l4 = (
("VCC3V3_AUX", (20.50, 36.00, 74.00, 72.00)),
("VCC3V3_AUX", (53.00, 88.00, 70.00, 105.00)),
("VCC3V3_AUX", (77.50, 84.00, 94.20, 100.50)),
("VCC3V3_AUX", (133.00, 74.50, 152.00, 82.00)),
("SYS5", (20.50, 85.50, 52.50, 109.50)),
("SYS5", (89.50, 103.60, 145.00, 109.50)),
# A corridor up the east edge carries 5 V to the antenna-bias block
# instead of leaving those three pads stranded 55 mm from the rail.
("SYS5", (140.00, 103.00, 174.50, 109.50)),
("SYS5", (144.00, 86.50, 174.50, 104.00)),
("SYS5", (157.00, 72.00, 174.50, 87.00)),
("SYS5", (157.00, 63.50, 174.50, 72.50)),
("VCC3V0_CLK", (126.00, 48.50, 156.00, 67.00)),
("VCC3V0_RF", (140.00, 58.50, 150.50, 65.50)),
("VCC3V0_DIG", (134.00, 62.50, 147.00, 74.00)),
# Core balls are interspersed with GND/auxiliary balls, so a complete
# local patch (with automatic anti-pads) replaces any voltage split
# crossing the BGA field.
("VCC0V95", (94.50, 56.00, 121.00, 103.00)),
("VCC3V0_RF", (146.00, 39.80, 174.50, 50.50)),
("VCC3V0_DIG", (149.00, 35.20, 161.00, 39.60)),
("VCC3V0_DIG", (151.50, 44.80, 159.00, 48.60)),
("VCC3V3_ANT", (162.00, 53.50, 174.50, 63.20)),
)
for priority, (net_name, bounds) in enumerate(l4, 1):
# Only the core island sits under the BGA and needs the tight anti-pad.
add_zone(board, net_name, pcbnew.In3_Cu, bounds, priority,
clearance=PLANE_CLEARANCE if net_name == "VCC0V95" else 0.20)
# L6: the two FPGA I/O rails. VCC3V3 is the background because its loads
# are spread over the whole west and the RF corner; VCC1V8 is carved out
# where its own loads sit - under the BGA, along the 1.8-V PMOD headers,
# over the configuration flash and at its converter output. The carve-outs
# overlap each other so the rail stays one connected pour.
l6 = (
# Overlapping zones need distinct priorities even when they carry the
# same net, so the carve-outs are numbered rather than sharing one.
("VCC3V3", (X0 + 0.50, Y0 + 0.50, X1 - 0.50, Y1 - 0.50), 1),
("VCC1V8", (99.00, 39.50, 122.00, 92.00), 10),
("VCC1V8", (115.00, 20.50, 174.50, 40.50), 11),
("VCC1V8", (94.00, 45.00, 106.00, 56.00), 12),
("VCC1V8", (108.50, 91.50, 127.00, 106.00), 13),
("VCC3V3", (128.00, 91.50, 141.00, 106.00), 20),
)
for net_name, bounds, priority in l6:
add_zone(board, net_name, pcbnew.In5_Cu, bounds, priority,
clearance=PLANE_CLEARANCE)
# Areas the autorouter and the power stages must leave alone until
# finalize_routing.py installs the audited RF and USB copper. The "below"
# regions keep unrelated copper off the reference and routing layers under the
# GPS front end and the USB pair; the "corridor" regions additionally reserve
# F.Cu along the exact paths that finalize_routing.py later builds, since the
# critical nets themselves are not part of the Specctra export.
CRITICAL_KEEPOUTS_BELOW = (
("TEMP_RF_NO_DIGITAL_BELOW", (152.00, 36.00, 174.50, 49.50)),
("TEMP_USB_NO_CROSSING_BELOW", (26.00, 46.00, 39.60, 56.60)),
)
CRITICAL_KEEPOUTS_CORRIDOR = (
# GPS chain: SMA launch, bias tee, DC block, SAW, series R and MIXIN_HI.
("TEMP_RF_LINE", (154.40, 40.00, 174.60, 43.70)),
# USB pair: receptacle breakout and the mirrored-contact crossover.
("TEMP_USB_BREAKOUT", (27.30, 46.90, 30.65, 49.60)),
# USB pair: vertical channel into the TPD2EUSB30A clamp, and the short
# southward step D_P needs to clear the clamp's ground pad.
("TEMP_USB_CHANNEL", (29.60, 49.60, 30.68, 52.60)),
# USB pair: 45-degree run from the clamp to the series terminators.
("TEMP_USB_DIAGONAL", (30.68, 52.20, 35.30, 56.00)),
# USB pair: terminators and the matched legs into the FT4232H PHY.
("TEMP_USB_TERMINATORS", (34.80, 54.35, 39.45, 55.60)),
)
def add_temporary_critical_keepouts(board: pcbnew.BOARD) -> None:
"""Reserve the RF and USB geometry while the rest of the board is routed.
finalize_routing.py removes every TEMP_ area before installing the audited
copper, so nothing here reaches the manufactured board.
"""
below = pcbnew.LSET()
for layer in (pcbnew.In2_Cu, pcbnew.In4_Cu, pcbnew.In5_Cu, pcbnew.B_Cu):
below.AddLayer(layer)
corridor = pcbnew.LSET()
for layer in (pcbnew.F_Cu, pcbnew.In2_Cu, pcbnew.In4_Cu,
pcbnew.In5_Cu, pcbnew.B_Cu):
corridor.AddLayer(layer)
for layers, group in ((below, CRITICAL_KEEPOUTS_BELOW),
(corridor, CRITICAL_KEEPOUTS_CORRIDOR)):
for name, bounds in group:
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_PWR" (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)
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()