1ea2bc64ac
The power mux's status output had no pull-up, which broke the USB current budget interlock. TPS2121 ST is an open-drain output; the datasheet lists RST = 6-20 kOhm under Recommended Operating Conditions, alongside RILM, so it is an external part. ST is driven low only while IN2 (the J3 external 5 V header) is sourcing OUT, and is high impedance whenever IN1 (the J7 USB-C input) is sourcing. U10 gate 2 inverts MUX_ST into EXT_SELECTED, and MAIN_EN = USB_3A_OK OR EXT_SELECTED enables the 15 A core buck. With MUX_ST floating on USB-C power, a drift low asserts MAIN_EN no matter what the TUSB320 advertised, so the board would try to bring up the core rail on a 500 mA port. Every other open-drain net here already has its pull-up (R7/R8, R30, R33/R34, R98/R104/R105/R106); this one was missed. R108 (10 k, VCC3V3_AUX -> MUX_ST) is appended at the end of design_data.py rather than beside the mux: reference designators are assigned in call order, so inserting it mid-file would renumber every later resistor and invalidate the routed board. Placed at (53.0, 101.6), 2.4 mm from the U10 gate it holds up, on a site checked clear of existing copper, and routed with close_remaining.py. Also corrects POWER_TREE.md, which credited PR1 for giving the external input priority. PR1 = GND actually *disables* priority mode; CP2 (tied to VCC3V3_AUX) is what selects IN2. Since CP2 derives from the mux's own output the behaviour is two-phase - highest-voltage wins at cold start, IN2 unconditionally once the aux rail is up - so with both inputs connected there can be one seamless switchover at startup. Documented, with the numbers: both eFuses UV 3.98 V / OV 5.80 V, ILIM 2.69 A (USB-C) and 4.04 A (header), mux OV 5.69 V / ILIM 4.5 A. The two inputs are never summed: both eFuses block reverse current and the mux bonds exactly one input at a time. board_routed.kicad_sch/.kicad_sym are now gitignored byte copies of the tracked schematic; kicad-cli needs them beside the board for --schematic-parity, and deleting them in the previous commit had silently disabled that check. Verified: ERC 0/0, DRC 0 violations, 0 unconnected, every net one copper group, schematic parity unchanged at the 402 pre-existing warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012KaTmCCxCEW4awsmXRNT1d
737 lines
34 KiB
Python
737 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
"""Create the placed ten-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": (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),
|
|
# 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:
|
|
# Ten-layer 1.6 mm build. The Ti375 north-east escape carries ~54 signals
|
|
# (bank 2E PMOD3/4 + the HVIO banks' PMOD1/2, the MAX2771 interface, both
|
|
# JTAG chains, QSPI, IMU) out of a ~10 mm span; five routing layers gave
|
|
# ~60 escape slots for 54 nets and the last handful never fit. Six routing
|
|
# layers (F, L3, L5, L7, L9, B) restore the margin. L6 is the single power
|
|
# plane - every rail lives there as an island - so it is the only layer the
|
|
# escape cannot use, and it is not needed there. L2/L4/L8 are solid GND;
|
|
# the outer F->L2 and L9->B prepreg is unchanged 3313 (0.0994 mm), so the
|
|
# F.Cu 50-ohm CPWG and 90-ohm USB geometries carry over untouched.
|
|
board.SetCopperLayerCount(10)
|
|
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",
|
|
pcbnew.In7_Cu: "L8_PWR", pcbnew.In8_Cu: "L9_SIG",
|
|
}
|
|
for layer, name in names.items():
|
|
board.SetLayerName(layer, name)
|
|
# L2/L7 solid GND; L4 the core + front-end power plane; L8 the two big FPGA
|
|
# I/O rails (VCC3V3 background, VCC1V8 carved). Both power planes sit
|
|
# between GND and a routing layer, and neither is a layer the BGA escape
|
|
# needs, so all six routing layers (F, L3, L5, L6, L9, B) stay clean.
|
|
for layer in (pcbnew.In1_Cu, pcbnew.In3_Cu, pcbnew.In6_Cu, pcbnew.In7_Cu):
|
|
board.SetLayerType(layer, pcbnew.LT_POWER)
|
|
for layer in (pcbnew.In2_Cu, pcbnew.In4_Cu, pcbnew.In5_Cu, pcbnew.In8_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:
|
|
"""Ten-layer power: solid GND on L2/L7 and B.Cu, the core + front-end rails
|
|
on the L4 plane, and the two big FPGA I/O rails on the L8 plane.
|
|
|
|
Both power planes sit between a GND plane and a routing layer. Neither is
|
|
a layer the Ti375 escape needs, so all six routing layers (F, L3, L5, L6,
|
|
L9, B) stay clean and the I/O-rail pads stitch straight into L8 instead of
|
|
needing a via to a buried background.
|
|
"""
|
|
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)
|
|
add_zone(board, "GND", pcbnew.B_Cu, full, priority=0, clearance=0.20)
|
|
|
|
# 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)
|
|
|
|
# L8 (In7_Cu): the two big FPGA I/O rails. VCC3V3 is the board-wide
|
|
# background; VCC1V8 is carved out under the BGA config banks, along the
|
|
# 1.8-V PMOD headers, over the configuration flash and at its converter.
|
|
l8 = (
|
|
("VCC3V3", full, 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 l8:
|
|
add_zone(board, net_name, pcbnew.In7_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/L9/B and the L4/L8 power planes.
|
|
inner = (pcbnew.In2_Cu, pcbnew.In3_Cu, pcbnew.In4_Cu, pcbnew.In5_Cu,
|
|
pcbnew.In7_Cu, pcbnew.In8_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 JLC10161H 10L", 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 = '''\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.19) (material "JLC FR-4 core") (epsilon_r 4.4) (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.15) (material "JLC 2116") (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.19) (material "JLC FR-4 core") (epsilon_r 4.4) (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.15) (material "JLC 2116") (epsilon_r 4.16) (loss_tangent 0.02))
|
|
\t\t\t(layer "L6_SIG" (type "copper") (thickness 0.0152))
|
|
\t\t\t(layer "dielectric 6" (type "core") (thickness 0.19) (material "JLC FR-4 core") (epsilon_r 4.4) (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.15) (material "JLC 2116") (epsilon_r 4.16) (loss_tangent 0.02))
|
|
\t\t\t(layer "L8_PWR" (type "copper") (thickness 0.0152))
|
|
\t\t\t(layer "dielectric 8" (type "core") (thickness 0.19) (material "JLC FR-4 core") (epsilon_r 4.4) (loss_tangent 0.02))
|
|
\t\t\t(layer "L9_SIG" (type "copper") (thickness 0.0152))
|
|
\t\t\t(layer "dielectric 9" (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, "J7 USB-C 5 V or EXT_5V, muxed, never both; J1 debug-only, no back-feed")
|
|
title.SetComment(1, "JLC10161H exact ten-layer construction; F->L2 and L9->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()
|