Files
pcb/scripts/generate_pcb.py
T
onur 791ca5c9fa Restack the generator and source board to eight layers, matching the deliverable
The routed deliverable has always been eight layers (JLC08161H-3313), but
generate_pcb.py still emitted ten and hardware/board.kicad_pcb was itself still
a ten-layer board. Source and generator agreed with each other and disagreed
with what is actually being fabricated, so running the documented regeneration
flow would have silently replaced the eight-layer design with a conflicting
ten-layer one and destroyed the routing.

generate_pcb.py now builds the real stack: SetCopperLayerCount(8), no L8_PWR /
L9_SIG, and the STACKUP string taken verbatim from board_routed.kicad_pcb - the
two are now byte-identical. Silkscreen reads JLC08161H-3313 8L instead of
JLC10161H 10L, and the title block follows.

The zone layout follows the same correction. With only one dedicated power
plane there is no third plane for the two big FPGA I/O rails, so they are
carried as islands on the routing layers that have room: VCC1V8 on L6 and
VCC3V3 on B.Cu, replacing the ten-layer arrangement of a VCC3V3 background on
L8 and a board-wide GND pour on B.Cu. The regenerated board now reproduces the
routed board's 26 copper zones exactly (the 27th, the F.Cu RF ground pour, is
added later by finalize_routing).

Also drops the In7_Cu/In8_Cu remnants from finalize_routing's RF_NO_DIGITAL
layer set and route_local_power's RF_REGION_LAYERS - those layers do not exist
on this board, so they were dead bits in a layer mask - and corrects the
JLC10161H reference in board.kicad_dru.

The placement check now reports 21 isolated_copper warnings rather than 17.
That is expected and explained in README.md: on an unrouted board the I/O-rail
islands on L6 and B.Cu have nothing joining them yet. The routed board still
reports zero.

Verified: regenerated board is 8 layers with a stackup identical to the routed
board, 312 footprints (311 + R108), TEMP keepouts at their corrected extents.
design_data assertions pass, ERC 0/0, routed board still 0 DRC violations,
0 unconnected, every net one copper group, parity unchanged at 402.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KaTmCCxCEW4awsmXRNT1d
2026-09-01 13:26:37 +03:00

838 lines
34 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, 65, 0),
# The three FTDI-to-FPGA level buffers pulled west toward the FT4232H so
# BDBUS/CDBUS source nets reach them without threading the whole cluster.
"U29": (52.5, 54, 0), "U30": (60, 58, 0), "U31": (55.5, 62.5, 0),
# Dedicated USB-C power input, stacked in the gap between mounting hole H1
# and the J1 debug block on the same west edge. U35 sits at the same
# relative offset from J7 that U5 sits from J1.
"J7": (23.1, 35, 270), "U35": (30.5, 31.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": (171.30, 41.715, 270), "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),
# Widened from 1.2 mm to 2.0 mm pitch and dropped 1.3 mm south of the
# MAX2771 so each debug tap (MAX_I0_DEV etc.) has its own escape lane out
# from under U21.
"R46": (153.6, 51.5, 90), "R47": (155.6, 51.5, 90),
"R48": (157.6, 51.5, 90), "R49": (159.6, 51.5, 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": (30.5, 36.5, 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),
# TPS2121 open-drain status pull-up, beside the U10 gate it holds up.
"R108": (53.0, 101.6, 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:
# Eight-layer 1.6 mm build, JLC08161H-3313. L2 and L7 are the two solid
# GND reference planes and carry no routing; L4 is the only dedicated power
# plane. That leaves five routable layers - F.Cu, L3, L5, L6 and B.Cu -
# which is what the Ti375 escape, the RF front end and the USB pair are
# built around. The two big FPGA I/O rails have no plane of their own and
# live as islands on L6 (VCC1V8) and B.Cu (VCC3V3). The outer F->L2 and
# L7->B prepreg is 3313 (0.0994 mm), which is what sets the F.Cu 50-ohm
# CPWG and 90-ohm USB geometries.
board.SetCopperLayerCount(8)
names = {
pcbnew.In1_Cu: "L2_GND", pcbnew.In2_Cu: "L3_SIG",
pcbnew.In3_Cu: "L4_PWR", pcbnew.In4_Cu: "L5_SIG",
pcbnew.In5_Cu: "L6_SIG", pcbnew.In6_Cu: "L7_GND",
}
for layer, name in names.items():
board.SetLayerName(layer, name)
# L2/L7 solid GND, L4 the core + front-end power plane. The power plane
# sits between GND and a routing layer and is not a layer the BGA escape
# needs, so all five routing layers (F, L3, L5, L6, B) stay usable.
for layer in (pcbnew.In1_Cu, pcbnew.In3_Cu, pcbnew.In6_Cu):
board.SetLayerType(layer, pcbnew.LT_POWER)
for layer in (pcbnew.In2_Cu, pcbnew.In4_Cu, pcbnew.In5_Cu):
board.SetLayerType(layer, pcbnew.LT_SIGNAL)
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:
"""Eight-layer power: solid GND on L2 and L7, the core and front-end rails
on the L4 plane, and the two big FPGA I/O rails as islands on L6 (VCC1V8)
and B.Cu (VCC3V3).
There is no third plane layer to give the I/O rails, so they share the two
routing layers with the most free area. That is why those rails end up
fragmented by signal routing and why the residual-connection stage has to
treat a foreign pour as an obstacle.
"""
full = (X0 + 0.50, Y0 + 0.50, X1 - 0.50, Y1 - 0.50)
for layer in (pcbnew.In1_Cu, pcbnew.In6_Cu):
add_zone(board, "GND", layer, full, clearance=PLANE_CLEARANCE)
# L4 (In3_Cu): core plus the 5-V input, the always-on auxiliary rail and
# the four 3.0-V front-end rails. Islands follow the functional floorplan.
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)),
("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)),
("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):
add_zone(board, net_name, pcbnew.In3_Cu, bounds, priority,
clearance=PLANE_CLEARANCE if net_name == "VCC0V95" else 0.20)
# The two big FPGA I/O rails do not get a plane layer of their own on an
# eight-layer board, so they are carried as islands on the two routing
# layers that have the room: VCC1V8 on L6 over its own loads (the BGA
# config banks, the 1.8-V PMOD headers and the configuration flash), and
# VCC3V3 on B.Cu under the three regions that draw it. Both layers still
# route signals around the islands, which is why the residual-connection
# stage refuses to cross a foreign pour.
l6 = (
("VCC1V8", (93.50, 44.00, 123.00, 92.00), 10),
("VCC1V8", (108.50, 91.50, 128.00, 106.00), 11),
("VCC1V8", (124.00, 20.50, 147.00, 28.50), 12),
)
for net_name, bounds, priority in l6:
add_zone(board, net_name, pcbnew.In5_Cu, bounds, priority,
clearance=PLANE_CLEARANCE)
bcu = (
("VCC3V3", (51.00, 45.00, 76.00, 92.00), 1),
("VCC3V3", (88.50, 22.50, 124.00, 86.00), 2),
("VCC3V3", (128.00, 56.50, 153.00, 104.50), 3),
)
for net_name, bounds, priority in bcu:
add_zone(board, net_name, pcbnew.B_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, 43.70)),
("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.
"""
# Every copper layer under F.Cu except the solid GND planes (L2/L7): the
# routing layers L3/L5/L6/B and the L4 power plane.
inner = (pcbnew.In2_Cu, pcbnew.In3_Cu, pcbnew.In4_Cu, pcbnew.In5_Cu,
pcbnew.B_Cu)
below = pcbnew.LSET()
for layer in inner:
below.AddLayer(layer)
corridor = pcbnew.LSET()
for layer in (pcbnew.F_Cu,) + inner:
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 PWR", 37, 30, 0.8)
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 = ''' (stackup
(layer "F.SilkS"
(type "Top Silk Screen")
)
(layer "F.Paste"
(type "Top Solder Paste")
)
(layer "F.Mask"
(type "Top Solder Mask")
(thickness 0.01)
)
(layer "F.Cu"
(type "copper")
(thickness 0.035)
)
(layer "dielectric 1"
(type "prepreg")
(thickness 0.0994)
(material "JLC 3313")
(epsilon_r 4.1)
(loss_tangent 0.02)
)
(layer "dielectric 2"
(type "copper")
(thickness 0.0152)
(material "FR4")
(epsilon_r 4.5)
(loss_tangent 0.02)
)
(layer "dielectric 3"
(type "core")
(thickness 0.1)
(material "JLC FR-4 core")
(epsilon_r 4.36)
(loss_tangent 0.02)
)
(layer "dielectric 4"
(type "copper")
(thickness 0.0152)
(material "FR4")
(epsilon_r 4.5)
(loss_tangent 0.02)
)
(layer "dielectric 5"
(type "prepreg")
(thickness 0.3568)
(material "JLC 3x2116")
(epsilon_r 4.16)
(loss_tangent 0.02)
)
(layer "dielectric 6"
(type "copper")
(thickness 0.0152)
(material "FR4")
(epsilon_r 4.5)
(loss_tangent 0.02)
)
(layer "dielectric 7"
(type "core")
(thickness 0.3)
(material "JLC FR-4 core")
(epsilon_r 4.41)
(loss_tangent 0.02)
)
(layer "dielectric 8"
(type "copper")
(thickness 0.0152)
(material "FR4")
(epsilon_r 4.5)
(loss_tangent 0.02)
)
(layer "dielectric 9"
(type "prepreg")
(thickness 0.3568)
(material "JLC 3x2116")
(epsilon_r 4.16)
(loss_tangent 0.02)
)
(layer "dielectric 10"
(type "copper")
(thickness 0.0152)
(material "FR4")
(epsilon_r 4.5)
(loss_tangent 0.02)
)
(layer "dielectric 11"
(type "core")
(thickness 0.1)
(material "JLC FR-4 core")
(epsilon_r 4.36)
(loss_tangent 0.02)
)
(layer "dielectric 12"
(type "copper")
(thickness 0.0152)
(material "FR4")
(epsilon_r 4.5)
(loss_tangent 0.02)
)
(layer "dielectric 13"
(type "prepreg")
(thickness 0.0994)
(material "JLC 3313")
(epsilon_r 4.1)
(loss_tangent 0.02)
)
(layer "B.Cu"
(type "copper")
(thickness 0.035)
)
(layer "B.Mask"
(type "Bottom Solder Mask")
(thickness 0.01)
)
(layer "B.Paste"
(type "Bottom Solder Paste")
)
(layer "B.SilkS"
(type "Bottom Silk Screen")
)
(copper_finish "ENIG")
(dielectric_constraints yes)
)'''
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, "J7 USB-C 5 V or EXT_5V, muxed, never both; J1 debug-only, no back-feed")
title.SetComment(1, "JLC08161H-3313 exact eight-layer construction; F->L2 and L7->B keep 3313 for F.Cu impedance")
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()