#!/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()