Restack to four signal layers and route the board
The Ti375 fanout could not be resolved with three routing layers, so L5 stops being a dedicated 1.8-V plane and becomes a signal layer. No rail owns a whole layer any more: the supplies are split into zone islands on L4 and L6, L2 and L7 stay solid and completely unrouted, and F.Cu, L3, L5 and B.Cu are all available for signals. Routing is staged rather than handed to the autorouter wholesale. The BGA escape, the buck high-current loops, plane stitching and the local power completion all run first and are locked; Freerouting then sees only the ordinary digital classes, with everything else present as fixed copper. finalize_routing.py installs the audited RF and USB geometry and closes whatever the autorouter left. Four bugs behind the routing failures, all of them the same shape - a layer list written down instead of read from the board, which was correct while L5 was a plane and silently wrong afterwards: - export_routing_stage.py emitted via keepouts for three layers only, so every existing via was invisible to the router on L5. It drove traces straight through them: 102 shorting_items in DRC. - route_local_power.py never used L5 at all, and picked up escape vias on two layers instead of all four. - add_rf_ground() judged fence positions against vias and pads but not tracks. It runs after the session import, when that region already has routed copper, and dropped a ground via on top of a signal trace. route_local_power.py also grew a via cloud on every rail: reaching a plane counted as progress even when it merged nothing, so the same pad collected a via per pass, 0.4 mm further out each time. Progress is now measured by the group count actually falling, and groups already sitting on their plane are skipped. That removed 404 redundant vias. The MAX2771 PLL loop filter was spread across three corners of the package, which forced the charge-pump node onto an inner layer under the GPS front end. All three parts now sit in the pocket at U21's north-west corner and the net stays on F.Cu. A permanent RF_NO_DIGITAL area plus a scoped rule keeps that region free of non-power copper on L3/L5/L6/B.Cu, and the local router enforces the same restriction, since it is the one stage that runs after the temporary keepout is deleted. Also enables the solder-mask bridge check that was switched off board-wide; the board passes it with no violations. Final board: DRC clean - no shorts, clearance, disallowed-item or dangling-copper violations - with 26 unconnected endpoints left. Routing is spread 37% L3, 27% F.Cu, 26% L5, 10% B.Cu over 6548 mm and 971 vias. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+109
-29
@@ -60,70 +60,147 @@ beklenmez.
|
||||
|
||||
Ti375'in bağlı ball'larına kilitli 0.8 mm pitch dogbone/via escape ekler.
|
||||
|
||||
### 2. Yerel plane bağlantıları
|
||||
### 2. Güç katı
|
||||
|
||||
```bash
|
||||
/usr/bin/python3 scripts/route_power_stage.py \
|
||||
hardware/board_prerouted.kicad_pcb \
|
||||
hardware/board_power_routed.kicad_pcb
|
||||
```
|
||||
|
||||
Autorouter'dan önce aşağıdaki yüksek akım hatlarını deterministik ve kilitli
|
||||
olarak route eder:
|
||||
|
||||
- TPS6287B15 giriş kapasitörü, SW, indüktör, çıkış kapasitörü ve remote-sense.
|
||||
- TPS62867 1.8 V / 3.3 V giriş-SW-çıkış döngüleri ve plane via dizileri.
|
||||
- Converter bölgesindeki SYS5 dağıtımı ve ana güç geçişleri.
|
||||
|
||||
SW node bakırı küçük tutulmalı; feedback/sense hatları SW ve indüktörden uzak,
|
||||
Kelvin bağlantılı götürülmelidir. Autorouter bu kararları vermez.
|
||||
|
||||
Bu aşama stitching'den **önce** gelir. Tersi sırada plane stitching, buck
|
||||
çıkışının geçmesi gereken koridora via bırakıp yüksek akım yolunu kesiyordu.
|
||||
|
||||
### 3. Yerel plane bağlantıları
|
||||
|
||||
```bash
|
||||
/usr/bin/python3 scripts/stitch_plane_pads.py \
|
||||
hardware/board_prerouted.kicad_pcb \
|
||||
hardware/board_power_routed.kicad_pcb \
|
||||
hardware/board_plane_stitched.kicad_pcb
|
||||
```
|
||||
|
||||
Yalnızca altında gerçekten aynı nete ait dolu plane bulunan SMD güç/GND
|
||||
padlerini kısa dogbone ile bağlar. Bu işlem yüksek akım buck loop routinginin
|
||||
yerini tutmaz.
|
||||
yerini tutmaz. RF/USB geçici keepoutlarına through-via koymaz; edge-launch J2
|
||||
ground bağlantıları final RF ground zone/via-fence aşamasına bırakılır.
|
||||
|
||||
### 3. Güç katı
|
||||
### 4. Yerel güç tamamlama
|
||||
|
||||
KiCad içinde önce aşağıdaki hatlar elle route edilmelidir:
|
||||
```bash
|
||||
/usr/bin/python3 scripts/route_local_power.py \
|
||||
hardware/board_plane_stitched.kicad_pcb \
|
||||
hardware/board_power_local.kicad_pcb --clocks
|
||||
```
|
||||
|
||||
- TPS6287B15 giriş kapasitörü, SW, indüktör, çıkış kapasitörü ve remote-sense.
|
||||
- TPS62867 1.8 V ve 3.3 V converter loopları.
|
||||
- SYS5 dağıtımı ve ana güç geçişleri.
|
||||
- LDO giriş/çıkış bypass bağlantıları.
|
||||
`--clocks` clock sınıfını da burada, autorouter başlamadan kapatır; böylece
|
||||
saat hatları sabit bakır olarak DSN'e girer ve router onları bozamaz.
|
||||
|
||||
SW node bakırı küçük tutulmalı; feedback/sense hatları SW ve indüktörden uzak,
|
||||
Kelvin bağlantılı götürülmelidir. Autorouter bu kararları vermez.
|
||||
Plane ve plane stitching'in ulaşamadığı güç bağlantılarını tamamlar: adasının
|
||||
hemen dışında kalan pad'ler, plane'i olmayan küçük raylar (`FTDI_1V8`,
|
||||
`IMU_VREGD`, anten bias hatları) ve her LDO'nun etrafındaki yerel giriş/çıkış
|
||||
döngüleri. Gerçek connectivity grafiği üzerinde çalışır: ayrık kalan her grup
|
||||
için ya kendi plane'ine via indirir ya da en yakın gruba 45 derecelik yol
|
||||
çeker. Bütün sonuçlar kilitlidir.
|
||||
|
||||
### 4. Freerouting için sınıf bazlı DSN
|
||||
İki fazlıdır. Önce bütün raylar için yalnızca plane inişi ve kısa doğrudan
|
||||
yollar denenir; ikinci fazda kalanlar ara nokta üzerinden dolaşabilir. Bu ayrım
|
||||
olmadan listedeki ilk ray, sonraki bir rayın kısa bağlantı için ihtiyaç duyduğu
|
||||
boşluğu uzun yollarla harcar.
|
||||
|
||||
Bir grubu kendi plane'ine indirmek, o adada başka grup yoksa grup sayısını
|
||||
düşürmez. Bu yüzden ilerleme, `reach_plane`'in döndürdüğü değere değil grup
|
||||
sayısının gerçekten azalmasına bakılarak ölçülür ve zaten plane'e bağlı gruplar
|
||||
atlanır. Aksi halde aynı pad çevresinde her turda 0.4 mm daha dışarıda yeni bir
|
||||
via açılır ve ray tek bir pad'in etrafında onlarca gereksiz via'dan oluşan bir
|
||||
"via cloud" ile biter.
|
||||
|
||||
Bu aşama güç raylarını autorouter'a bırakmamanın karşılığıdır. Freerouting
|
||||
DSN'de plane göremez; `GND` ve `VCC3V3` gibi rayları ona verseydik yüzlerce
|
||||
pad'i plane yerine trace ile birbirine bağlamaya çalışırdı.
|
||||
|
||||
### 5. Freerouting için sınıf bazlı DSN
|
||||
|
||||
```bash
|
||||
/usr/bin/python3 scripts/export_routing_stage.py \
|
||||
hardware/board_plane_stitched.kicad_pcb outputs/signal_stage.dsn \
|
||||
hardware/board_power_local.kicad_pcb outputs/signal_stage.dsn \
|
||||
--classes "Default;BGA_ESCAPE;CLOCK" \
|
||||
--preview outputs/signal_stage_preview.kicad_pcb
|
||||
|
||||
python3 scripts/prepare_freerouting_dsn.py \
|
||||
outputs/signal_stage.dsn outputs/signal_stage_freerouting.dsn
|
||||
--extra-nets "ANT_BIAS_SW;EXT5_PROT;FTDI_1V8;FTDI_VPHY;FTDI_VPLL;USB5_PROT;VBUS_RAW" \
|
||||
--fixed-copper keepout
|
||||
```
|
||||
|
||||
İkinci script Freerouting 2.3.0/KiCad 10 DSN clearance ölçek farkını düzeltir;
|
||||
koordinatlara, track genişliklerine, via veya keepoutlara dokunmaz. Freerouting
|
||||
arayüzünde düzeltilmiş DSN açılır, en fazla 8 thread kullanılır ve sonuç `.ses`
|
||||
olarak kaydedilir.
|
||||
`--extra-nets`, POWER sınıfında olduğu için sınıf filtresine takılan ama
|
||||
plane'i de bulunmayan rayları listeler; yerel tamamlama bunlardan artakalan
|
||||
bağlantıları autorouter'a devreder.
|
||||
|
||||
RF, USB, güç converter loopları ve hassas clock kararları otomatik sonuca
|
||||
bırakılmamalıdır. `to route` sayısının uzun süre düşmemesi halinde aynı işlemi
|
||||
saatlerce sürdürmek yerine placement/escape/keepout çatışması incelenmelidir.
|
||||
Route edilmeyen netlerin bakırı `--fixed-copper keepout` ile her parça için bir
|
||||
rule area olarak aktarılır: router o bakırı görmez, yalnızca etrafından dolaşır.
|
||||
`--fixed-copper wire` alternatifi bakırı kendi netinde `(type fix)` wire olarak
|
||||
yazar ve netleri network bölümünde pinsiz tanımlar; daha az alan üretir ama bu
|
||||
kartta pass başına yaklaşık dört kat yavaş çalışır.
|
||||
|
||||
### 5. Kritik hatları kur ve session'ı sonlandır
|
||||
Buradaki alan sayısı doğrudan yerel güç aşamasının ne kadar bakır bıraktığına
|
||||
bağlıdır: via cloud'lar temizlendikten sonra 4146 keepout 2541'e düştü.
|
||||
|
||||
Bundled Freerouting 2.3.0 build'i KiCad 10 DSN clearance değerlerini doğrudan
|
||||
doğru ölçekte okur; clearance değerleri ölçeklenmez. Bu sürüm çok-thread route
|
||||
optimizasyonunun clearance ihlali üretebildiğini kendi logunda bildirir, bu
|
||||
nedenle `-mt 1` kullanılır ve sonuç `.ses` olarak kaydedilir.
|
||||
|
||||
```bash
|
||||
.tools/freerouting-2.3.0/app/freerouting-2.3.0-linux-x64/bin/freerouting \
|
||||
-de outputs/signal_stage.dsn -do outputs/signal_stage.ses \
|
||||
-mp 60 -mt 1 -us Greedy -is prioritized
|
||||
```
|
||||
|
||||
RF, USB ve güç converter loopları otomatik sonuca bırakılmaz. Bu netler DSN'de
|
||||
hiç yoktur; yerlerini `generate_pcb.py` içindeki `TEMP_` rule area'ları tutar.
|
||||
`TEMP_*_BELOW` alanları RF ve USB altındaki referans/routing katmanlarını,
|
||||
`TEMP_RF_LINE` ve `TEMP_USB_*` koridorları ise `finalize_routing.py`'nin
|
||||
kuracağı geometrinin F.Cu izini korur. CLOCK sınıfı kendi genişlik/clearance
|
||||
kurallarıyla route edilir ve finalde ayrıca incelenir.
|
||||
|
||||
### 6. Kritik hatları kur ve session'ı sonlandır
|
||||
|
||||
```bash
|
||||
/usr/bin/python3 scripts/finalize_routing.py \
|
||||
outputs/signal_stage.ses \
|
||||
--base hardware/board_power_local.kicad_pcb \
|
||||
--output hardware/board_routed.kicad_pcb
|
||||
```
|
||||
|
||||
Bu işlem Freerouter'ın RF ve USB üzerindeki copperını kaldırır, denetlenmiş
|
||||
RF/USB geometrisini ekler, RF GND via fence'i kurar ve zone'ları doldurur.
|
||||
Bu işlem Freerouter'ın RF ve USB üzerindeki copperını kaldırır, geçici rule
|
||||
area'ları siler, denetlenmiş RF/USB geometrisini ekler ve RF GND via fence'i
|
||||
kurar. Ardından koridorlar boşaldığı için `stitch_plane_pads` ve
|
||||
`route_local_power` bir kez daha çalıştırılır; bu, RF/USB bölgesindeki güç
|
||||
pad'lerinin plane'e ulaşabildiği tek andır. Son olarak plane'lerde island
|
||||
temizliği açılır ve zone'lar doldurulur.
|
||||
|
||||
### 6. Final kontroller
|
||||
### 7. Final kontroller
|
||||
|
||||
```bash
|
||||
kicad-cli pcb drc --severity-all --schematic-parity --refill-zones \
|
||||
--save-board -o outputs/drc_final_routed.rpt \
|
||||
hardware/board_routed.kicad_pcb
|
||||
|
||||
/usr/bin/python3 scripts/report_routing.py hardware/board_routed.kicad_pcb
|
||||
```
|
||||
|
||||
DRC "yanlış olan var mı" sorusunu, `report_routing.py` ise "eksik olan var mı
|
||||
ve bakır kurallarına uyuyor mu" sorusunu yanıtlar: hâlâ birden fazla parçaya
|
||||
bölünmüş netleri, net-class başına gerçekten kullanılan hat genişliğini
|
||||
(Specctra gidiş-dönüşünde sessizce değişebilen değer) ve katman dağılımını
|
||||
listeler.
|
||||
|
||||
Final kartta unconnected item, short, clearance, isolated copper veya
|
||||
schematic-parity hatası kabul edilmez. RF uzunlukları, USB eşleşmesi, güç
|
||||
loopları, return path ve test-point erişimi görsel olarak da incelenmelidir.
|
||||
@@ -139,6 +216,9 @@ loopları, return path ve test-point erişimi görsel olarak da incelenmelidir.
|
||||
| `generate_pcb.py` | Stackup, rule, zone ve deterministic placement üretimi |
|
||||
| `preroute_bga.py` | Kilitli Ti375 dogbone escape |
|
||||
| `stitch_plane_pads.py` | Yerel pad-to-plane bağlantıları |
|
||||
| `route_power_stage.py` | Buck yüksek-akım döngüleri ve plane girişleri |
|
||||
| `route_local_power.py` | Plane'e ulaşamayan güç bağlantılarının deterministik tamamlanması |
|
||||
| `export_routing_stage.py` | Seçili net-class için DSN üretimi |
|
||||
| `prepare_freerouting_dsn.py` | Freerouting 2.3 clearance düzeltmesi |
|
||||
| `prepare_freerouting_dsn.py` | Yalnız doğrulanmış farklı router build'leri için opsiyonel DSN clearance ölçekleme |
|
||||
| `finalize_routing.py` | SES importu ve denetlenmiş RF/USB geometrisi |
|
||||
| `report_routing.py` | Routed kartta eksik bağlantı, hat genişliği ve katman dağılımı raporu |
|
||||
|
||||
@@ -4,11 +4,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pcbnew
|
||||
|
||||
|
||||
NET_TOKEN = re.compile(r'\(net\s+("[^"]+"|[^\s()]+)')
|
||||
RULE_WIDTH = re.compile(r'(\(width\s+)(\d+(?:\.\d+)?)')
|
||||
# Keepouts describe the copper outline itself and nothing more. Freerouting
|
||||
# already applies the routing clearance to a keepout boundary, so any envelope
|
||||
# added here is counted twice: measured against a 0.10-mm envelope it kept
|
||||
# 0.20 mm, against plain wiring 0.102 mm. In the Ti375 escape field that is
|
||||
# the difference between a usable 0.385-mm channel and a 0.485-mm one that
|
||||
# does not exist, and 184 of the 261 escape vias are power nets exported this
|
||||
# way, so the doubled figure sealed the array shut.
|
||||
OBSTACLE_CLEARANCE_MM = 0.0
|
||||
|
||||
|
||||
def restore_fixed_net_declarations(dsn: Path) -> list[str]:
|
||||
"""Declare fixed-copper-only nets without adding their pads to routing."""
|
||||
source = dsn.read_text()
|
||||
network_start = source.index(" (network\n")
|
||||
wiring_start = source.index(" (wiring\n")
|
||||
network = source[network_start:wiring_start]
|
||||
wiring = source[wiring_start:]
|
||||
declared = set(NET_TOKEN.findall(network))
|
||||
used_by_copper = set(NET_TOKEN.findall(wiring))
|
||||
missing = sorted(used_by_copper - declared)
|
||||
if not missing:
|
||||
return []
|
||||
declarations = "".join(f" (net {name})\n" for name in missing)
|
||||
insertion = network_start + len(" (network\n")
|
||||
dsn.write_text(source[:insertion] + declarations + source[insertion:])
|
||||
return missing
|
||||
|
||||
|
||||
def inject_obstacles(dsn: Path, obstacles: list[str]) -> None:
|
||||
"""Insert physical keepouts for copper omitted from the routing nets."""
|
||||
if not obstacles:
|
||||
return
|
||||
source = dsn.read_text()
|
||||
structure = source.index(" (structure\n")
|
||||
insertion = source.index(" (via ", structure)
|
||||
rendered = "".join(f" {item}\n" for item in obstacles)
|
||||
dsn.write_text(source[:insertion] + rendered + source[insertion:])
|
||||
|
||||
|
||||
def scale_route_rule_widths(dsn: Path, factor: float = 1.0) -> int:
|
||||
"""Optionally rescale the routed widths the DSN asks Freerouting for.
|
||||
|
||||
The default is 1.0 because Freerouting 2.3.0 honours the DSN rule width
|
||||
exactly: a `(width 120)` rule produces 0.12-mm copper. An earlier 4/3
|
||||
factor here, meant to compensate a supposed 0.75x output, instead made
|
||||
every autorouted trace a third too wide, and that is fatal in the Ti375
|
||||
escape field. Its via grid is on a 0.8-mm pitch, so a trace threading
|
||||
between two 0.45-mm vias has 0.400 mm to work with: a 0.12-mm trace at
|
||||
0.10-mm clearance needs 0.385 mm and fits, a 0.16-mm one needs 0.405 mm
|
||||
and cannot be placed legally at all. Verify with report_routing.py after
|
||||
every import: the width per net class must equal the net class setting.
|
||||
"""
|
||||
source = dsn.read_text()
|
||||
|
||||
def scale(match: re.Match[str]) -> str:
|
||||
value = float(match.group(2)) * factor
|
||||
rendered = str(int(round(value)))
|
||||
return match.group(1) + rendered
|
||||
|
||||
converted, count = RULE_WIDTH.subn(scale, source)
|
||||
dsn.write_text(converted)
|
||||
return count
|
||||
|
||||
|
||||
def enforce_router_clearance_floor(dsn: Path, minimum_um: float = 100) -> int:
|
||||
"""Hold every routed class to at least the board's minimum copper clearance.
|
||||
|
||||
100 um is that minimum, and it is also what the Default and BGA_ESCAPE
|
||||
classes already ask for, so this normally changes nothing. It must not be
|
||||
raised to the 0.14 mm of the U1 dogbone rule: that rule governs copper
|
||||
against Ti375 *lands*, and applying it to via-to-trace spacing as well
|
||||
closes every channel through the escape field.
|
||||
"""
|
||||
source = dsn.read_text()
|
||||
pattern = re.compile(r'(\(clearance\s+)(\d+(?:\.\d+)?)(\))')
|
||||
changed = 0
|
||||
|
||||
def raise_value(match: re.Match[str]) -> str:
|
||||
nonlocal changed
|
||||
value = float(match.group(2))
|
||||
if value >= minimum_um:
|
||||
return match.group(0)
|
||||
changed += 1
|
||||
return f"{match.group(1)}{minimum_um:g}{match.group(3)}"
|
||||
|
||||
dsn.write_text(pattern.sub(raise_value, source))
|
||||
return changed
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("board", type=Path)
|
||||
@@ -17,6 +109,20 @@ def main() -> None:
|
||||
help="Exact KiCad net-class names separated by ';' (or ',' for legacy single names)")
|
||||
parser.add_argument("--preview", type=Path,
|
||||
help="Optional filtered KiCad board for inspection")
|
||||
parser.add_argument("--fixed-copper", choices=("wire", "keepout"),
|
||||
default="wire",
|
||||
help="How copper on non-routed nets is presented to the "
|
||||
"router (see comment in the source)")
|
||||
parser.add_argument("--only-nets", default="",
|
||||
help="Route just these nets, separated by ';'. Every "
|
||||
"other net becomes fixed copper. Used for a "
|
||||
"focused second round over the connections the "
|
||||
"first one left open.")
|
||||
parser.add_argument("--extra-nets", default="",
|
||||
help="Additional net names to route, separated by ';'. "
|
||||
"Used for the few POWER-class rails that have no "
|
||||
"plane and that route_local_power.py could not "
|
||||
"finish with a short local path.")
|
||||
args = parser.parse_args()
|
||||
|
||||
# KiCad can report a compound effective class such as "CLOCK,POWER" when
|
||||
@@ -27,24 +133,91 @@ def main() -> None:
|
||||
board = pcbnew.LoadBoard(str(args.board))
|
||||
kept_pads = 0
|
||||
omitted_pads = 0
|
||||
kept_nets: set[str] = set()
|
||||
extra = {name.strip() for name in args.extra_nets.split(";") if name.strip()}
|
||||
only = {name.strip() for name in args.only_nets.split(";") if name.strip()}
|
||||
kept_nets = {
|
||||
pad.GetNetname()
|
||||
for fp in board.GetFootprints()
|
||||
for pad in fp.Pads()
|
||||
if pad.GetNetname() and (pad.GetNetClassName() in keep
|
||||
or pad.GetNetname() in extra)
|
||||
}
|
||||
if only:
|
||||
kept_nets &= only
|
||||
kept_nets |= only
|
||||
for fp in board.GetFootprints():
|
||||
for pad in fp.Pads():
|
||||
if not pad.GetNetname():
|
||||
continue
|
||||
if pad.GetNetClassName() in keep:
|
||||
if pad.GetNetname() in kept_nets:
|
||||
kept_pads += 1
|
||||
kept_nets.add(pad.GetNetname())
|
||||
else:
|
||||
pad.SetNetCode(0)
|
||||
omitted_pads += 1
|
||||
|
||||
# Copper on omitted nets must stay a physical obstacle without becoming
|
||||
# something the router tries to connect. Two encodings are available.
|
||||
#
|
||||
# "wire" is the default and much the faster of the two: the copper keeps
|
||||
# its net, so KiCad exports it as ordinary `(type fix)` wiring, and
|
||||
# restore_fixed_net_declarations() then declares those nets in the network
|
||||
# section with no pins. Freerouting therefore has no connection to route
|
||||
# on them and simply routes around the fixed copper.
|
||||
#
|
||||
# "keepout" strips the net and emits an expanded envelope per item instead.
|
||||
# It is unambiguous but produces thousands of small rule areas, which slows
|
||||
# every routing pass down considerably. Kept for comparison runs.
|
||||
obstacles: list[str] = []
|
||||
# A through via blocks every routing layer, so the layer list has to come
|
||||
# from the board rather than be written out here: hard-coding it meant that
|
||||
# promoting L5 from a power plane to a signal layer left every existing via
|
||||
# invisible on L5, and the router happily drove traces straight through
|
||||
# them. Freerouting only routes layers it was told are signal layers, so
|
||||
# that set is exactly the one that needs a keepout.
|
||||
routing_layers = [layer for layer in board.GetEnabledLayers().CuStack()
|
||||
if board.GetLayerType(layer) == pcbnew.LT_SIGNAL]
|
||||
for item in board.GetTracks():
|
||||
if not item.GetNetname() or item.GetNetname() in kept_nets:
|
||||
continue
|
||||
if args.fixed_copper == "wire":
|
||||
continue
|
||||
if isinstance(item, pcbnew.PCB_VIA):
|
||||
diameter = pcbnew.ToMM(item.GetWidth(pcbnew.F_Cu)) + 2 * OBSTACLE_CLEARANCE_MM
|
||||
position = item.GetPosition()
|
||||
x, y = pcbnew.ToMM(position.x), -pcbnew.ToMM(position.y)
|
||||
for layer in routing_layers:
|
||||
obstacles.append(
|
||||
f'(keepout "" (circle {board.GetLayerName(layer)} '
|
||||
f'{diameter * 1000:g} {x * 1000:g} {y * 1000:g}))'
|
||||
)
|
||||
else:
|
||||
start, end = item.GetStart(), item.GetEnd()
|
||||
width = pcbnew.ToMM(item.GetWidth()) + 2 * OBSTACLE_CLEARANCE_MM
|
||||
obstacles.append(
|
||||
f'(keepout "" (path {board.GetLayerName(item.GetLayer())} '
|
||||
f'{width * 1000:g} {pcbnew.ToMM(start.x) * 1000:g} '
|
||||
f'{-pcbnew.ToMM(start.y) * 1000:g} {pcbnew.ToMM(end.x) * 1000:g} '
|
||||
f'{-pcbnew.ToMM(end.y) * 1000:g}))'
|
||||
)
|
||||
item.SetNetCode(0)
|
||||
for zone in list(board.Zones()):
|
||||
if not zone.GetIsRuleArea():
|
||||
board.Remove(zone)
|
||||
board.BuildListOfNets()
|
||||
if args.preview:
|
||||
pcbnew.SaveBoard(str(args.preview), board)
|
||||
if not pcbnew.ExportSpecctraDSN(board, str(args.dsn)):
|
||||
raise RuntimeError(f"Failed to export {args.dsn}")
|
||||
inject_obstacles(args.dsn, obstacles)
|
||||
scaled_widths = scale_route_rule_widths(args.dsn)
|
||||
raised_clearances = enforce_router_clearance_floor(args.dsn)
|
||||
restored = restore_fixed_net_declarations(args.dsn)
|
||||
print(f"Classes: {', '.join(sorted(keep))}")
|
||||
print(f"Kept {kept_pads} pads on {len(kept_nets)} nets; omitted {omitted_pads} pads")
|
||||
print(f"Fixed copper as {args.fixed_copper}: {len(obstacles)} keepouts inserted")
|
||||
print(f"Rewrote {scaled_widths} route-rule widths")
|
||||
print(f"Raised {raised_clearances} route clearances to the board minimum")
|
||||
print(f"Declared {len(restored)} fixed-copper-only nets")
|
||||
print(f"Saved {args.dsn}")
|
||||
|
||||
|
||||
|
||||
+241
-22
@@ -15,8 +15,11 @@ from pathlib import Path
|
||||
|
||||
import pcbnew
|
||||
|
||||
import route_local_power
|
||||
import stitch_plane_pads
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PLACED = ROOT / "hardware" / "board.kicad_pcb"
|
||||
POWER_LOCAL = ROOT / "hardware" / "board_power_local.kicad_pcb"
|
||||
ROUTED = ROOT / "hardware" / "board_routed.kicad_pcb"
|
||||
|
||||
RF_NETS = {"RF_SMA", "RF_PRE_SAW", "RF_POST_SAW", "MAX_MIXIN_HI"}
|
||||
@@ -119,11 +122,12 @@ def route_rf(board: pcbnew.BOARD) -> None:
|
||||
|
||||
w = 0.164
|
||||
j2 = pad_xy(board, "J2", "1")
|
||||
j2_inner = (j2[0] - 0.50, j2[1])
|
||||
dc_block_1 = pad_xy(board, "C83", "1")
|
||||
l4_2 = pad_xy(board, "L4", "2")
|
||||
add_polyline(board, "RF_SMA", [j2, l4_2, dc_block_1], w)
|
||||
add_polyline(board, "RF_SMA", [j2_inner, (170.10, 42.09), l4_2, dc_block_1], w)
|
||||
d1 = pad_xy(board, "D1", "1")
|
||||
add_polyline(board, "RF_SMA", [d1, (d1[0], 45.07)], w)
|
||||
add_polyline(board, "RF_SMA", [d1, (d1[0], 42.09)], w)
|
||||
|
||||
add_polyline(board, "RF_PRE_SAW",
|
||||
[pad_xy(board, "C83", "2"), pad_xy(board, "FL1", "1")], w)
|
||||
@@ -146,12 +150,16 @@ def route_usb(board: pcbnew.BOARD) -> None:
|
||||
# mirrored USB-C contacts join through a sub-1.5-mm L3 crossover so neither
|
||||
# polarity creates a same-layer crossing at the receptacle.
|
||||
w = 0.135
|
||||
dm_main = pad_xy(board, "J1", "B7")
|
||||
dp_main = pad_xy(board, "J1", "A6")
|
||||
dm_mirror = pad_xy(board, "J1", "A7")
|
||||
dp_mirror = pad_xy(board, "J1", "B6")
|
||||
dm_join, dp_join = (28.30, 47.25), (28.80, 47.75)
|
||||
dm_aux, dp_aux = (27.30, 48.25), (27.80, 48.75)
|
||||
for ref, expected in {
|
||||
"R62": {"USB_D_P", "FTDI_D_P"},
|
||||
"R63": {"USB_D_N", "FTDI_D_N"},
|
||||
}.items():
|
||||
assert_ref_nets(board, ref, expected)
|
||||
|
||||
dm_main, dp_main = pad_xy(board, "J1", "B7"), pad_xy(board, "J1", "A6")
|
||||
dm_mirror, dp_mirror = pad_xy(board, "J1", "A7"), pad_xy(board, "J1", "B6")
|
||||
dm_join, dp_join = (28.60, 47.25), (29.20, 47.75)
|
||||
dm_aux, dp_aux = (27.60, 48.25), (28.20, 48.75)
|
||||
|
||||
add_polyline(board, "USB_D_N", [dm_main, dm_join], w)
|
||||
add_polyline(board, "USB_D_P", [dp_main, dp_join], w)
|
||||
@@ -163,21 +171,33 @@ def route_usb(board: pcbnew.BOARD) -> None:
|
||||
add_polyline(board, "USB_D_N", [dm_aux, dm_join], w, pcbnew.In2_Cu)
|
||||
add_polyline(board, "USB_D_P", [dp_aux, dp_join], w, pcbnew.In2_Cu)
|
||||
|
||||
# Down the free channel east of J1 into the TPD2EUSB30A clamp pads. D_P
|
||||
# drops at x=30.00 rather than on its own pad column so it keeps 0.2325 mm
|
||||
# to the D_N clamp pad, then jogs 45 degrees onto U6.1.
|
||||
dm_esd = pad_xy(board, "U6", "3")
|
||||
dp_esd = pad_xy(board, "U6", "1")
|
||||
add_polyline(board, "USB_D_N", [dm_join, (30.00, 49.00), (30.00, 50.70), dm_esd], w)
|
||||
add_polyline(board, "USB_D_P", [dp_join, (30.285, 49.285), (30.285, 51.64), dp_esd], w)
|
||||
|
||||
dm_r = pad_xy(board, "R64", "1")
|
||||
dp_r = pad_xy(board, "R63", "1")
|
||||
add_polyline(board, "USB_D_N",
|
||||
[dm_esd, (31.00, 51.40), (34.00, 54.40), (35.80, 54.40), dm_r], w)
|
||||
[dm_join, (29.60, 47.25), (30.50, 48.15), dm_esd], w)
|
||||
add_polyline(board, "USB_D_P",
|
||||
[dp_esd, (30.715, 51.685), (33.715, 54.685), (35.80, 54.685), dp_r], w)
|
||||
[dp_join, (29.45, 47.75), (30.00, 48.30),
|
||||
(30.00, 51.775), dp_esd], w)
|
||||
|
||||
# Equal 1.511-mm post-termination legs into the FT4232H USB PHY.
|
||||
add_polyline(board, "FTDI_D_N", [pad_xy(board, "R64", "2"), pad_xy(board, "U2", "7")], w)
|
||||
add_polyline(board, "FTDI_D_P", [pad_xy(board, "R63", "2"), pad_xy(board, "U2", "8")], w)
|
||||
# Both clamp taps run as a 0.5-mm-pitch 45-degree pair to the series
|
||||
# terminators. D_N leaves U6.3 straight east; D_P has to step south first,
|
||||
# because U6.2 (ground) sits directly east of U6.1 on the same pad row.
|
||||
dm_r = pad_xy(board, "R63", "1")
|
||||
dp_r = pad_xy(board, "R62", "1")
|
||||
add_polyline(board, "USB_D_N",
|
||||
[dm_esd, (31.000, 51.075), (34.425, 54.50), dm_r], w)
|
||||
add_polyline(board, "USB_D_P",
|
||||
[dp_esd, (30.150, 52.500), (31.718, 52.500),
|
||||
(34.718, 55.50), dp_r], w)
|
||||
|
||||
# Equal short post-termination legs into the FT4232H USB PHY.
|
||||
add_polyline(board, "FTDI_D_N",
|
||||
[pad_xy(board, "R63", "2"), (37.76, 54.75), pad_xy(board, "U2", "7")], w)
|
||||
add_polyline(board, "FTDI_D_P",
|
||||
[pad_xy(board, "R62", "2"), (37.76, 55.25), pad_xy(board, "U2", "8")], w)
|
||||
|
||||
|
||||
def add_rf_ground(board: pcbnew.BOARD) -> None:
|
||||
@@ -199,8 +219,183 @@ def add_rf_ground(board: pcbnew.BOARD) -> None:
|
||||
# tighter and also gives every local shunt/EP a short plane connection.
|
||||
fence = [(x, 38.7) for x in range(153, 174, 2)]
|
||||
fence += [(x, 45.0) for x in range(161, 174, 2)]
|
||||
existing_vias = [item for item in board.GetTracks()
|
||||
if isinstance(item, pcbnew.PCB_VIA)]
|
||||
# The fence goes in after the session has been imported, so by now there is
|
||||
# routed copper on F.Cu and B.Cu inside this region as well. Checking only
|
||||
# vias and pads let a fence via land straight on top of a signal trace.
|
||||
other_tracks = [item for item in board.GetTracks()
|
||||
if not isinstance(item, pcbnew.PCB_VIA)
|
||||
and item.GetNetname() != "GND"]
|
||||
all_pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()]
|
||||
via_radius = 0.225 + 0.10
|
||||
for location in fence:
|
||||
add_via(board, "GND", location, 0.45, 0.225)
|
||||
candidate = point(*location)
|
||||
blocked = any(
|
||||
item.GetNetname() != "GND" and
|
||||
math.hypot(pcbnew.ToMM(item.GetPosition().x - candidate.x),
|
||||
pcbnew.ToMM(item.GetPosition().y - candidate.y)) < 0.75
|
||||
for item in existing_vias
|
||||
)
|
||||
if not blocked:
|
||||
x, y = location
|
||||
for pad in all_pads:
|
||||
if pad.GetNetname() == "GND":
|
||||
continue
|
||||
box = pad.GetBoundingBox()
|
||||
if (pcbnew.ToMM(box.GetX()) - 0.35 <= x <= pcbnew.ToMM(box.GetRight()) + 0.35 and
|
||||
pcbnew.ToMM(box.GetY()) - 0.35 <= y <= pcbnew.ToMM(box.GetBottom()) + 0.35):
|
||||
blocked = True
|
||||
break
|
||||
if not blocked:
|
||||
for item in other_tracks:
|
||||
start, end = item.GetStart(), item.GetEnd()
|
||||
reach = via_radius + pcbnew.ToMM(item.GetWidth()) / 2
|
||||
if route_local_power.segment_distance(location,
|
||||
(pcbnew.ToMM(start.x), pcbnew.ToMM(start.y)),
|
||||
(pcbnew.ToMM(end.x), pcbnew.ToMM(end.y))) < reach:
|
||||
blocked = True
|
||||
break
|
||||
if not blocked:
|
||||
add_via(board, "GND", location, 0.45, 0.225)
|
||||
|
||||
|
||||
NETCLASS_WIDTH = {"Default": 0.12, "BGA_ESCAPE": 0.10, "CLOCK": 0.15,
|
||||
"POWER": 0.40}
|
||||
|
||||
|
||||
MIN_TRACK_WIDTH = 0.10
|
||||
MIN_CLEARANCE = 0.10
|
||||
|
||||
|
||||
def normalize_imported_widths(board: pcbnew.BOARD) -> tuple[int, int]:
|
||||
"""Restore the net class width on copper the session came back too thin.
|
||||
|
||||
Freerouting's auto-routing stage honours the DSN rule width exactly, but
|
||||
its fanout stage emits three quarters of it, which lands below this board's
|
||||
0.10-mm minimum track width. The affected pieces are the short stubs at
|
||||
the pads, and widening one can walk it into a neighbouring pad, so each is
|
||||
grown only as far as it still clears everything else - the class width
|
||||
where there is room, the board minimum where there is not.
|
||||
"""
|
||||
obstacles = route_local_power.Obstacles(board)
|
||||
doomed = []
|
||||
widened = 0
|
||||
for item in board.GetTracks():
|
||||
if isinstance(item, pcbnew.PCB_VIA) or item.IsLocked():
|
||||
continue
|
||||
target = NETCLASS_WIDTH.get(item.GetNetClassName())
|
||||
if target is None or pcbnew.ToMM(item.GetWidth()) >= target - 1e-6:
|
||||
continue
|
||||
start = (pcbnew.ToMM(item.GetStart().x), pcbnew.ToMM(item.GetStart().y))
|
||||
end = (pcbnew.ToMM(item.GetEnd().x), pcbnew.ToMM(item.GetEnd().y))
|
||||
for width in (target, MIN_TRACK_WIDTH):
|
||||
# Judge existing copper against the rule itself, not the extra
|
||||
# margin the local router leaves itself when placing new copper.
|
||||
if obstacles.track_clear(item.GetNetname(), start, end,
|
||||
width, item.GetLayer(),
|
||||
clearance_floor=MIN_CLEARANCE):
|
||||
item.SetWidth(mm(width))
|
||||
widened += 1
|
||||
break
|
||||
else:
|
||||
# Nothing legal fits here, so this piece cannot ship. Dropping it
|
||||
# unravels its net back to the pads, and the completion pass
|
||||
# further down rebuilds the connection at the proper width.
|
||||
doomed.append(item)
|
||||
for item in doomed:
|
||||
board.Remove(item)
|
||||
return widened, len(doomed)
|
||||
|
||||
|
||||
def remove_dangling_copper(board: pcbnew.BOARD) -> tuple[int, int]:
|
||||
"""Delete copper the finished routing left hanging.
|
||||
|
||||
A ball whose net ended up routed entirely on F.Cu leaves its pre-routed
|
||||
dogbone via connected on one layer only, and removing that via in turn
|
||||
strands the stub that fed it. Both are drilled holes and copper with no
|
||||
purpose, so this repeats until nothing new comes loose.
|
||||
"""
|
||||
vias = tracks = 0
|
||||
for _round in range(8):
|
||||
board.BuildConnectivity()
|
||||
connectivity = board.GetConnectivity()
|
||||
doomed = [item for item in board.GetTracks()
|
||||
if item.GetNetname() not in CRITICAL_NETS
|
||||
and connectivity.TestTrackEndpointDangling(item, False)]
|
||||
if not doomed:
|
||||
break
|
||||
for item in doomed:
|
||||
if isinstance(item, pcbnew.PCB_VIA):
|
||||
vias += 1
|
||||
else:
|
||||
tracks += 1
|
||||
board.Remove(item)
|
||||
board.BuildConnectivity()
|
||||
return vias, tracks
|
||||
|
||||
|
||||
def drop_plane_islands(board: pcbnew.BOARD) -> int:
|
||||
"""Discard unconnected plane fragments left by the escape via anti-pads.
|
||||
|
||||
The generated planes keep every fragment while the board is unrouted so the
|
||||
Specctra export still shows the router a plane target under each via field.
|
||||
A finished board must not ship floating copper, so the final fill removes
|
||||
any island the completed routing did not connect.
|
||||
"""
|
||||
changed = 0
|
||||
for zone in board.Zones():
|
||||
if zone.GetIsRuleArea() or zone.GetNetname() == "":
|
||||
continue
|
||||
zone.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_ALWAYS)
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
def add_named_rule_areas(board: pcbnew.BOARD) -> None:
|
||||
"""Permanent named areas that only exist so custom DRC rules can name them.
|
||||
|
||||
The TPD2EUSB30A is a SOT-23 clamp whose ground pad sits between its two
|
||||
channel pads, so USB_D_P and USB_D_N must separate for about 1.5 mm to pass
|
||||
it. That is normal for this package, but it does break the 0.90-mm coupled
|
||||
gap the pair holds everywhere else, so the rule is scoped to skip this one
|
||||
breakout instead of being relaxed board-wide.
|
||||
|
||||
RF_NO_DIGITAL names the region under the GPS front end. The TEMP_ version
|
||||
of it exists only while the autorouter runs, so once that is deleted
|
||||
nothing would stop a later hand edit from dropping a digital trace onto L3,
|
||||
L5, L6 or B.Cu directly under the RF line. A plain keepout cannot be used
|
||||
here because it would also reject the ground stitching vias and the
|
||||
MAX2771's own supply drops, so the area carries no constraint itself and
|
||||
board.kicad_dru scopes the disallow to non-power net classes. It
|
||||
deliberately stops short of L2 and L7: those must stay solid and unbroken
|
||||
under the RF path, which is exactly what an unrouted plane layer gives.
|
||||
"""
|
||||
below = pcbnew.LSET()
|
||||
for layer in (pcbnew.In2_Cu, pcbnew.In4_Cu, pcbnew.In5_Cu, pcbnew.B_Cu):
|
||||
below.AddLayer(layer)
|
||||
top = pcbnew.LSET()
|
||||
top.AddLayer(pcbnew.F_Cu)
|
||||
areas = (
|
||||
("USB_ESD_BREAKOUT", (29.90, 50.70, 32.10, 53.10), top),
|
||||
("RF_NO_DIGITAL", (152.00, 36.00, 174.50, 49.50), below),
|
||||
)
|
||||
for name, bounds, layers in areas:
|
||||
x0, y0, x1, y1 = bounds
|
||||
area = pcbnew.ZONE(board)
|
||||
area.SetZoneName(name)
|
||||
area.SetIsRuleArea(True)
|
||||
area.SetLayerSet(layers)
|
||||
area.SetDoNotAllowTracks(False)
|
||||
area.SetDoNotAllowVias(False)
|
||||
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 routed_lengths(board: pcbnew.BOARD) -> dict[str, float]:
|
||||
@@ -217,26 +412,50 @@ def routed_lengths(board: pcbnew.BOARD) -> dict[str, float]:
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("session", type=Path, help="Freerouting .ses file")
|
||||
parser.add_argument("--base", type=Path, default=POWER_LOCAL,
|
||||
help="Board used to create the routed DSN")
|
||||
parser.add_argument("--output", type=Path, default=ROUTED)
|
||||
args = parser.parse_args()
|
||||
|
||||
board = pcbnew.LoadBoard(str(PLACED))
|
||||
board = pcbnew.LoadBoard(str(args.base))
|
||||
if not pcbnew.ImportSpecctraSES(board, str(args.session)):
|
||||
raise RuntimeError(f"Could not import {args.session}")
|
||||
removed = remove_critical_routes(board)
|
||||
widened, dropped = normalize_imported_widths(board)
|
||||
stranded_vias, stranded_tracks = remove_dangling_copper(board)
|
||||
removed_keepouts = remove_temporary_keepouts(board)
|
||||
route_rf(board)
|
||||
route_usb(board)
|
||||
add_rf_ground(board)
|
||||
add_named_rule_areas(board)
|
||||
|
||||
# The reserved RF and USB corridors are gone now, so the power pads that
|
||||
# sit inside them can finally reach their planes. Both passes are the same
|
||||
# deterministic code used earlier in the pipeline.
|
||||
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
|
||||
raise RuntimeError("Zone fill before final power completion failed")
|
||||
stitched, _unreachable = stitch_plane_pads.stitch(board)
|
||||
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
|
||||
raise RuntimeError("Zone fill after final plane stitching failed")
|
||||
route_local_power.complete(board, signals=True)
|
||||
|
||||
late_vias, late_tracks = remove_dangling_copper(board)
|
||||
cleaned = drop_plane_islands(board)
|
||||
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
|
||||
raise RuntimeError("Final zone fill failed")
|
||||
pcbnew.SaveBoard(str(args.output), board)
|
||||
for suffix in (".kicad_pro", ".kicad_dru"):
|
||||
source = PLACED.with_suffix(suffix)
|
||||
source = args.base.with_suffix(suffix)
|
||||
if source.exists():
|
||||
shutil.copyfile(source, args.output.with_suffix(suffix))
|
||||
print(f"Removed {removed} autorouted critical-net items")
|
||||
print(f"Widened {widened} imported tracks; dropped {dropped} that had no room")
|
||||
print(f"Removed {stranded_vias + late_vias} unused vias and "
|
||||
f"{stranded_tracks + late_tracks} stranded stubs")
|
||||
print(f"Removed {removed_keepouts} temporary critical-route keepouts")
|
||||
print("Late plane stitching:",
|
||||
", ".join(f"{n}={k}" for n, k in sorted(stitched.items())) or "none")
|
||||
print(f"Enabled island removal on {cleaned} plane zones")
|
||||
print(f"Saved {args.output}")
|
||||
for net, length in sorted(routed_lengths(board).items()):
|
||||
print(f"{net:16s} {length:8.3f} mm")
|
||||
|
||||
+183
-60
@@ -29,6 +29,13 @@ STOCK = Path("/usr/share/kicad/footprints")
|
||||
# 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)
|
||||
@@ -91,9 +98,40 @@ SPECIAL = {
|
||||
# likewise land on the two controlled-impedance sections.
|
||||
"C83": (167.5, 42.1, 180), "C84": (162.15, 41.45, 90), "R39": (160.5, 42.1, 180),
|
||||
"C85": (159.0, 40.70, 90), "L4": (169.5, 42.585, 90),
|
||||
"L1": (105.0, 91.0, 0), "L2": (115.0, 91.0, 0), "L3": (130.0, 91.0, 0),
|
||||
"FB1": (60.0, 88.5, 0), "FB2": (36.0, 49.0, 0), "FB3": (39.0, 46.5, 0),
|
||||
"R63": (37.0, 55.50, 0), "R64": (37.0, 54.50, 0),
|
||||
# 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),
|
||||
@@ -102,7 +140,16 @@ SPECIAL = {
|
||||
"R50": (160.5, 47.5, 90),
|
||||
"R51": (121.5, 65.5, 0), "R52": (121.5, 66.7, 0),
|
||||
"R53": (121.5, 67.9, 0), "R54": (121.5, 69.1, 0),
|
||||
"R55": (161.7, 44.5, 90), "C89": (146.5, 53.0, 0),
|
||||
"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),
|
||||
@@ -283,11 +330,17 @@ def add_stackup(board: pcbnew.BOARD) -> None:
|
||||
board.SetLayerName(pcbnew.In1_Cu, "L2_GND")
|
||||
board.SetLayerName(pcbnew.In2_Cu, "L3_SIG")
|
||||
board.SetLayerName(pcbnew.In3_Cu, "L4_PWR")
|
||||
# L5 is a routing layer, not a rail. Declaring it signal is what puts
|
||||
# it into the Specctra export as routable: a layer left as "power" is
|
||||
# offered to the router as "not used for routing", which is exactly the
|
||||
# congestion this stackup exists to avoid.
|
||||
board.SetLayerName(pcbnew.In4_Cu, "L5_SIG")
|
||||
board.SetLayerName(pcbnew.In5_Cu, "L6_SIG")
|
||||
board.SetLayerName(pcbnew.In5_Cu, "L6_PWR")
|
||||
board.SetLayerName(pcbnew.In6_Cu, "L7_GND")
|
||||
board.SetLayerType(pcbnew.In1_Cu, pcbnew.LT_POWER)
|
||||
board.SetLayerType(pcbnew.In3_Cu, pcbnew.LT_POWER)
|
||||
board.SetLayerType(pcbnew.In4_Cu, pcbnew.LT_SIGNAL)
|
||||
board.SetLayerType(pcbnew.In5_Cu, pcbnew.LT_POWER)
|
||||
board.SetLayerType(pcbnew.In6_Cu, pcbnew.LT_POWER)
|
||||
|
||||
|
||||
@@ -420,12 +473,20 @@ def add_mounting_holes(board: pcbnew.BOARD) -> None:
|
||||
|
||||
|
||||
def add_zone(board: pcbnew.BOARD, net_name: str, layer: int,
|
||||
bounds: tuple[float, float, float, float], priority: int = 0) -> None:
|
||||
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:
|
||||
@@ -434,7 +495,7 @@ def add_zone(board: pcbnew.BOARD, net_name: str, layer: int,
|
||||
zone = pcbnew.ZONE(board)
|
||||
zone.SetLayer(layer)
|
||||
zone.SetNet(net)
|
||||
zone.SetLocalClearance(mm(0.20))
|
||||
zone.SetLocalClearance(mm(clearance))
|
||||
zone.SetMinThickness(mm(0.10))
|
||||
zone.SetThermalReliefGap(mm(0.20))
|
||||
zone.SetThermalReliefSpokeWidth(mm(0.25))
|
||||
@@ -449,69 +510,133 @@ def add_zone(board: pcbnew.BOARD, net_name: str, layer: int,
|
||||
|
||||
|
||||
def add_power_planes(board: pcbnew.BOARD) -> None:
|
||||
"""Ground on L2/L7, and the supply rails split across L4 and L6.
|
||||
|
||||
No rail owns a whole layer. L4 carries the core, the 5-V input rail, the
|
||||
always-on auxiliary rail and the four 3.0-V front-end rails; L6 carries the
|
||||
two large FPGA I/O rails. That leaves L3, L5 and B.Cu free for routing,
|
||||
which is where the Ti375's fanout has to go.
|
||||
"""
|
||||
# Continuous reference planes immediately below the two outer signal
|
||||
# layers. A 0.50-mm inset exceeds JLCPCB's copper-to-edge requirement.
|
||||
# These are the return path for every routed layer, so they use the tight
|
||||
# anti-pad clearance and stay continuous through the Ti375 via field. L2
|
||||
# in particular is the reference under the whole GPS front end and is never
|
||||
# split between "RF ground" and "digital ground".
|
||||
for layer in (pcbnew.In1_Cu, pcbnew.In6_Cu):
|
||||
add_zone(board, "GND", layer, (X0 + 0.50, Y0 + 0.50, X1 - 0.50, Y1 - 0.50))
|
||||
add_zone(board, "GND", layer, (X0 + 0.50, Y0 + 0.50, X1 - 0.50, Y1 - 0.50),
|
||||
clearance=PLANE_CLEARANCE)
|
||||
|
||||
# L4 is a deliberately split DC plane. Islands follow the functional
|
||||
# floorplan; no high-speed route uses L4 and no signal crosses a split on
|
||||
# either adjacent routing layer without a nearby return transition.
|
||||
islands = (
|
||||
("VCC3V3_AUX", (20.50, 38.00, 67.00, 72.00)),
|
||||
("VCC3V3", (39.00, 73.00, 68.00, 85.10)),
|
||||
("SYS5", (20.50, 85.50, 90.00, 109.50)),
|
||||
("SYS5", (90.40, 106.40, 140.00, 109.50)),
|
||||
# L4: everything except the two big I/O rails. Islands follow the
|
||||
# functional floorplan; no high-speed route uses L4 and no signal crosses a
|
||||
# split on either adjacent routing layer without a nearby return via.
|
||||
l4 = (
|
||||
("VCC3V3_AUX", (20.50, 36.00, 74.00, 72.00)),
|
||||
("VCC3V3_AUX", (53.00, 88.00, 70.00, 105.00)),
|
||||
("VCC3V3_AUX", (77.50, 84.00, 94.20, 100.50)),
|
||||
("VCC3V3_AUX", (133.00, 74.50, 152.00, 82.00)),
|
||||
("SYS5", (20.50, 85.50, 52.50, 109.50)),
|
||||
("SYS5", (89.50, 103.60, 145.00, 109.50)),
|
||||
# A corridor up the east edge carries 5 V to the antenna-bias block
|
||||
# instead of leaving those three pads stranded 55 mm from the rail.
|
||||
("SYS5", (140.00, 103.00, 174.50, 109.50)),
|
||||
("SYS5", (144.00, 86.50, 174.50, 104.00)),
|
||||
("SYS5", (157.00, 72.00, 174.50, 87.00)),
|
||||
("SYS5", (157.00, 63.50, 174.50, 72.50)),
|
||||
("VCC3V0_CLK", (126.00, 48.50, 156.00, 67.00)),
|
||||
("VCC3V0_RF", (140.00, 58.50, 150.50, 65.50)),
|
||||
("VCC3V0_DIG", (134.00, 62.50, 147.00, 74.00)),
|
||||
# Core balls are interspersed with GND/auxiliary balls, so a complete
|
||||
# local patch (with automatic anti-pads) replaces the invalid left/right
|
||||
# voltage split that previously crossed the BGA field.
|
||||
("VCC0V95", (95.00, 60.50, 119.00, 94.00)),
|
||||
("VCC3V3", (119.50, 87.00, 140.00, 106.00)),
|
||||
("VCC3V0_CLK", (126.00, 50.00, 148.50, 72.00)),
|
||||
("VCC3V0_DIG", (119.50, 60.50, 125.50, 72.00)),
|
||||
("VCC3V0_DIG", (148.90, 50.40, 164.00, 72.00)),
|
||||
("VCC3V3_ANT", (164.40, 50.40, 174.50, 70.00)),
|
||||
("VCC3V0_RF", (145.00, 36.00, 174.50, 50.00)),
|
||||
# local patch (with automatic anti-pads) replaces any voltage split
|
||||
# crossing the BGA field.
|
||||
("VCC0V95", (94.50, 56.00, 121.00, 103.00)),
|
||||
("VCC3V0_RF", (146.00, 39.80, 174.50, 50.50)),
|
||||
("VCC3V0_DIG", (149.00, 35.20, 161.00, 39.60)),
|
||||
("VCC3V0_DIG", (151.50, 44.80, 159.00, 48.60)),
|
||||
("VCC3V3_ANT", (162.00, 53.50, 174.50, 63.20)),
|
||||
)
|
||||
for priority, (net_name, bounds) in enumerate(islands, 1):
|
||||
add_zone(board, net_name, pcbnew.In3_Cu, bounds, priority)
|
||||
for priority, (net_name, bounds) in enumerate(l4, 1):
|
||||
# Only the core island sits under the BGA and needs the tight anti-pad.
|
||||
add_zone(board, net_name, pcbnew.In3_Cu, bounds, priority,
|
||||
clearance=PLANE_CLEARANCE if net_name == "VCC0V95" else 0.20)
|
||||
|
||||
# Local BGA supply patches. L2/L7 remain uninterrupted ground planes;
|
||||
# only the area below and immediately south of U1 is reserved on these
|
||||
# otherwise routable signal layers.
|
||||
add_zone(board, "VCC1V8", pcbnew.In4_Cu, (97.50, 60.50, 122.00, 101.00), 50)
|
||||
add_zone(board, "VCC3V3", pcbnew.In5_Cu, (97.50, 60.50, 140.00, 101.00), 50)
|
||||
# L6: the two FPGA I/O rails. VCC3V3 is the background because its loads
|
||||
# are spread over the whole west and the RF corner; VCC1V8 is carved out
|
||||
# where its own loads sit - under the BGA, along the 1.8-V PMOD headers,
|
||||
# over the configuration flash and at its converter output. The carve-outs
|
||||
# overlap each other so the rail stays one connected pour.
|
||||
l6 = (
|
||||
# Overlapping zones need distinct priorities even when they carry the
|
||||
# same net, so the carve-outs are numbered rather than sharing one.
|
||||
("VCC3V3", (X0 + 0.50, Y0 + 0.50, X1 - 0.50, Y1 - 0.50), 1),
|
||||
("VCC1V8", (99.00, 39.50, 122.00, 92.00), 10),
|
||||
("VCC1V8", (115.00, 20.50, 174.50, 40.50), 11),
|
||||
("VCC1V8", (94.00, 45.00, 106.00, 56.00), 12),
|
||||
("VCC1V8", (108.50, 91.50, 127.00, 106.00), 13),
|
||||
("VCC3V3", (128.00, 91.50, 141.00, 106.00), 20),
|
||||
)
|
||||
for net_name, bounds, priority in l6:
|
||||
add_zone(board, net_name, pcbnew.In5_Cu, bounds, priority,
|
||||
clearance=PLANE_CLEARANCE)
|
||||
|
||||
|
||||
# Areas the autorouter and the power stages must leave alone until
|
||||
# finalize_routing.py installs the audited RF and USB copper. The "below"
|
||||
# regions keep unrelated copper off the reference and routing layers under the
|
||||
# GPS front end and the USB pair; the "corridor" regions additionally reserve
|
||||
# F.Cu along the exact paths that finalize_routing.py later builds, since the
|
||||
# critical nets themselves are not part of the Specctra export.
|
||||
CRITICAL_KEEPOUTS_BELOW = (
|
||||
("TEMP_RF_NO_DIGITAL_BELOW", (152.00, 36.00, 174.50, 49.50)),
|
||||
("TEMP_USB_NO_CROSSING_BELOW", (26.00, 46.00, 39.60, 56.60)),
|
||||
)
|
||||
CRITICAL_KEEPOUTS_CORRIDOR = (
|
||||
# GPS chain: SMA launch, bias tee, DC block, SAW, series R and MIXIN_HI.
|
||||
("TEMP_RF_LINE", (154.40, 40.00, 174.60, 43.70)),
|
||||
# USB pair: receptacle breakout and the mirrored-contact crossover.
|
||||
("TEMP_USB_BREAKOUT", (27.30, 46.90, 30.65, 49.60)),
|
||||
# USB pair: vertical channel into the TPD2EUSB30A clamp, and the short
|
||||
# southward step D_P needs to clear the clamp's ground pad.
|
||||
("TEMP_USB_CHANNEL", (29.60, 49.60, 30.68, 52.60)),
|
||||
# USB pair: 45-degree run from the clamp to the series terminators.
|
||||
("TEMP_USB_DIAGONAL", (30.68, 52.20, 35.30, 56.00)),
|
||||
# USB pair: terminators and the matched legs into the FT4232H PHY.
|
||||
("TEMP_USB_TERMINATORS", (34.80, 54.35, 39.45, 55.60)),
|
||||
)
|
||||
|
||||
|
||||
def add_temporary_critical_keepouts(board: pcbnew.BOARD) -> None:
|
||||
"""Reserve the layers below RF and USB while Freerouting is running.
|
||||
"""Reserve the RF and USB geometry while the rest of the board is routed.
|
||||
|
||||
F.Cu deliberately remains available so the autorouted critical nets reserve
|
||||
their own top-layer corridors. The rule areas prevent unrelated signal
|
||||
tracks and through vias from crossing underneath. finalize_routing.py
|
||||
removes these temporary areas before installing the audited copper.
|
||||
finalize_routing.py removes every TEMP_ area before installing the audited
|
||||
copper, so nothing here reaches the manufactured board.
|
||||
"""
|
||||
layers = pcbnew.LSET()
|
||||
below = pcbnew.LSET()
|
||||
for layer in (pcbnew.In2_Cu, pcbnew.In4_Cu, pcbnew.In5_Cu, pcbnew.B_Cu):
|
||||
layers.AddLayer(layer)
|
||||
for name, bounds in (
|
||||
("TEMP_RF_NO_DIGITAL_BELOW", (152.0, 36.0, 174.5, 49.5)),
|
||||
("TEMP_USB_NO_CROSSING_BELOW", (22.0, 41.5, 44.5, 57.5))):
|
||||
x0, y0, x1, y1 = bounds
|
||||
area = pcbnew.ZONE(board)
|
||||
area.SetZoneName(name)
|
||||
area.SetIsRuleArea(True)
|
||||
area.SetLayerSet(layers)
|
||||
area.SetDoNotAllowTracks(True)
|
||||
area.SetDoNotAllowVias(True)
|
||||
area.SetDoNotAllowPads(False)
|
||||
area.SetDoNotAllowFootprints(False)
|
||||
area.SetDoNotAllowZoneFills(False)
|
||||
outline = area.Outline()
|
||||
outline.NewOutline()
|
||||
for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1)):
|
||||
outline.Append(point(x, y))
|
||||
board.Add(area)
|
||||
below.AddLayer(layer)
|
||||
corridor = pcbnew.LSET()
|
||||
for layer in (pcbnew.F_Cu, pcbnew.In2_Cu, pcbnew.In4_Cu,
|
||||
pcbnew.In5_Cu, pcbnew.B_Cu):
|
||||
corridor.AddLayer(layer)
|
||||
|
||||
for layers, group in ((below, CRITICAL_KEEPOUTS_BELOW),
|
||||
(corridor, CRITICAL_KEEPOUTS_CORRIDOR)):
|
||||
for name, bounds in group:
|
||||
x0, y0, x1, y1 = bounds
|
||||
area = pcbnew.ZONE(board)
|
||||
area.SetZoneName(name)
|
||||
area.SetIsRuleArea(True)
|
||||
area.SetLayerSet(layers)
|
||||
area.SetDoNotAllowTracks(True)
|
||||
area.SetDoNotAllowVias(True)
|
||||
area.SetDoNotAllowPads(False)
|
||||
area.SetDoNotAllowFootprints(False)
|
||||
area.SetDoNotAllowZoneFills(False)
|
||||
outline = area.Outline()
|
||||
outline.NewOutline()
|
||||
for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1)):
|
||||
outline.Append(point(x, y))
|
||||
board.Add(area)
|
||||
|
||||
|
||||
def add_documentation_graphics(board: pcbnew.BOARD) -> None:
|
||||
@@ -547,7 +672,7 @@ STACKUP = '''\t\t(stackup
|
||||
\t\t\t(layer "dielectric 4" (type "core") (thickness 0.3) (material "JLC FR-4 core") (epsilon_r 4.41) (loss_tangent 0.02))
|
||||
\t\t\t(layer "L5_SIG" (type "copper") (thickness 0.0152))
|
||||
\t\t\t(layer "dielectric 5" (type "prepreg") (thickness 0.3568) (material "JLC 3x2116") (epsilon_r 4.16) (loss_tangent 0.02))
|
||||
\t\t\t(layer "L6_SIG" (type "copper") (thickness 0.0152))
|
||||
\t\t\t(layer "L6_PWR" (type "copper") (thickness 0.0152))
|
||||
\t\t\t(layer "dielectric 6" (type "core") (thickness 0.1) (material "JLC FR-4 core") (epsilon_r 4.36) (loss_tangent 0.02))
|
||||
\t\t\t(layer "L7_GND" (type "copper") (thickness 0.0152))
|
||||
\t\t\t(layer "dielectric 7" (type "prepreg") (thickness 0.0994) (material "JLC 3313") (epsilon_r 4.1) (loss_tangent 0.02))
|
||||
@@ -566,8 +691,6 @@ def inject_stackup(path: Path) -> None:
|
||||
data = re.sub(r"\n\t\t\(stackup\n.*?\n\t\t\)\n", "\n" + STACKUP + "\n", data, count=1, flags=re.S)
|
||||
else:
|
||||
data = data.replace("\n\t(setup\n", "\n\t(setup\n" + STACKUP + "\n", 1)
|
||||
data = data.replace("(allow_soldermask_bridges_in_footprints no)",
|
||||
"(allow_soldermask_bridges_in_footprints yes)")
|
||||
path.write_text(data)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compensate Freerouting 2.3.0's KiCad-10 DSN clearance scale mismatch.
|
||||
"""Optionally scale only DSN clearance entries by an explicit factor.
|
||||
|
||||
KiCad exports widths and clearances in micrometres. Freerouting 2.3.0 keeps
|
||||
the widths but internally imports DSN clearance values at one quarter of the
|
||||
intended physical value. Scaling only ``(clearance N)`` entries by four makes
|
||||
the router's physical rules match the source KiCad board. Coordinates, track
|
||||
widths, vias, drills, padstacks, and keepouts are deliberately untouched.
|
||||
The bundled Freerouting 2.3.0 build reads KiCad 10 micrometre clearance values
|
||||
directly, so the safe default is 1.0 (no scaling). This utility is retained for
|
||||
reproducible experiments with other router builds; never apply a non-unity
|
||||
factor without comparing the imported physical rule values first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,8 +21,10 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--factor", type=float, default=4.0)
|
||||
parser.add_argument("--factor", type=float, default=1.0)
|
||||
args = parser.parse_args()
|
||||
if args.factor <= 0:
|
||||
raise ValueError("--factor must be positive")
|
||||
|
||||
source = args.input.read_text()
|
||||
original = [float(match.group(2)) for match in CLEARANCE.finditer(source)]
|
||||
|
||||
+47
-3
@@ -22,6 +22,10 @@ def mm(value: float) -> int:
|
||||
return pcbnew.FromMM(value)
|
||||
|
||||
|
||||
def centre_mm(position: pcbnew.VECTOR2I) -> tuple[float, float]:
|
||||
return pcbnew.ToMM(position.x), pcbnew.ToMM(position.y)
|
||||
|
||||
|
||||
def add_segment(board: pcbnew.BOARD, pad: pcbnew.PAD,
|
||||
end: pcbnew.VECTOR2I, width_mm: float) -> None:
|
||||
track = pcbnew.PCB_TRACK(board)
|
||||
@@ -46,10 +50,25 @@ def add_via(board: pcbnew.BOARD, pad: pcbnew.PAD,
|
||||
board.Add(via)
|
||||
|
||||
|
||||
def ball_grid(footprint: pcbnew.FOOTPRINT) -> tuple[dict, float, float, float, float]:
|
||||
"""Every ball position, connected or not, plus the array extent."""
|
||||
positions = {}
|
||||
for pad in footprint.Pads():
|
||||
p = pad.GetPosition()
|
||||
positions[(round(pcbnew.ToMM(p.x), 2), round(pcbnew.ToMM(p.y), 2))] = pad
|
||||
xs = [x for x, _ in positions]
|
||||
ys = [y for _, y in positions]
|
||||
return positions, min(xs), min(ys), max(xs), max(ys)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input", nargs="?", type=Path, default=DEFAULT_INPUT)
|
||||
parser.add_argument("output", nargs="?", type=Path, default=DEFAULT_OUTPUT)
|
||||
parser.add_argument("--surface-rings", type=int, default=1,
|
||||
help="How many outer ball rings escape on F.Cu without "
|
||||
"a via. Each via removed here frees an inner-layer "
|
||||
"channel and one anti-pad per plane.")
|
||||
args = parser.parse_args()
|
||||
|
||||
board = pcbnew.LoadBoard(str(args.input))
|
||||
@@ -58,12 +77,37 @@ def main() -> None:
|
||||
raise RuntimeError("U1 Ti375 footprint is missing")
|
||||
centre = fpga.GetPosition()
|
||||
occupied: set[tuple[int, int]] = set()
|
||||
counts = {"power": 0, "signal": 0}
|
||||
counts = {"power": 0, "signal": 0, "surface": 0}
|
||||
_grid, gx0, gy0, gx1, gy1 = ball_grid(fpga)
|
||||
pitch = 0.8
|
||||
|
||||
for pad in fpga.Pads():
|
||||
if not pad.GetNetname():
|
||||
continue
|
||||
position = pad.GetPosition()
|
||||
x, y = pcbnew.ToMM(position.x), pcbnew.ToMM(position.y)
|
||||
ring = min(round((x - gx0) / pitch), round((gx1 - x) / pitch),
|
||||
round((y - gy0) / pitch), round((gy1 - y) / pitch))
|
||||
is_power = pad.GetNetname() in POWER_NETS
|
||||
# Signal balls on the outer rings leave straight out on F.Cu. They
|
||||
# need no via at all, and the interstitial site they would have taken
|
||||
# stays open as a channel for the balls further in. Power and ground
|
||||
# balls always drop, because their whole purpose is to reach a plane.
|
||||
if not is_power and ring < args.surface_rings:
|
||||
dx = 0.0 if abs(x - centre_mm(centre)[0]) < 0.4 else (
|
||||
1.0 if x > centre_mm(centre)[0] else -1.0)
|
||||
dy = 0.0 if abs(y - centre_mm(centre)[1]) < 0.4 else (
|
||||
1.0 if y > centre_mm(centre)[1] else -1.0)
|
||||
if abs(x - centre_mm(centre)[0]) > abs(y - centre_mm(centre)[1]):
|
||||
dy = 0.0
|
||||
else:
|
||||
dx = 0.0
|
||||
reach = 1.6
|
||||
end = pcbnew.VECTOR2I(position.x + mm(dx * reach),
|
||||
position.y + mm(dy * reach))
|
||||
add_segment(board, pad, end, 0.10)
|
||||
counts["surface"] += 1
|
||||
continue
|
||||
# Move each ball toward its package quadrant. Centre-row/column balls
|
||||
# use the positive direction, leaving a full 0.8-mm via grid without
|
||||
# duplicated interstitial locations.
|
||||
@@ -74,7 +118,6 @@ def main() -> None:
|
||||
if key in occupied:
|
||||
raise RuntimeError(f"Duplicate dogbone via at {key} for U1.{pad.GetNumber()}")
|
||||
occupied.add(key)
|
||||
is_power = pad.GetNetname() in POWER_NETS
|
||||
add_segment(board, pad, via_position, 0.15 if is_power else 0.10)
|
||||
add_via(board, pad, via_position)
|
||||
counts["power" if is_power else "signal"] += 1
|
||||
@@ -86,7 +129,8 @@ def main() -> None:
|
||||
source = args.input.with_suffix(suffix)
|
||||
if source.exists():
|
||||
shutil.copyfile(source, args.output.with_suffix(suffix))
|
||||
print(f"Installed {counts['power']} power/GND and {counts['signal']} signal dogbones")
|
||||
print(f"Installed {counts['power']} power/GND and {counts['signal']} signal "
|
||||
f"dogbones, {counts['surface']} via-less surface escapes")
|
||||
print(f"Saved {args.output}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Summarise what a routed board actually contains.
|
||||
|
||||
DRC answers "is anything wrong"; this answers "is anything missing, and does
|
||||
the copper match the rules it was routed under". It reports the still-open
|
||||
connections per net, the track width actually used per net class (the Specctra
|
||||
round trip is where widths silently drift), the layer split, and the via count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import pcbnew
|
||||
|
||||
|
||||
def net_groups(board: pcbnew.BOARD, net: str) -> int:
|
||||
"""Count the separate copper islands a net is currently split into."""
|
||||
connectivity = board.GetConnectivity()
|
||||
pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()
|
||||
if pad.GetNetname() == net]
|
||||
seen: set[str] = set()
|
||||
groups = 0
|
||||
for pad in pads:
|
||||
key = pad.m_Uuid.AsString()
|
||||
if key in seen:
|
||||
continue
|
||||
members = [item for item in connectivity.GetConnectedItems(pad, 0)
|
||||
if item.GetClass() == "PAD"]
|
||||
for item in members:
|
||||
seen.add(item.m_Uuid.AsString())
|
||||
seen.add(key)
|
||||
groups += 1
|
||||
return groups
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("board", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
board = pcbnew.LoadBoard(str(args.board))
|
||||
board.BuildConnectivity()
|
||||
|
||||
net_class: dict[str, str] = {}
|
||||
pad_count: Counter[str] = Counter()
|
||||
for footprint in board.GetFootprints():
|
||||
for pad in footprint.Pads():
|
||||
name = pad.GetNetname()
|
||||
if name:
|
||||
net_class[name] = pad.GetNetClassName()
|
||||
pad_count[name] += 1
|
||||
|
||||
widths: dict[str, Counter[float]] = defaultdict(Counter)
|
||||
layers: Counter[str] = Counter()
|
||||
vias = 0
|
||||
for item in board.GetTracks():
|
||||
if isinstance(item, pcbnew.PCB_VIA):
|
||||
vias += 1
|
||||
continue
|
||||
name = item.GetNetname()
|
||||
widths[net_class.get(name, "?")][round(pcbnew.ToMM(item.GetWidth()), 3)] += 1
|
||||
layers[board.GetLayerName(item.GetLayer())] += 1
|
||||
|
||||
print(f"Board: {args.board}")
|
||||
print(f"Unconnected endpoints reported by connectivity: "
|
||||
f"{board.GetConnectivity().GetUnconnectedCount(True)}")
|
||||
print(f"Tracks: {sum(layers.values())} Vias: {vias}")
|
||||
print("\nTrack segments per layer:")
|
||||
for layer, count in layers.most_common():
|
||||
print(f" {layer:10s} {count:6d}")
|
||||
|
||||
print("\nTrack width used per net class (mm: segments):")
|
||||
for klass in sorted(widths):
|
||||
used = ", ".join(f"{width}: {count}"
|
||||
for width, count in sorted(widths[klass].items()))
|
||||
print(f" {klass:12s} {used}")
|
||||
|
||||
print("\nNets still split into more than one copper group:")
|
||||
incomplete = []
|
||||
for name in sorted(net_class):
|
||||
if pad_count[name] < 2:
|
||||
continue
|
||||
groups = net_groups(board, name)
|
||||
if groups > 1:
|
||||
incomplete.append((groups, name))
|
||||
for groups, name in sorted(incomplete, reverse=True):
|
||||
print(f" {name:22s} {net_class[name]:12s} "
|
||||
f"{pad_count[name]:3d} pads in {groups} groups")
|
||||
print(f" ({len(incomplete)} nets incomplete)" if incomplete
|
||||
else " none - every net is one connected group")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,858 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Finish the power distribution that planes and plane stitching cannot reach.
|
||||
|
||||
Three kinds of connection are left after `stitch_plane_pads.py`:
|
||||
|
||||
* pads whose rail has a plane, but not directly underneath them (a regulator
|
||||
output capacitor just outside its island, a PMOD header on the board edge);
|
||||
* rails that are too small to deserve a plane at all (`FTDI_1V8`,
|
||||
`IMU_VREGD`, the antenna-bias nets), which are nevertheless excluded from
|
||||
the Specctra export because their netclass is POWER;
|
||||
* the local input/output loops around each LDO, where two different rails are
|
||||
interleaved inside a couple of millimetres and no split plane can serve both.
|
||||
|
||||
None of these belong in the autorouter: it cannot see planes at all, so handing
|
||||
it `GND` or `VCC3V3` would make it wire hundreds of pads together that the
|
||||
plane already joins. This stage works from the real connectivity graph
|
||||
instead. For every group of a rail that is still separate it either drops a
|
||||
via onto the rail's own plane, or routes to the nearest other group with
|
||||
45-degree geometry.
|
||||
|
||||
It runs in two phases. The first allows only plane drops and short direct
|
||||
paths, for every rail; the second lets whatever is left take a detour through
|
||||
an intermediate waypoint. Without that split the first rail in the list
|
||||
spends the open space on long routes that a later rail needed for a short one.
|
||||
Everything installed here is locked, and the same code runs again from
|
||||
finalize_routing.py once the RF and USB corridors are released.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import pcbnew
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_INPUT = ROOT / "hardware" / "board_plane_stitched.kicad_pcb"
|
||||
DEFAULT_OUTPUT = ROOT / "hardware" / "board_power_local.kicad_pcb"
|
||||
|
||||
# Rails handled here. Signal nets, the deterministic converter switch nodes
|
||||
# and the critical RF/USB nets are deliberately absent.
|
||||
PLANE_NETS = (
|
||||
"GND", "SYS5", "VCC0V95", "VCC1V8", "VCC3V3", "VCC3V3_AUX",
|
||||
"VCC3V0_CLK", "VCC3V0_DIG", "VCC3V0_RF", "VCC3V3_ANT",
|
||||
)
|
||||
TRACE_NETS = (
|
||||
"VBUS_RAW", "USB5_PROT", "EXT5_RAW", "EXT5_PROT", "USB_SHIELD",
|
||||
"FTDI_1V8", "FTDI_VPHY", "FTDI_VPLL",
|
||||
"IMU_3V3", "IMU_VREGA2", "IMU_VREGD",
|
||||
"ANT_BIAS_SEL", "ANT_BIAS_SW",
|
||||
)
|
||||
TARGET_NETS = PLANE_NETS + TRACE_NETS
|
||||
|
||||
WIDE_NETS = {"GND", "SYS5", "VCC0V95", "VBUS_RAW", "USB5_PROT",
|
||||
"EXT5_RAW", "EXT5_PROT"}
|
||||
VIA_DIAMETER = 0.45
|
||||
VIA_DRILL = 0.225
|
||||
CLEARANCE = 0.13
|
||||
# The rule value plus a rounding margin, used when completing signal nets
|
||||
# through the Ti375 escape field where nothing wider will fit.
|
||||
SIGNAL_CLEARANCE = 0.105
|
||||
HOLE_TO_HOLE = 0.30
|
||||
EDGE_CLEARANCE = 0.30
|
||||
EDGE = (20.6, 20.6, 174.4, 109.4)
|
||||
# Nets whose netclass demands more than the plain copper clearance. The local
|
||||
# power router must respect the controlled-impedance corridors even though it
|
||||
# never routes them itself.
|
||||
STRICT_CLEARANCE = {
|
||||
"RF_SMA": 0.20, "RF_PRE_SAW": 0.20, "RF_POST_SAW": 0.20, "MAX_MIXIN_HI": 0.20,
|
||||
"USB_D_P": 0.15, "USB_D_N": 0.15, "FTDI_D_P": 0.15, "FTDI_D_N": 0.15,
|
||||
}
|
||||
# The edge-launch SMA lands sit on the board outline by construction; their
|
||||
# ground return is built by finalize_routing.py's RF ground zone and via fence.
|
||||
SKIP_REFS = {"J2"}
|
||||
# Every ground land under the GPS front end is connected by that same RF ground
|
||||
# zone and its via fence. Ordinary dogbones here would only add long stubs
|
||||
# alongside the controlled-impedance line.
|
||||
RF_GROUND_REGION = (152.0, 36.0, 174.5, 49.5)
|
||||
# Nothing but ground and the MAX2771's own supplies may run under the GPS front
|
||||
# end on a layer other than F.Cu. This router is the one stage that runs after
|
||||
# finalize_routing.py has deleted the temporary keepout, so it has to carry the
|
||||
# restriction itself; without it the deterministic completion is free to drop a
|
||||
# charge-pump or control net onto L3 directly beneath the 50-ohm line.
|
||||
RF_REGION_LAYERS = (pcbnew.In2_Cu, pcbnew.In3_Cu, pcbnew.In4_Cu,
|
||||
pcbnew.In5_Cu, pcbnew.B_Cu)
|
||||
|
||||
|
||||
def rf_region_forbids(net: str, layer: int | None) -> bool:
|
||||
"""True when this net may not occupy the RF region on this layer.
|
||||
|
||||
`layer` of None means a through via, which is present on every layer and so
|
||||
is judged as if it were on the inner ones.
|
||||
"""
|
||||
if net in TARGET_NETS:
|
||||
return False
|
||||
if layer is not None and layer not in RF_REGION_LAYERS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def in_rf_region(position: tuple[float, float], margin: float = 0.0) -> bool:
|
||||
return (RF_GROUND_REGION[0] - margin <= position[0] <= RF_GROUND_REGION[2] + margin and
|
||||
RF_GROUND_REGION[1] - margin <= position[1] <= RF_GROUND_REGION[3] + margin)
|
||||
MAX_PLANE_REACH = 7.0
|
||||
# A dogbone via never sits further than this from the pad it escapes.
|
||||
ESCAPE_VIA_REACH = 0.8
|
||||
MAX_JOIN_LENGTH = 80.0
|
||||
|
||||
|
||||
def mm(value: float) -> int:
|
||||
return pcbnew.FromMM(value)
|
||||
|
||||
|
||||
def to_mm(value: int) -> float:
|
||||
return pcbnew.ToMM(value)
|
||||
|
||||
|
||||
def point(xy: tuple[float, float]) -> pcbnew.VECTOR2I:
|
||||
return pcbnew.VECTOR2I(mm(xy[0]), mm(xy[1]))
|
||||
|
||||
|
||||
# Widths used when this stage finishes leftover signal nets. They match the
|
||||
# KiCad net classes so the copper is indistinguishable from the autorouted
|
||||
# result; the controlled-impedance classes are absent because finalize_routing
|
||||
# builds those by hand.
|
||||
NETCLASS_WIDTH = {"Default": 0.12, "BGA_ESCAPE": 0.10, "CLOCK": 0.15}
|
||||
# Per-class routing clearance. CLOCK keeps its wider figure; the others drop
|
||||
# to the rule value so they still fit the Ti375 escape channels.
|
||||
NETCLASS_CLEARANCE = {"Default": 0.105, "BGA_ESCAPE": 0.105, "CLOCK": 0.155}
|
||||
SKIP_CLASSES = frozenset({"RF50_CPWG", "USB90_DIFF"})
|
||||
|
||||
|
||||
def net_width(net: str) -> float:
|
||||
return 0.40 if net in WIDE_NETS else 0.25
|
||||
|
||||
|
||||
def segment_distance(p: tuple[float, float], a: tuple[float, float],
|
||||
b: tuple[float, float]) -> float:
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length = dx * dx + dy * dy
|
||||
if length == 0.0:
|
||||
return math.hypot(p[0] - a[0], p[1] - a[1])
|
||||
t = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length))
|
||||
return math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy))
|
||||
|
||||
|
||||
def segments_distance(a0: tuple[float, float], a1: tuple[float, float],
|
||||
b0: tuple[float, float], b1: tuple[float, float]) -> float:
|
||||
"""Minimum distance between two segments, including the crossing case."""
|
||||
def cross(o, a, b):
|
||||
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
||||
|
||||
d1, d2 = cross(a0, a1, b0), cross(a0, a1, b1)
|
||||
d3, d4 = cross(b0, b1, a0), cross(b0, b1, a1)
|
||||
if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)):
|
||||
return 0.0
|
||||
return min(segment_distance(b0, a0, a1), segment_distance(b1, a0, a1),
|
||||
segment_distance(a0, b0, b1), segment_distance(a1, b0, b1))
|
||||
|
||||
|
||||
class Obstacles:
|
||||
"""Other-net copper, indexed on a coarse grid for quick local lookups."""
|
||||
|
||||
CELL = 5.0
|
||||
|
||||
def __init__(self, board: pcbnew.BOARD) -> None:
|
||||
self.board = board
|
||||
self.cells: dict[tuple[int, int], list] = defaultdict(list)
|
||||
self.holes: list[tuple[tuple[float, float], float]] = []
|
||||
for footprint in board.GetFootprints():
|
||||
for pad in footprint.Pads():
|
||||
box = pad.GetBoundingBox()
|
||||
bounds = (to_mm(box.GetX()), to_mm(box.GetY()),
|
||||
to_mm(box.GetRight()), to_mm(box.GetBottom()))
|
||||
self._insert(("pad", pad.GetNetname(), bounds,
|
||||
pad.GetLayerSet()), bounds)
|
||||
drill = max(to_mm(pad.GetDrillSizeX()), to_mm(pad.GetDrillSizeY()))
|
||||
if drill > 0:
|
||||
position = pad.GetPosition()
|
||||
self.holes.append(((to_mm(position.x), to_mm(position.y)),
|
||||
drill / 2))
|
||||
for item in board.GetTracks():
|
||||
if isinstance(item, pcbnew.PCB_VIA):
|
||||
position = (to_mm(item.GetPosition().x), to_mm(item.GetPosition().y))
|
||||
radius = to_mm(item.GetWidth(pcbnew.F_Cu)) / 2
|
||||
self.add_via(item.GetNetname(), position, radius,
|
||||
to_mm(item.GetDrill()) / 2)
|
||||
else:
|
||||
start = (to_mm(item.GetStart().x), to_mm(item.GetStart().y))
|
||||
end = (to_mm(item.GetEnd().x), to_mm(item.GetEnd().y))
|
||||
self.add_track(item.GetNetname(), start, end,
|
||||
to_mm(item.GetWidth()), item.GetLayer())
|
||||
# Only areas that actually forbid copper block routing. Rule areas
|
||||
# that exist purely so a custom DRC rule can name a region (see
|
||||
# finalize_routing.add_named_rule_areas) must not stop anything.
|
||||
self.track_areas = [zone for zone in board.Zones()
|
||||
if zone.GetIsRuleArea() and zone.GetDoNotAllowTracks()]
|
||||
self.via_areas = [zone for zone in board.Zones()
|
||||
if zone.GetIsRuleArea() and zone.GetDoNotAllowVias()]
|
||||
|
||||
def _keys(self, bounds: tuple[float, float, float, float]):
|
||||
x0 = int(math.floor(bounds[0] / self.CELL))
|
||||
y0 = int(math.floor(bounds[1] / self.CELL))
|
||||
x1 = int(math.floor(bounds[2] / self.CELL))
|
||||
y1 = int(math.floor(bounds[3] / self.CELL))
|
||||
for x in range(x0 - 1, x1 + 2):
|
||||
for y in range(y0 - 1, y1 + 2):
|
||||
yield x, y
|
||||
|
||||
def _insert(self, entry, bounds) -> None:
|
||||
for key in self._keys(bounds):
|
||||
self.cells[key].append(entry)
|
||||
|
||||
def add_via(self, net: str, position: tuple[float, float], radius: float,
|
||||
drill_radius: float = VIA_DRILL / 2) -> None:
|
||||
bounds = (position[0] - radius, position[1] - radius,
|
||||
position[0] + radius, position[1] + radius)
|
||||
self._insert(("via", net, position, radius), bounds)
|
||||
self.holes.append((position, drill_radius))
|
||||
|
||||
def add_track(self, net: str, start: tuple[float, float],
|
||||
end: tuple[float, float], width: float, layer: int) -> None:
|
||||
bounds = (min(start[0], end[0]) - width, min(start[1], end[1]) - width,
|
||||
max(start[0], end[0]) + width, max(start[1], end[1]) + width)
|
||||
self._insert(("track", net, start, end, width / 2, layer), bounds)
|
||||
|
||||
def _nearby(self, bounds):
|
||||
seen = set()
|
||||
for key in self._keys(bounds):
|
||||
for entry in self.cells.get(key, ()):
|
||||
if id(entry) in seen:
|
||||
continue
|
||||
seen.add(id(entry))
|
||||
yield entry
|
||||
|
||||
def rule_area_blocks(self, position: tuple[float, float], layer: int | None,
|
||||
radius: float = 0.0) -> bool:
|
||||
"""Test a disc, not a point: copper close to a rule area still violates it.
|
||||
|
||||
A layer of None means a through via, which is checked against the areas
|
||||
that forbid vias on any layer.
|
||||
"""
|
||||
probes = [position]
|
||||
for step in range(8):
|
||||
angle = math.radians(45 * step)
|
||||
probes.append((position[0] + radius * math.cos(angle),
|
||||
position[1] + radius * math.sin(angle)))
|
||||
areas = self.via_areas if layer is None else self.track_areas
|
||||
for zone in areas:
|
||||
if layer is not None and not zone.GetLayerSet().Contains(layer):
|
||||
continue
|
||||
outline = zone.Outline()
|
||||
if any(outline.Contains(point(probe)) for probe in probes):
|
||||
return True
|
||||
return False
|
||||
|
||||
def hole_clear(self, position: tuple[float, float],
|
||||
drill_radius: float = VIA_DRILL / 2) -> bool:
|
||||
for other, other_radius in self.holes:
|
||||
gap = math.hypot(position[0] - other[0], position[1] - other[1])
|
||||
if gap < drill_radius + other_radius + HOLE_TO_HOLE:
|
||||
return False
|
||||
return True
|
||||
|
||||
def track_clear(self, net: str, start: tuple[float, float],
|
||||
end: tuple[float, float], width: float, layer: int,
|
||||
clearance_floor: float | None = None) -> bool:
|
||||
"""`clearance_floor` overrides the routing margin with the bare rule
|
||||
value, for callers that are checking existing copper rather than
|
||||
placing new copper with room to spare."""
|
||||
floor = CLEARANCE if clearance_floor is None else clearance_floor
|
||||
half = width / 2
|
||||
margin_to_edge = half + EDGE_CLEARANCE
|
||||
for probe in (start, end):
|
||||
if not (EDGE[0] + margin_to_edge <= probe[0] <= EDGE[2] - margin_to_edge and
|
||||
EDGE[1] + margin_to_edge <= probe[1] <= EDGE[3] - margin_to_edge):
|
||||
return False
|
||||
forbidden = rf_region_forbids(net, layer)
|
||||
for fraction in range(0, 11):
|
||||
sample = (start[0] + (end[0] - start[0]) * fraction / 10,
|
||||
start[1] + (end[1] - start[1]) * fraction / 10)
|
||||
if self.rule_area_blocks(sample, layer, half + CLEARANCE):
|
||||
return False
|
||||
if forbidden and in_rf_region(sample, half + CLEARANCE):
|
||||
return False
|
||||
bounds = (min(start[0], end[0]) - 2, min(start[1], end[1]) - 2,
|
||||
max(start[0], end[0]) + 2, max(start[1], end[1]) + 2)
|
||||
for entry in self._nearby(bounds):
|
||||
if entry[1] == net:
|
||||
continue
|
||||
clearance = max(floor, STRICT_CLEARANCE.get(entry[1], 0.0))
|
||||
if entry[0] == "pad":
|
||||
_, _, box, layers = entry
|
||||
if not layers.Contains(layer):
|
||||
continue
|
||||
margin = half + clearance
|
||||
corners = ((box[0], box[1]), (box[2], box[1]),
|
||||
(box[2], box[3]), (box[0], box[3]))
|
||||
inflated = (box[0] - margin, box[1] - margin,
|
||||
box[2] + margin, box[3] + margin)
|
||||
for probe in (start, end):
|
||||
if (inflated[0] <= probe[0] <= inflated[2] and
|
||||
inflated[1] <= probe[1] <= inflated[3]):
|
||||
return False
|
||||
for index in range(4):
|
||||
if segments_distance(start, end, corners[index],
|
||||
corners[(index + 1) % 4]) < margin:
|
||||
return False
|
||||
elif entry[0] == "via":
|
||||
_, _, position, radius = entry
|
||||
if segment_distance(position, start, end) < half + radius + clearance:
|
||||
return False
|
||||
else:
|
||||
_, _, other_start, other_end, other_half, other_layer = entry
|
||||
if other_layer != layer:
|
||||
continue
|
||||
if segments_distance(start, end, other_start,
|
||||
other_end) < half + other_half + clearance:
|
||||
return False
|
||||
return True
|
||||
|
||||
def via_clear(self, net: str, position: tuple[float, float],
|
||||
clearance_floor: float | None = None) -> bool:
|
||||
floor = CLEARANCE if clearance_floor is None else clearance_floor
|
||||
radius = VIA_DIAMETER / 2
|
||||
if not (EDGE[0] + radius <= position[0] <= EDGE[2] - radius and
|
||||
EDGE[1] + radius <= position[1] <= EDGE[3] - radius):
|
||||
return False
|
||||
# A through via is present on every layer, so any rule area blocks it.
|
||||
if self.rule_area_blocks(position, None, radius + CLEARANCE):
|
||||
return False
|
||||
if rf_region_forbids(net, None) and in_rf_region(position, radius + CLEARANCE):
|
||||
return False
|
||||
if not self.hole_clear(position):
|
||||
return False
|
||||
bounds = (position[0] - 2, position[1] - 2, position[0] + 2, position[1] + 2)
|
||||
for entry in self._nearby(bounds):
|
||||
if entry[1] == net:
|
||||
continue
|
||||
clearance = max(floor, STRICT_CLEARANCE.get(entry[1], 0.0))
|
||||
if entry[0] == "pad":
|
||||
_, _, box, _layers = entry
|
||||
margin = radius + clearance
|
||||
if (box[0] - margin <= position[0] <= box[2] + margin and
|
||||
box[1] - margin <= position[1] <= box[3] + margin):
|
||||
return False
|
||||
elif entry[0] == "via":
|
||||
_, _, other, other_radius = entry
|
||||
if math.hypot(position[0] - other[0],
|
||||
position[1] - other[1]) < radius + other_radius + clearance:
|
||||
return False
|
||||
else:
|
||||
_, _, start, end, half, _layer = entry
|
||||
if segment_distance(position, start, end) < radius + half + clearance:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def diagonal_paths(a: tuple[float, float],
|
||||
b: tuple[float, float]) -> list[list[tuple[float, float]]]:
|
||||
"""Two-segment 45-degree routes between two points, shortest first."""
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
if abs(dx) < 1e-6 or abs(dy) < 1e-6 or abs(abs(dx) - abs(dy)) < 1e-6:
|
||||
return [[a, b]]
|
||||
sx = math.copysign(1.0, dx)
|
||||
sy = math.copysign(1.0, dy)
|
||||
run = abs(abs(dx) - abs(dy))
|
||||
if abs(dx) > abs(dy):
|
||||
diagonal_first = [a, (a[0] + sx * abs(dy), a[1] + dy), b]
|
||||
straight_first = [a, (a[0] + sx * run, a[1]), b]
|
||||
else:
|
||||
diagonal_first = [a, (a[0] + dx, a[1] + sy * abs(dx)), b]
|
||||
straight_first = [a, (a[0], a[1] + sy * run), b]
|
||||
return [diagonal_first, straight_first,
|
||||
[a, (a[0], b[1]), b], [a, (b[0], a[1]), b]]
|
||||
|
||||
|
||||
def detour_paths(a: tuple[float, float], b: tuple[float, float],
|
||||
step: float = 2.5, margin: float = 12.0,
|
||||
limit: int = 96) -> list[list[tuple[float, float]]]:
|
||||
"""Routes that reach b through one intermediate point, shortest first.
|
||||
|
||||
Two segments only ever get around an obstacle by luck. Sampling a waypoint
|
||||
grid over the span between the two ends, and joining a-to-waypoint and
|
||||
waypoint-to-b with the same 45-degree geometry, is enough to get a supply
|
||||
trace past the components between an LDO and the far end of its rail.
|
||||
"""
|
||||
x0, x1 = sorted((a[0], b[0]))
|
||||
y0, y1 = sorted((a[1], b[1]))
|
||||
candidates = []
|
||||
steps_x = int((x1 - x0 + 2 * margin) / step) + 1
|
||||
steps_y = int((y1 - y0 + 2 * margin) / step) + 1
|
||||
for ix in range(steps_x):
|
||||
for iy in range(steps_y):
|
||||
way = (round(x0 - margin + ix * step, 3),
|
||||
round(y0 - margin + iy * step, 3))
|
||||
if not (EDGE[0] <= way[0] <= EDGE[2] and EDGE[1] <= way[1] <= EDGE[3]):
|
||||
continue
|
||||
length = (math.hypot(way[0] - a[0], way[1] - a[1]) +
|
||||
math.hypot(b[0] - way[0], b[1] - way[1]))
|
||||
candidates.append((length, way))
|
||||
candidates.sort()
|
||||
routes = []
|
||||
for _length, way in candidates[:limit]:
|
||||
first = diagonal_paths(a, way)[0]
|
||||
second = diagonal_paths(way, b)[0]
|
||||
routes.append(first + second[1:])
|
||||
return routes
|
||||
|
||||
|
||||
class LocalRouter:
|
||||
def __init__(self, board: pcbnew.BOARD) -> None:
|
||||
self.board = board
|
||||
self.obstacles = Obstacles(board)
|
||||
self.installed = 0
|
||||
self.vias = 0
|
||||
self.widths: dict[str, float] = {}
|
||||
# Power rails are routed with a margin over the rule. Signal
|
||||
# completion has to drop to the rule itself: a 0.12-mm trace needs
|
||||
# 0.385 mm of the 0.400-mm channel between two Ti375 escape vias, and
|
||||
# at 0.13 mm it would need 0.415 mm and never fit.
|
||||
self.clearance = CLEARANCE
|
||||
self.clearances: dict[str, float] = {}
|
||||
# Taken from the board, not written down here: the stackup gained a
|
||||
# fourth signal layer when L5 stopped being a power plane, and a fixed
|
||||
# list would have gone on routing everything through the old three.
|
||||
self.routing_layers = [layer for layer in board.GetEnabledLayers().CuStack()
|
||||
if board.GetLayerType(layer) == pcbnew.LT_SIGNAL]
|
||||
|
||||
def width_for(self, net: str) -> float:
|
||||
return self.widths.get(net, net_width(net))
|
||||
|
||||
def clearance_for(self, net: str) -> float:
|
||||
return self.clearances.get(net, self.clearance)
|
||||
|
||||
def path_clear(self, net: str, vertices: list[tuple[float, float]],
|
||||
width: float, layer: int) -> bool:
|
||||
for start, end in zip(vertices, vertices[1:]):
|
||||
if start == end:
|
||||
continue
|
||||
if not self.obstacles.track_clear(net, start, end, width, layer,
|
||||
clearance_floor=self.clearance_for(net)):
|
||||
return False
|
||||
return True
|
||||
|
||||
def first_clear(self, net: str, a: tuple[float, float], b: tuple[float, float],
|
||||
width: float, layer: int,
|
||||
detour: bool = True) -> list[tuple[float, float]] | None:
|
||||
"""The shortest clear route from a to b on one layer, or None."""
|
||||
for vertices in diagonal_paths(a, b):
|
||||
if self.path_clear(net, vertices, width, layer):
|
||||
return vertices
|
||||
if not detour:
|
||||
return None
|
||||
for vertices in detour_paths(a, b):
|
||||
if self.path_clear(net, vertices, width, layer):
|
||||
return vertices
|
||||
return None
|
||||
|
||||
def commit(self, net: str, vertices: list[tuple[float, float]],
|
||||
width: float, layer: int) -> None:
|
||||
for start, end in zip(vertices, vertices[1:]):
|
||||
if start == end:
|
||||
continue
|
||||
track = pcbnew.PCB_TRACK(self.board)
|
||||
track.SetStart(point(start))
|
||||
track.SetEnd(point(end))
|
||||
track.SetWidth(mm(width))
|
||||
track.SetLayer(layer)
|
||||
track.SetNet(self.board.FindNet(net))
|
||||
track.SetLocked(True)
|
||||
self.board.Add(track)
|
||||
self.obstacles.add_track(net, start, end, width, layer)
|
||||
self.installed += 1
|
||||
|
||||
def commit_via(self, net: str, position: tuple[float, float]) -> None:
|
||||
via = pcbnew.PCB_VIA(self.board)
|
||||
via.SetPosition(point(position))
|
||||
via.SetWidth(mm(VIA_DIAMETER))
|
||||
via.SetDrill(mm(VIA_DRILL))
|
||||
via.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
|
||||
via.SetNet(self.board.FindNet(net))
|
||||
via.SetLocked(True)
|
||||
self.board.Add(via)
|
||||
self.obstacles.add_via(net, position, VIA_DIAMETER / 2)
|
||||
self.vias += 1
|
||||
|
||||
def connect(self, net: str, a: tuple[float, float], a_layer: int,
|
||||
b: tuple[float, float], b_layer: int,
|
||||
detour: bool = True) -> bool:
|
||||
"""Route a to b, changing layer through a via when that is what fits."""
|
||||
width = self.width_for(net)
|
||||
if a_layer == b_layer:
|
||||
vertices = self.first_clear(net, a, b, width, a_layer, detour)
|
||||
if vertices is not None:
|
||||
self.commit(net, vertices, width, a_layer)
|
||||
return True
|
||||
for layer in self.routing_layers:
|
||||
if layer == a_layer and layer == b_layer:
|
||||
continue
|
||||
for via_a, via_b in self.via_options(net, a, a_layer, b, b_layer, layer):
|
||||
plan = []
|
||||
if via_a is not None:
|
||||
if not self.path_clear(net, [a, via_a], width, a_layer):
|
||||
continue
|
||||
plan.append(("track", [a, via_a], a_layer))
|
||||
plan.append(("via", via_a))
|
||||
start = via_a
|
||||
else:
|
||||
start = a
|
||||
end = via_b if via_b is not None else b
|
||||
middle = self.first_clear(net, start, end, width, layer, detour)
|
||||
if middle is None:
|
||||
continue
|
||||
plan.append(("track", middle, layer))
|
||||
if via_b is not None:
|
||||
if not self.path_clear(net, [via_b, b], width, b_layer):
|
||||
continue
|
||||
plan.append(("via", via_b))
|
||||
plan.append(("track", [via_b, b], b_layer))
|
||||
for entry in plan:
|
||||
if entry[0] == "via":
|
||||
self.commit_via(net, entry[1])
|
||||
else:
|
||||
self.commit(net, entry[1], width, entry[2])
|
||||
return True
|
||||
return False
|
||||
|
||||
def via_options(self, net: str, a: tuple[float, float], a_layer: int,
|
||||
b: tuple[float, float], b_layer: int, layer: int):
|
||||
"""Offer via drop points just outside each endpoint's own pad."""
|
||||
escapes_a = [None] if a_layer == layer else self.escape_points(net, a, b)
|
||||
escapes_b = [None] if b_layer == layer else self.escape_points(net, b, a)
|
||||
for via_a in escapes_a[:4]:
|
||||
for via_b in escapes_b[:4]:
|
||||
yield via_a, via_b
|
||||
|
||||
def escape_points(self, net: str, origin: tuple[float, float],
|
||||
towards: tuple[float, float]) -> list[tuple[float, float]]:
|
||||
heading = math.atan2(towards[1] - origin[1], towards[0] - origin[0])
|
||||
options = []
|
||||
for radius in (0.65, 0.95, 1.35):
|
||||
for step in range(8):
|
||||
angle = math.radians(45 * step)
|
||||
candidate = (round(origin[0] + radius * math.cos(angle), 3),
|
||||
round(origin[1] + radius * math.sin(angle), 3))
|
||||
if not self.obstacles.via_clear(net, candidate, self.clearance_for(net)):
|
||||
continue
|
||||
score = -math.cos(angle - heading) * 10 + radius
|
||||
options.append((score, candidate))
|
||||
options.sort()
|
||||
return [candidate for _score, candidate in options]
|
||||
|
||||
def reach_plane(self, net: str, zones: list[pcbnew.ZONE],
|
||||
terminals: list[tuple[tuple[float, float], int]]) -> bool:
|
||||
"""Drop a via from one of this group's pads onto the rail's own plane."""
|
||||
width = self.width_for(net)
|
||||
best = None
|
||||
for origin, layer in terminals:
|
||||
for radius in [x * 0.4 for x in range(2, int(MAX_PLANE_REACH / 0.4) + 1)]:
|
||||
for step in range(8):
|
||||
angle = math.radians(45 * step)
|
||||
candidate = (round(origin[0] + radius * math.cos(angle), 3),
|
||||
round(origin[1] + radius * math.sin(angle), 3))
|
||||
probe = point(candidate)
|
||||
if not any(zone.HitTestFilledArea(zone.GetLayer(), probe)
|
||||
for zone in zones):
|
||||
continue
|
||||
if not self.obstacles.via_clear(net, candidate, self.clearance_for(net)):
|
||||
continue
|
||||
if not self.path_clear(net, [origin, candidate], width, layer):
|
||||
continue
|
||||
if best is None or radius < best[0]:
|
||||
best = (radius, origin, candidate, layer)
|
||||
break
|
||||
if best is not None and best[0] <= radius:
|
||||
break
|
||||
if best is None:
|
||||
return False
|
||||
_radius, origin, candidate, layer = best
|
||||
self.commit(net, [origin, candidate], width, layer)
|
||||
self.commit_via(net, candidate)
|
||||
return True
|
||||
|
||||
|
||||
def pad_layer(pad: pcbnew.PAD) -> int:
|
||||
layers = pad.GetLayerSet().Seq()
|
||||
return layers[0] if layers else pcbnew.F_Cu
|
||||
|
||||
|
||||
def groups_for_net(board: pcbnew.BOARD, net: str) -> list[list[pcbnew.PAD]]:
|
||||
connectivity = board.GetConnectivity()
|
||||
pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()
|
||||
if pad.GetNetname() == net]
|
||||
seen: set[str] = set()
|
||||
groups: list[list[pcbnew.PAD]] = []
|
||||
for pad in pads:
|
||||
key = pad.m_Uuid.AsString()
|
||||
if key in seen:
|
||||
continue
|
||||
members = [item for item in connectivity.GetConnectedItems(pad, 0)
|
||||
if item.GetClass() == "PAD"] or [pad]
|
||||
for item in members:
|
||||
seen.add(item.m_Uuid.AsString())
|
||||
seen.add(key)
|
||||
groups.append([item.Cast() if hasattr(item, "Cast") else item
|
||||
for item in members])
|
||||
return groups
|
||||
|
||||
|
||||
def escape_via_map(board: pcbnew.BOARD) -> dict[str, list[tuple[float, float]]]:
|
||||
result: dict[str, list[tuple[float, float]]] = defaultdict(list)
|
||||
for item in board.GetTracks():
|
||||
if isinstance(item, pcbnew.PCB_VIA):
|
||||
position = item.GetPosition()
|
||||
result[item.GetNetname()].append(
|
||||
(round(to_mm(position.x), 4), round(to_mm(position.y), 4)))
|
||||
return result
|
||||
|
||||
|
||||
def terminals_of(group: list[pcbnew.PAD], net: str,
|
||||
vias: dict[str, list[tuple[float, float]]] | None = None,
|
||||
inner_layers: tuple[int, ...] = (pcbnew.B_Cu,),
|
||||
) -> list[tuple[tuple[float, float], int]]:
|
||||
"""Points this group can be routed from, with the layer each one sits on.
|
||||
|
||||
A pad that already has a dogbone via contributes that via as well. Under
|
||||
the Ti375 the escape vias are the only usable terminals: an isolated bank
|
||||
supply ball has no plane left around its via (every neighbouring ball's
|
||||
anti-pad eats it), and F.Cu there is solid escape fan-out, so the one route
|
||||
that exists is a short hop to the neighbouring same-net via on B.Cu.
|
||||
|
||||
`inner_layers` is the set of routing layers such a via can be picked up on;
|
||||
it comes from the board so a stackup change cannot silently strand one.
|
||||
"""
|
||||
result = []
|
||||
for pad in group:
|
||||
parent = pad.GetParentFootprint()
|
||||
if parent is not None and parent.GetReference() in SKIP_REFS:
|
||||
continue
|
||||
position = pad.GetPosition()
|
||||
xy = (round(to_mm(position.x), 4), round(to_mm(position.y), 4))
|
||||
if net == "GND" and (RF_GROUND_REGION[0] <= xy[0] <= RF_GROUND_REGION[2] and
|
||||
RF_GROUND_REGION[1] <= xy[1] <= RF_GROUND_REGION[3]):
|
||||
continue
|
||||
result.append((xy, pad_layer(pad)))
|
||||
for via in (vias or {}).get(net, ()):
|
||||
if math.hypot(via[0] - xy[0], via[1] - xy[1]) <= ESCAPE_VIA_REACH:
|
||||
for layer in inner_layers:
|
||||
result.append((via, layer))
|
||||
return result
|
||||
|
||||
|
||||
def on_plane(board: pcbnew.BOARD, group: list[pcbnew.PAD],
|
||||
zones: list[pcbnew.ZONE]) -> bool:
|
||||
"""True when this group already sits on one of the rail's own zones.
|
||||
|
||||
Without this test a group that reached its plane but did not thereby merge
|
||||
with any other group - because no other group is on that island - reports
|
||||
progress on every pass. route_net then keeps calling reach_plane, which
|
||||
keeps finding the next free spot one step further out, and the rail ends up
|
||||
with a cloud of dozens of redundant vias around a single pad.
|
||||
"""
|
||||
if not zones:
|
||||
return False
|
||||
wanted = {zone.m_Uuid.AsString() for zone in zones}
|
||||
connectivity = board.GetConnectivity()
|
||||
for pad in group:
|
||||
for item in connectivity.GetConnectedItems(pad, 0):
|
||||
if item.GetClass() == "ZONE" and item.m_Uuid.AsString() in wanted:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def route_net(router: LocalRouter, board: pcbnew.BOARD, net: str,
|
||||
zones: list[pcbnew.ZONE], detour: bool = True) -> tuple[int, int]:
|
||||
"""Return the number of groups joined and the number left unresolved."""
|
||||
joined = 0
|
||||
stalled = 0
|
||||
previous = None
|
||||
for _pass in range(60):
|
||||
board.BuildConnectivity()
|
||||
groups = groups_for_net(board, net)
|
||||
if len(groups) <= 1:
|
||||
break
|
||||
# A pass that claims progress without reducing the group count has not
|
||||
# actually connected anything new. Allow a couple of those - a plane
|
||||
# drop can legitimately need one - then stop rather than pile on copper.
|
||||
if previous is not None and len(groups) >= previous:
|
||||
stalled += 1
|
||||
if stalled >= 2:
|
||||
break
|
||||
else:
|
||||
stalled = 0
|
||||
previous = len(groups)
|
||||
vias = escape_via_map(board)
|
||||
inner = tuple(layer for layer in router.routing_layers
|
||||
if layer != pcbnew.F_Cu)
|
||||
terminals = [terminals_of(group, net, vias, inner) for group in groups]
|
||||
sizes = [len(group) for group in groups]
|
||||
main = sizes.index(max(sizes))
|
||||
|
||||
progress = False
|
||||
for index, group_terminals in enumerate(terminals):
|
||||
if index == main:
|
||||
continue
|
||||
if (zones and not on_plane(board, groups[index], zones)
|
||||
and router.reach_plane(net, zones, group_terminals)):
|
||||
joined += 1
|
||||
progress = True
|
||||
break
|
||||
candidates = []
|
||||
for other, other_terminals in enumerate(terminals):
|
||||
if other == index:
|
||||
continue
|
||||
for a, a_layer in group_terminals:
|
||||
for b, b_layer in other_terminals:
|
||||
distance = math.hypot(a[0] - b[0], a[1] - b[1])
|
||||
if distance <= MAX_JOIN_LENGTH:
|
||||
candidates.append((distance, a, a_layer, b, b_layer))
|
||||
candidates.sort(key=lambda entry: entry[0])
|
||||
for _distance, a, a_layer, b, b_layer in candidates[:24]:
|
||||
if router.connect(net, a, a_layer, b, b_layer, detour):
|
||||
joined += 1
|
||||
progress = True
|
||||
break
|
||||
if progress:
|
||||
break
|
||||
if not progress:
|
||||
break
|
||||
board.BuildConnectivity()
|
||||
return joined, len(groups_for_net(board, net)) - 1
|
||||
|
||||
|
||||
def signal_nets(board: pcbnew.BOARD,
|
||||
classes: frozenset[str] | None = None) -> dict[str, float]:
|
||||
"""Routable signal nets and the track width their net class asks for."""
|
||||
result: dict[str, float] = {}
|
||||
for footprint in board.GetFootprints():
|
||||
for pad in footprint.Pads():
|
||||
name = pad.GetNetname()
|
||||
if not name or name in TARGET_NETS or name in result:
|
||||
continue
|
||||
klass = pad.GetNetClassName()
|
||||
if klass in SKIP_CLASSES or (classes and klass not in classes):
|
||||
continue
|
||||
width = NETCLASS_WIDTH.get(klass)
|
||||
if width is not None:
|
||||
result[name] = width
|
||||
return result
|
||||
|
||||
|
||||
def signal_clearances(board: pcbnew.BOARD,
|
||||
classes: frozenset[str] | None = None) -> dict[str, float]:
|
||||
result: dict[str, float] = {}
|
||||
for footprint in board.GetFootprints():
|
||||
for pad in footprint.Pads():
|
||||
name = pad.GetNetname()
|
||||
if not name or name in result:
|
||||
continue
|
||||
klass = pad.GetNetClassName()
|
||||
if classes and klass not in classes:
|
||||
continue
|
||||
value = NETCLASS_CLEARANCE.get(klass)
|
||||
if value is not None:
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
|
||||
def complete(board: pcbnew.BOARD, verbose: bool = True,
|
||||
signals: bool = False,
|
||||
signal_classes: frozenset[str] | None = None) -> dict[str, int]:
|
||||
"""Run the local power completion on an already-filled board.
|
||||
|
||||
finalize_routing.py calls this a second time once the temporary RF and USB
|
||||
rule areas are gone, which is the only moment the pads inside those
|
||||
corridors can be reached. With `signals` it then makes the same two passes
|
||||
over ordinary signal nets, to close the connections the autorouter gave up
|
||||
on; those use their net class width and are locked like everything else.
|
||||
"""
|
||||
router = LocalRouter(board)
|
||||
zones_by_net: dict[str, list[pcbnew.ZONE]] = defaultdict(list)
|
||||
for zone in board.Zones():
|
||||
if not zone.GetIsRuleArea():
|
||||
zones_by_net[zone.GetNetname()].append(zone)
|
||||
|
||||
# Two phases. Everything that can be closed with a plane drop or a short
|
||||
# direct path is done first, for every rail; only then are the leftovers
|
||||
# allowed to take a detour. Otherwise the first rail in the list spends
|
||||
# the free space on long routes that a later rail needed for a short one.
|
||||
remaining: dict[str, int] = {}
|
||||
for detour in (False, True):
|
||||
remaining = {}
|
||||
for net in TARGET_NETS:
|
||||
if board.FindNet(net) is None:
|
||||
continue
|
||||
joined, left = route_net(router, board, net,
|
||||
zones_by_net.get(net, []), detour)
|
||||
if verbose and detour and (joined or left):
|
||||
print(f"{net:14s} {left} groups still separate")
|
||||
if left:
|
||||
remaining[net] = left
|
||||
|
||||
if signals:
|
||||
router.widths = signal_nets(board, signal_classes)
|
||||
router.clearance = SIGNAL_CLEARANCE
|
||||
router.clearances = signal_clearances(board, signal_classes)
|
||||
closed = 0
|
||||
for detour in (False, True):
|
||||
for net in sorted(router.widths):
|
||||
joined, left = route_net(router, board, net, [], detour)
|
||||
closed += joined
|
||||
if left:
|
||||
remaining[net] = left
|
||||
else:
|
||||
remaining.pop(net, None)
|
||||
if verbose:
|
||||
print(f"Signal completion joined {closed} groups")
|
||||
|
||||
if verbose:
|
||||
print(f"Installed {router.installed} tracks and {router.vias} vias")
|
||||
print("Unresolved:",
|
||||
", ".join(f"{n}={k}" for n, k in sorted(remaining.items())) or "none")
|
||||
return remaining
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input", nargs="?", type=Path, default=DEFAULT_INPUT)
|
||||
parser.add_argument("output", nargs="?", type=Path, default=DEFAULT_OUTPUT)
|
||||
parser.add_argument("--clocks", action="store_true",
|
||||
help="Also route the CLOCK class here, before the "
|
||||
"autorouter runs, so those nets are fixed copper "
|
||||
"by the time it starts.")
|
||||
args = parser.parse_args()
|
||||
|
||||
board = pcbnew.LoadBoard(str(args.input))
|
||||
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
|
||||
raise RuntimeError("Could not fill planes before local power routing")
|
||||
complete(board, signals=args.clocks,
|
||||
signal_classes=frozenset({"CLOCK"}) if args.clocks else None)
|
||||
|
||||
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
|
||||
raise RuntimeError("Could not refill planes after local power routing")
|
||||
pcbnew.SaveBoard(str(args.output), board)
|
||||
for suffix in (".kicad_pro", ".kicad_dru"):
|
||||
source = args.input.with_suffix(suffix)
|
||||
if source.exists():
|
||||
shutil.copyfile(source, args.output.with_suffix(suffix))
|
||||
print(f"Saved {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Route the three buck converters before general signal autorouting.
|
||||
|
||||
The switching nodes and high-current input/output paths are deterministic and
|
||||
locked. Freerouting must never be asked to invent these loops. Internal-plane
|
||||
entry points use explicit via arrays; ordinary low-current power stitching is
|
||||
left to stitch_plane_pads.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pcbnew
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_INPUT = ROOT / "hardware" / "board_prerouted.kicad_pcb"
|
||||
DEFAULT_OUTPUT = ROOT / "hardware" / "board_power_routed.kicad_pcb"
|
||||
|
||||
|
||||
def mm(value: float) -> int:
|
||||
return pcbnew.FromMM(value)
|
||||
|
||||
|
||||
def point(x: float, y: float) -> pcbnew.VECTOR2I:
|
||||
return pcbnew.VECTOR2I(mm(x), mm(y))
|
||||
|
||||
|
||||
def pad(board: pcbnew.BOARD, reference: str, number: str) -> pcbnew.PAD:
|
||||
footprint = board.FindFootprintByReference(reference)
|
||||
if footprint is None:
|
||||
raise RuntimeError(f"Missing footprint {reference}")
|
||||
matches = [item for item in footprint.Pads() if item.GetNumber() == number]
|
||||
if not matches:
|
||||
raise RuntimeError(f"Missing pad {reference}.{number}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def xy(item: pcbnew.BOARD_ITEM) -> tuple[float, float]:
|
||||
position = item.GetPosition()
|
||||
return pcbnew.ToMM(position.x), pcbnew.ToMM(position.y)
|
||||
|
||||
|
||||
def assert_pad_net(board: pcbnew.BOARD, reference: str, number: str,
|
||||
expected: str) -> None:
|
||||
actual = pad(board, reference, number).GetNetname()
|
||||
if actual != expected:
|
||||
raise RuntimeError(
|
||||
f"{reference}.{number}: expected {expected}, found {actual or '<none>'}"
|
||||
)
|
||||
|
||||
|
||||
def add_track(board: pcbnew.BOARD, net_name: str,
|
||||
start: tuple[float, float], end: tuple[float, float],
|
||||
width: float, layer: int = pcbnew.F_Cu) -> None:
|
||||
if start == end:
|
||||
return
|
||||
track = pcbnew.PCB_TRACK(board)
|
||||
track.SetStart(point(*start))
|
||||
track.SetEnd(point(*end))
|
||||
track.SetWidth(mm(width))
|
||||
track.SetLayer(layer)
|
||||
track.SetNet(board.FindNet(net_name))
|
||||
track.SetLocked(True)
|
||||
board.Add(track)
|
||||
|
||||
|
||||
def add_path(board: pcbnew.BOARD, net_name: str,
|
||||
points: list[tuple[float, float]], width: float,
|
||||
layer: int = pcbnew.F_Cu) -> None:
|
||||
for start, end in zip(points, points[1:]):
|
||||
add_track(board, net_name, start, end, width, layer)
|
||||
|
||||
|
||||
def add_via(board: pcbnew.BOARD, net_name: str, x: float, y: float,
|
||||
diameter: float = 0.70, drill: float = 0.35) -> None:
|
||||
via = pcbnew.PCB_VIA(board)
|
||||
via.SetPosition(point(x, y))
|
||||
via.SetWidth(mm(diameter))
|
||||
via.SetDrill(mm(drill))
|
||||
via.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
|
||||
via.SetNet(board.FindNet(net_name))
|
||||
via.SetLocked(True)
|
||||
board.Add(via)
|
||||
|
||||
|
||||
def route_core_buck(board: pcbnew.BOARD) -> None:
|
||||
for reference, number, expected in (
|
||||
("U13", "4", "SYS5"), ("U13", "9", "CORE_SW"),
|
||||
("U13", "23", "CORE_COMP"), ("U13", "24", "CORE_SENSE_N"),
|
||||
("L1", "1", "CORE_SW"), ("L1", "2", "VCC0V95"),
|
||||
("C18", "1", "SYS5"), ("C22", "1", "VCC0V95"),
|
||||
("R27", "2", "CORE_SENSE_P"), ("R28", "2", "CORE_SENSE_N"),
|
||||
):
|
||||
assert_pad_net(board, reference, number, expected)
|
||||
# The exposed pad carries the converter return/thermal current. Four
|
||||
# via-in-pad connections plus explicit perimeter-pin escapes replace the
|
||||
# generic stitching that is intentionally disabled for U13.
|
||||
for x in (99.70, 100.30):
|
||||
for y in (92.40, 93.60):
|
||||
add_via(board, "GND", x, y, 0.45, 0.225)
|
||||
add_track(board, "GND", xy(pad(board, "U13", "3")), (98.10, 92.25), 0.22)
|
||||
add_via(board, "GND", 98.10, 92.25, 0.45, 0.225)
|
||||
add_track(board, "GND", xy(pad(board, "U13", "7")), (98.10, 94.25), 0.22)
|
||||
add_via(board, "GND", 98.10, 94.25, 0.45, 0.225)
|
||||
add_path(board, "GND", [xy(pad(board, "U13", "8")),
|
||||
(98.675, 95.10), (98.10, 95.10)], 0.22)
|
||||
add_via(board, "GND", 98.10, 95.10, 0.45, 0.225)
|
||||
add_track(board, "GND", xy(pad(board, "U13", "19")), (101.65, 91.75), 0.22)
|
||||
add_via(board, "GND", 101.65, 91.75, 0.45, 0.225)
|
||||
add_path(board, "GND", [xy(pad(board, "U13", "20")),
|
||||
(101.25, 91.10), (101.65, 91.10)], 0.22)
|
||||
add_via(board, "GND", 101.65, 91.10, 0.45, 0.225)
|
||||
add_path(board, "GND", [xy(pad(board, "U13", "22")),
|
||||
(100.25, 92.40), (100.30, 92.40)], 0.22)
|
||||
add_track(board, "GND", xy(pad(board, "U13", "14")), (101.65, 94.25), 0.22)
|
||||
add_via(board, "GND", 101.65, 94.25, 0.45, 0.225)
|
||||
add_path(board, "GND", [xy(pad(board, "U13", "13")),
|
||||
(101.25, 95.10), (101.65, 95.10)], 0.22)
|
||||
add_via(board, "GND", 101.65, 95.10, 0.45, 0.225)
|
||||
|
||||
# TPS6287B switching pins fan into a short common neck, then immediately
|
||||
# enter L1. No other net is permitted through this high-dV/dt region.
|
||||
for number in ("9", "10", "11", "12"):
|
||||
x, y = xy(pad(board, "U13", number))
|
||||
add_track(board, "CORE_SW", (x, y), (x, 95.25), 0.20)
|
||||
add_track(board, "CORE_SW", (99.25, 95.25), (100.75, 95.25), 0.35)
|
||||
add_track(board, "CORE_SW", (100.0, 95.25), xy(pad(board, "L1", "1")), 0.80)
|
||||
|
||||
# Expand immediately after L1 into the four 100-uF output capacitors.
|
||||
l1_out = xy(pad(board, "L1", "2"))
|
||||
add_track(board, "VCC0V95", l1_out, (100.0, 102.025), 1.20)
|
||||
add_track(board, "VCC0V95", (95.5, 102.025), (106.0, 102.025), 1.00)
|
||||
# A separate broad trunk and four-via entry feed the 0.95-V internal plane.
|
||||
add_path(board, "VCC0V95",
|
||||
[(106.0, 102.025), (108.2, 102.025), (108.2, 93.0)], 1.50)
|
||||
for x in (107.8, 108.6):
|
||||
for y in (92.6, 93.4):
|
||||
add_via(board, "VCC0V95", x, y, 0.75, 0.38)
|
||||
|
||||
# Both VIN sides of U13 are locally decoupled. The right bank joins the
|
||||
# left bank above the IC, away from the switching/output trunk.
|
||||
add_track(board, "SYS5", xy(pad(board, "C18", "1")),
|
||||
xy(pad(board, "C19", "1")), 0.60)
|
||||
for number in ("4", "5", "6"):
|
||||
source = xy(pad(board, "U13", number))
|
||||
add_track(board, "SYS5", source, (97.175, source[1]), 0.25)
|
||||
add_track(board, "SYS5", xy(pad(board, "C20", "1")),
|
||||
xy(pad(board, "C21", "1")), 0.60)
|
||||
for number in ("15", "16", "18"):
|
||||
source = xy(pad(board, "U13", number))
|
||||
add_track(board, "SYS5", source, (102.725, source[1]), 0.25)
|
||||
# Feed the right input-capacitor bank from the existing B.Cu VIN trunk so
|
||||
# the quiet compensation/sense region above U13 remains unobstructed.
|
||||
add_track(board, "SYS5", xy(pad(board, "C20", "1")),
|
||||
(102.725, 90.70), 0.60)
|
||||
add_via(board, "SYS5", 102.725, 90.70, 0.70, 0.35)
|
||||
add_path(board, "SYS5", [(102.725, 90.70), (110.00, 90.70),
|
||||
(110.00, 97.20), (109.00, 97.20)],
|
||||
0.80, pcbnew.B_Cu)
|
||||
# Drop to B.Cu just below C19, bypassing the power-sequencer components
|
||||
# that occupy the left side of the converter on F.Cu.
|
||||
add_track(board, "SYS5", xy(pad(board, "C19", "1")),
|
||||
(97.175, 95.60), 0.60)
|
||||
add_via(board, "SYS5", 97.175, 95.60, 0.75, 0.38)
|
||||
add_track(board, "SYS5", (97.175, 95.60), (97.175, 97.20),
|
||||
0.80, pcbnew.B_Cu)
|
||||
add_path(board, "SYS5", [(97.175, 97.20), (109.00, 97.20),
|
||||
(109.00, 108.50), (92.60, 108.50)],
|
||||
1.20, pcbnew.B_Cu)
|
||||
for x in (92.2, 93.0):
|
||||
for y in (108.1, 108.9):
|
||||
add_via(board, "SYS5", x, y, 0.75, 0.38)
|
||||
|
||||
# Compensation components form a literal series chain. Keep it on F.Cu
|
||||
# and out of the switch-node field.
|
||||
add_track(board, "CORE_COMP_RC", xy(pad(board, "C16", "1")),
|
||||
xy(pad(board, "R29", "2")), 0.15)
|
||||
add_track(board, "CORE_COMP", xy(pad(board, "R29", "1")),
|
||||
xy(pad(board, "C17", "1")), 0.15)
|
||||
add_path(board, "CORE_COMP",
|
||||
[xy(pad(board, "C17", "1")), (99.52, 90.20),
|
||||
(99.75, 90.20),
|
||||
xy(pad(board, "U13", "23"))], 0.15)
|
||||
|
||||
# Route the positive/negative remote-sense pair together on L3_SIG. The
|
||||
# pair stays north of the converter and never approaches CORE_SW.
|
||||
sense = (
|
||||
("CORE_SENSE_P", "1", "R27", "2", 98.40, 90.40, 87.60),
|
||||
("CORE_SENSE_N", "24", "R28", "2", 99.10, 90.70, 89.70),
|
||||
)
|
||||
for (net_name, u13_pin, resistor, resistor_pin,
|
||||
start_x, start_y, lane_y) in sense:
|
||||
start = xy(pad(board, "U13", u13_pin))
|
||||
start_via = (start_x, start_y)
|
||||
end = xy(pad(board, resistor, resistor_pin))
|
||||
end_via = (102.90, end[1])
|
||||
add_path(board, net_name, [start, (start[0], start_y), start_via], 0.12)
|
||||
add_via(board, net_name, *start_via, 0.45, 0.225)
|
||||
add_path(board, net_name,
|
||||
[start_via, (start_x, lane_y), (102.90, lane_y), end_via],
|
||||
0.12, pcbnew.In2_Cu)
|
||||
add_via(board, net_name, *end_via, 0.45, 0.225)
|
||||
add_track(board, net_name, end_via, end, 0.12)
|
||||
|
||||
|
||||
def route_small_buck(board: pcbnew.BOARD, *, regulator: str, inductor: str,
|
||||
input_caps: tuple[str, str], output_caps: tuple[str, str],
|
||||
sw_net: str, out_net: str, input_x: float,
|
||||
plane_via_x: float) -> None:
|
||||
for reference, number, expected in (
|
||||
(regulator, "3", "SYS5"), (regulator, "7", sw_net),
|
||||
(inductor, "1", sw_net), (inductor, "2", out_net),
|
||||
(input_caps[0], "1", "SYS5"), (input_caps[1], "1", "SYS5"),
|
||||
(output_caps[0], "1", out_net), (output_caps[1], "1", out_net),
|
||||
):
|
||||
assert_pad_net(board, reference, number, expected)
|
||||
# Input capacitors, VIN pin, and the SYS5 plane entry form one compact path.
|
||||
upper_in = xy(pad(board, input_caps[0], "1"))
|
||||
lower_in = xy(pad(board, input_caps[1], "1"))
|
||||
add_track(board, "SYS5", upper_in, lower_in, 0.45)
|
||||
regulator_in = xy(pad(board, regulator, "3"))
|
||||
diagonal_start_x = lower_in[0] + abs(lower_in[1] - regulator_in[1])
|
||||
add_path(board, "SYS5", [regulator_in,
|
||||
(diagonal_start_x, regulator_in[1]), lower_in], 0.30)
|
||||
add_track(board, "SYS5", lower_in, (input_x, 107.60), 0.70)
|
||||
for x in (input_x - 0.35, input_x + 0.35):
|
||||
for y in (107.25, 107.95):
|
||||
add_via(board, "SYS5", x, y, 0.65, 0.33)
|
||||
|
||||
# Keep the switching node to the literal pin-to-inductor span.
|
||||
add_track(board, sw_net, xy(pad(board, regulator, "7")),
|
||||
xy(pad(board, inductor, "1")), 0.50)
|
||||
|
||||
# Join the inductor, output capacitors, regulator output pins and a
|
||||
# plane entry installed by stitch_plane_pads.py. These spans stay on F.Cu.
|
||||
coil_out = xy(pad(board, inductor, "2"))
|
||||
cap_a = xy(pad(board, output_caps[0], "1"))
|
||||
cap_b = xy(pad(board, output_caps[1], "1"))
|
||||
add_path(board, out_net, [coil_out, (plane_via_x, 97.0),
|
||||
(plane_via_x, cap_a[1]), cap_a], 0.55)
|
||||
add_path(board, out_net, [(plane_via_x, 97.0),
|
||||
(plane_via_x, cap_b[1]), cap_b], 0.55)
|
||||
reg_out_a = xy(pad(board, regulator, "8"))
|
||||
reg_out_b = xy(pad(board, regulator, "9"))
|
||||
add_track(board, out_net, reg_out_a, reg_out_b, 0.25)
|
||||
add_path(board, out_net,
|
||||
[reg_out_b, (plane_via_x, reg_out_b[1]), (plane_via_x, 97.0)], 0.30)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input", nargs="?", type=Path, default=DEFAULT_INPUT)
|
||||
parser.add_argument("output", nargs="?", type=Path, default=DEFAULT_OUTPUT)
|
||||
args = parser.parse_args()
|
||||
|
||||
board = pcbnew.LoadBoard(str(args.input))
|
||||
route_core_buck(board)
|
||||
route_small_buck(
|
||||
board, regulator="U14", inductor="L2", input_caps=("C26", "C27"),
|
||||
output_caps=("C28", "C29"), sw_net="BUCK1V8_SW", out_net="VCC1V8",
|
||||
input_x=112.98, plane_via_x=120.105,
|
||||
)
|
||||
route_small_buck(
|
||||
board, regulator="U15", inductor="L3", input_caps=("C30", "C31"),
|
||||
output_caps=("C32", "C33"), sw_net="BUCK3V3_SW", out_net="VCC3V3",
|
||||
input_x=127.98, plane_via_x=135.105,
|
||||
)
|
||||
|
||||
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
|
||||
raise RuntimeError("Zone fill failed after power-stage routing")
|
||||
pcbnew.SaveBoard(str(args.output), board)
|
||||
for suffix in (".kicad_pro", ".kicad_dru"):
|
||||
source = args.input.with_suffix(suffix)
|
||||
if source.exists():
|
||||
shutil.copyfile(source, args.output.with_suffix(suffix))
|
||||
print(f"Saved {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+164
-47
@@ -5,6 +5,10 @@ This deliberately does not replace power-stage routing. It only installs
|
||||
short pad-to-via connections where a filled, same-net plane is physically
|
||||
present below the candidate via. High-current converter loops and plane
|
||||
bridges are handled separately.
|
||||
|
||||
It runs *after* route_power_stage.py: a candidate via is only checked against
|
||||
copper that already exists, so any stage that lays down wide trunks has to go
|
||||
first or its copper is invisible here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,7 +22,7 @@ import pcbnew
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_INPUT = ROOT / "hardware" / "board_prerouted.kicad_pcb"
|
||||
DEFAULT_INPUT = ROOT / "hardware" / "board_power_routed.kicad_pcb"
|
||||
DEFAULT_OUTPUT = ROOT / "hardware" / "board_plane_stitched.kicad_pcb"
|
||||
PLANE_NETS = {
|
||||
"GND", "SYS5", "VCC0V95", "VCC1V8", "VCC3V3", "VCC3V3_AUX",
|
||||
@@ -38,7 +42,7 @@ def distance_mm(a: pcbnew.VECTOR2I, b: pcbnew.VECTOR2I) -> float:
|
||||
|
||||
|
||||
def add_track(board: pcbnew.BOARD, pad: pcbnew.PAD,
|
||||
end: pcbnew.VECTOR2I, width: float) -> None:
|
||||
end: pcbnew.VECTOR2I, width: float) -> pcbnew.PCB_TRACK:
|
||||
track = pcbnew.PCB_TRACK(board)
|
||||
track.SetStart(pad.GetPosition())
|
||||
track.SetEnd(end)
|
||||
@@ -47,6 +51,23 @@ def add_track(board: pcbnew.BOARD, pad: pcbnew.PAD,
|
||||
track.SetNet(pad.GetNet())
|
||||
track.SetLocked(True)
|
||||
board.Add(track)
|
||||
return track
|
||||
|
||||
|
||||
def already_connected(pad: pcbnew.PAD, tracks: list[pcbnew.PCB_TRACK],
|
||||
vias: list[pcbnew.PCB_VIA]) -> bool:
|
||||
"""Report whether an earlier stage already attached copper to this pad."""
|
||||
net = pad.GetNetname()
|
||||
box = pad.GetBoundingBox()
|
||||
|
||||
def inside(position: pcbnew.VECTOR2I) -> bool:
|
||||
return (box.GetX() <= position.x <= box.GetRight() and
|
||||
box.GetY() <= position.y <= box.GetBottom())
|
||||
|
||||
for track in tracks:
|
||||
if track.GetNetname() == net and (inside(track.GetStart()) or inside(track.GetEnd())):
|
||||
return True
|
||||
return any(via.GetNetname() == net and inside(via.GetPosition()) for via in vias)
|
||||
|
||||
|
||||
def add_via(board: pcbnew.BOARD, pad: pcbnew.PAD,
|
||||
@@ -68,6 +89,66 @@ def plane_at(zones_by_net: dict[str, list[pcbnew.ZONE]], net: str,
|
||||
for zone in zones_by_net.get(net, ()))
|
||||
|
||||
|
||||
def inside_temporary_keepout(position: pcbnew.VECTOR2I,
|
||||
keepouts: list[pcbnew.ZONE]) -> bool:
|
||||
"""Reject a through-via whose copper can touch an RF/USB rule area."""
|
||||
radius = VIA_DIAMETER / 2 + CLEARANCE
|
||||
probes = [(0.0, 0.0)]
|
||||
for index in range(8):
|
||||
angle = index * math.pi / 4
|
||||
probes.append((radius * math.cos(angle), radius * math.sin(angle)))
|
||||
for dx, dy in probes:
|
||||
probe = pcbnew.VECTOR2I(position.x + mm(dx), position.y + mm(dy))
|
||||
if any(zone.Outline().Contains(probe) for zone in keepouts):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def segment_distance_mm(point: pcbnew.VECTOR2I, start: pcbnew.VECTOR2I,
|
||||
end: pcbnew.VECTOR2I) -> float:
|
||||
px, py = pcbnew.ToMM(point.x), pcbnew.ToMM(point.y)
|
||||
ax, ay = pcbnew.ToMM(start.x), pcbnew.ToMM(start.y)
|
||||
bx, by = pcbnew.ToMM(end.x), pcbnew.ToMM(end.y)
|
||||
dx, dy = bx - ax, by - ay
|
||||
length = dx * dx + dy * dy
|
||||
if length == 0.0:
|
||||
return math.hypot(px - ax, py - ay)
|
||||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / length))
|
||||
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||
|
||||
|
||||
def clear_of_tracks(position: pcbnew.VECTOR2I, net: str,
|
||||
tracks: list[pcbnew.PCB_TRACK], extra: float = 0.0) -> bool:
|
||||
"""Keep a via barrel clear of copper installed by the routing stages."""
|
||||
for track in tracks:
|
||||
if track.GetNetname() == net or isinstance(track, pcbnew.PCB_VIA):
|
||||
continue
|
||||
limit = (VIA_DIAMETER / 2 + extra + pcbnew.ToMM(track.GetWidth()) / 2
|
||||
+ CLEARANCE + 0.03)
|
||||
if segment_distance_mm(position, track.GetStart(), track.GetEnd()) < limit:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def clear_track_of_tracks(start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, net: str,
|
||||
tracks: list[pcbnew.PCB_TRACK], layer: int,
|
||||
width: float) -> bool:
|
||||
for track in tracks:
|
||||
if track.GetNetname() == net or isinstance(track, pcbnew.PCB_VIA):
|
||||
continue
|
||||
if track.GetLayer() != layer:
|
||||
continue
|
||||
limit = width / 2 + pcbnew.ToMM(track.GetWidth()) / 2 + CLEARANCE + 0.03
|
||||
for step in range(0, 13):
|
||||
sample = pcbnew.VECTOR2I(
|
||||
round(start.x + (end.x - start.x) * step / 12),
|
||||
round(start.y + (end.y - start.y) * step / 12),
|
||||
)
|
||||
if segment_distance_mm(sample, track.GetStart(), track.GetEnd()) < limit:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def clear_of_other_copper(position: pcbnew.VECTOR2I, net: str,
|
||||
pads: list[pcbnew.PAD], vias: list[pcbnew.PCB_VIA]) -> bool:
|
||||
x, y = pcbnew.ToMM(position.x), pcbnew.ToMM(position.y)
|
||||
@@ -116,19 +197,93 @@ def candidates(pad: pcbnew.PAD, footprint: pcbnew.FOOTPRINT) -> list[pcbnew.VECT
|
||||
away = 0.0
|
||||
radius = max(pcbnew.ToMM(pad.GetSizeX()), pcbnew.ToMM(pad.GetSizeY())) / 2
|
||||
radius += VIA_DIAMETER / 2 + CLEARANCE + 0.08
|
||||
angles = [away]
|
||||
for delta_deg in (30, -30, 60, -60, 90, -90, 120, -120, 150, -150, 180):
|
||||
angles.append(away + math.radians(delta_deg))
|
||||
# Freerouting treats fixed non-45-degree traces as invalid geometry. Use
|
||||
# only global orthogonal/45-degree directions, ordered by how closely they
|
||||
# point away from the footprint centre.
|
||||
directions = [(math.cos(math.radians(angle)), math.sin(math.radians(angle)))
|
||||
for angle in range(0, 360, 45)]
|
||||
directions.sort(key=lambda direction: -(
|
||||
direction[0] * math.cos(away) + direction[1] * math.sin(away)
|
||||
))
|
||||
result = []
|
||||
for scale in (1.0, 1.35, 1.75):
|
||||
for angle in angles:
|
||||
for dx, dy in directions:
|
||||
result.append(pcbnew.VECTOR2I(
|
||||
pad_pos.x + mm(radius * scale * math.cos(angle)),
|
||||
pad_pos.y + mm(radius * scale * math.sin(angle)),
|
||||
pad_pos.x + mm(radius * scale * dx),
|
||||
pad_pos.y + mm(radius * scale * dy),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def stitch(board: pcbnew.BOARD) -> tuple[dict[str, int], dict[str, int]]:
|
||||
"""Connect every reachable SMD power pad to its plane on the given board.
|
||||
|
||||
Callable twice: once before signal routing, so the planes already have
|
||||
real connection points, and again from finalize_routing.py once the RF and
|
||||
USB corridors are no longer reserved by temporary rule areas. Pads that
|
||||
are already stitched are skipped because the candidate location is then
|
||||
occupied by the earlier via.
|
||||
"""
|
||||
zones_by_net: dict[str, list[pcbnew.ZONE]] = {}
|
||||
for zone in board.Zones():
|
||||
if not zone.GetIsRuleArea() and zone.GetNetname() in PLANE_NETS:
|
||||
zones_by_net.setdefault(zone.GetNetname(), []).append(zone)
|
||||
temporary_keepouts = [zone for zone in board.Zones()
|
||||
if zone.GetIsRuleArea() and zone.GetZoneName().startswith("TEMP_")]
|
||||
|
||||
all_pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()]
|
||||
all_vias = [item for item in board.GetTracks() if isinstance(item, pcbnew.PCB_VIA)]
|
||||
all_tracks = [item for item in board.GetTracks() if not isinstance(item, pcbnew.PCB_VIA)]
|
||||
connected: dict[str, int] = {}
|
||||
skipped: dict[str, int] = {}
|
||||
|
||||
for fp in board.GetFootprints():
|
||||
# U1 already has deterministic dogbones. J2 is an edge-launch part;
|
||||
# its ground lands are completed by finalize_routing.py's RF ground
|
||||
# zone/fence, not by ordinary tracks that would run along Edge.Cuts.
|
||||
# U13 ground/thermal vias are part of the deterministic high-current
|
||||
# converter geometry in route_power_stage.py. Generic radial dogbones
|
||||
# here can intrude into its SW and VIN paths.
|
||||
if fp.GetReference() in {"U1", "U13", "J2"}:
|
||||
continue
|
||||
for pad in fp.Pads():
|
||||
net = pad.GetNetname()
|
||||
if net not in PLANE_NETS or pad.GetAttribute() != pcbnew.PAD_ATTRIB_SMD:
|
||||
continue
|
||||
if already_connected(pad, all_tracks, all_vias):
|
||||
continue
|
||||
chosen = None
|
||||
pad_min = min(pcbnew.ToMM(pad.GetSizeX()), pcbnew.ToMM(pad.GetSizeY()))
|
||||
width = max(0.15, min(0.30, pad_min * 0.65))
|
||||
for candidate in candidates(pad, fp):
|
||||
x, y = pcbnew.ToMM(candidate.x), pcbnew.ToMM(candidate.y)
|
||||
if not (20.5 < x < 174.5 and 20.5 < y < 109.5):
|
||||
continue
|
||||
if not plane_at(zones_by_net, net, candidate):
|
||||
continue
|
||||
if inside_temporary_keepout(candidate, temporary_keepouts):
|
||||
continue
|
||||
if not clear_of_other_copper(candidate, net, all_pads, all_vias):
|
||||
continue
|
||||
if not clear_track_path(pad.GetPosition(), candidate, net, pad, all_pads, width):
|
||||
continue
|
||||
if not clear_of_tracks(candidate, net, all_tracks):
|
||||
continue
|
||||
if not clear_track_of_tracks(pad.GetPosition(), candidate, net,
|
||||
all_tracks, pad.GetLayer(), width):
|
||||
continue
|
||||
chosen = candidate
|
||||
break
|
||||
if chosen is None:
|
||||
skipped[net] = skipped.get(net, 0) + 1
|
||||
continue
|
||||
all_tracks.append(add_track(board, pad, chosen, width))
|
||||
all_vias.append(add_via(board, pad, chosen))
|
||||
connected[net] = connected.get(net, 0) + 1
|
||||
|
||||
return connected, skipped
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input", nargs="?", type=Path, default=DEFAULT_INPUT)
|
||||
@@ -138,45 +293,7 @@ def main() -> None:
|
||||
board = pcbnew.LoadBoard(str(args.input))
|
||||
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
|
||||
raise RuntimeError("Could not fill planes before stitching")
|
||||
|
||||
zones_by_net: dict[str, list[pcbnew.ZONE]] = {}
|
||||
for zone in board.Zones():
|
||||
if not zone.GetIsRuleArea() and zone.GetNetname() in PLANE_NETS:
|
||||
zones_by_net.setdefault(zone.GetNetname(), []).append(zone)
|
||||
|
||||
all_pads = [pad for fp in board.GetFootprints() for pad in fp.Pads()]
|
||||
all_vias = [item for item in board.GetTracks() if isinstance(item, pcbnew.PCB_VIA)]
|
||||
connected: dict[str, int] = {}
|
||||
skipped: dict[str, int] = {}
|
||||
|
||||
for fp in board.GetFootprints():
|
||||
if fp.GetReference() == "U1":
|
||||
continue
|
||||
for pad in fp.Pads():
|
||||
net = pad.GetNetname()
|
||||
if net not in PLANE_NETS or pad.GetAttribute() != pcbnew.PAD_ATTRIB_SMD:
|
||||
continue
|
||||
chosen = None
|
||||
pad_min = min(pcbnew.ToMM(pad.GetSizeX()), pcbnew.ToMM(pad.GetSizeY()))
|
||||
width = max(0.15, min(0.30, pad_min * 0.65))
|
||||
for candidate in candidates(pad, fp):
|
||||
x, y = pcbnew.ToMM(candidate.x), pcbnew.ToMM(candidate.y)
|
||||
if not (20.5 < x < 174.5 and 20.5 < y < 109.5):
|
||||
continue
|
||||
if not plane_at(zones_by_net, net, candidate):
|
||||
continue
|
||||
if not clear_of_other_copper(candidate, net, all_pads, all_vias):
|
||||
continue
|
||||
if not clear_track_path(pad.GetPosition(), candidate, net, pad, all_pads, width):
|
||||
continue
|
||||
chosen = candidate
|
||||
break
|
||||
if chosen is None:
|
||||
skipped[net] = skipped.get(net, 0) + 1
|
||||
continue
|
||||
add_track(board, pad, chosen, width)
|
||||
all_vias.append(add_via(board, pad, chosen))
|
||||
connected[net] = connected.get(net, 0) + 1
|
||||
connected, skipped = stitch(board)
|
||||
|
||||
if not pcbnew.ZONE_FILLER(board).Fill(board.Zones()):
|
||||
raise RuntimeError("Could not refill planes after stitching")
|
||||
|
||||
Reference in New Issue
Block a user