traceforge-pcb 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1410 @@
|
|
|
1
|
+
"""Dense-package escape: fan-out vias and stubs for fine-pitch parts, placed before routing.
|
|
2
|
+
|
|
3
|
+
A grid router cannot find the way out of a 0.4 or 0.5 mm QFN/QFP ring, or out of a 0.8 mm
|
|
4
|
+
BGA, by itself: a via needs about twice the pad pitch, so neighbouring pins have to leave in
|
|
5
|
+
a staggered pattern. This module plans that pattern with exact geometry (no grid) and writes
|
|
6
|
+
it to the board as ordinary tracks and vias; the router then starts from the via of each
|
|
7
|
+
pin (``attach_fanout``) instead of from the pad.
|
|
8
|
+
|
|
9
|
+
Packages handled (found from the pad geometry, not from names):
|
|
10
|
+
|
|
11
|
+
* **Perimeter parts** (QFN, QFP, fine-pitch board-to-board connectors on two or four sides):
|
|
12
|
+
pads in rows with a long axis pointing away from the part. Every ``m``-th pin (``m`` is the
|
|
13
|
+
fewest rows whose lanes fit, 3 at 0.5 mm pitch) shares a *row* of vias at one distance from
|
|
14
|
+
the part; the stubs of the pins behind them run out through the lanes between the vias of the
|
|
15
|
+
inner rows, in 45 degree jogs, and end in a via of their own row. The lane arithmetic is in
|
|
16
|
+
``_rows_needed``; every stub and via is then checked exactly. Pins the pattern does not
|
|
17
|
+
serve get the nearest legal dogbone; with ``via_in_pad`` a pin whose pad can hold a via gets
|
|
18
|
+
the via in the pad. A part whose pitch is too fine for the rules is left alone (single vias
|
|
19
|
+
would only block the router) and the report says what clearance it would need.
|
|
20
|
+
* **BGAs** (square pads on a grid): the outer two rings break out on the surface through the
|
|
21
|
+
channels between balls to lanes outside the part, where the same row pattern places their
|
|
22
|
+
vias; balls further in get a dogbone on the lattice point between four balls, or a via in the
|
|
23
|
+
pad. Through vias block every layer, so a fully populated 0.8 mm BGA escapes about three
|
|
24
|
+
rings at the fab's rules; pins that do not fit are reported.
|
|
25
|
+
|
|
26
|
+
Sizes come from the board: the project's design rules (the fab profile written by the
|
|
27
|
+
generator), the net classes and the router options. Every stub and via is checked against all
|
|
28
|
+
copper with the clearance rules of the nets involved, the hole clearance and the board edge.
|
|
29
|
+
|
|
30
|
+
Escape layers: each pin's via spans all layers; ``Fanout.layer_hint`` assigns the inner signal
|
|
31
|
+
layers to rings in turn, which the router uses as a soft preference when it leaves the via.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import math
|
|
38
|
+
import os
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from traceforge.fab import FabProfile, get_fab
|
|
43
|
+
from traceforge.kicad import edit
|
|
44
|
+
from traceforge.kicad.pcb import Board, Footprint, Pad, read_board
|
|
45
|
+
from traceforge.kicad.routegeom import Shape, SpatialIndex
|
|
46
|
+
|
|
47
|
+
_EPS = 1e-6
|
|
48
|
+
_MARGIN = 0.005 # KiCad measures round copper as polygons; keep this much beyond the rule
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------- data
|
|
52
|
+
@dataclass
|
|
53
|
+
class Fanout:
|
|
54
|
+
"""One pin's escape: a stub on the pad's layer and (usually) a via at its end."""
|
|
55
|
+
|
|
56
|
+
reference: str
|
|
57
|
+
pad: str
|
|
58
|
+
net: str
|
|
59
|
+
layer: str
|
|
60
|
+
points: list[tuple[float, float]] # stub centreline from the pad centre; empty: via in pad
|
|
61
|
+
width: float
|
|
62
|
+
via: tuple[float, float, float, float] | None # x, y, size, drill
|
|
63
|
+
style: str # dogbone | via-in-pad | surface
|
|
64
|
+
ring: int = 0
|
|
65
|
+
layer_hint: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class EscapeReport:
|
|
70
|
+
packages: list[dict] = field(default_factory=list)
|
|
71
|
+
fanouts: list[Fanout] = field(default_factory=list)
|
|
72
|
+
unplaced: list[tuple[str, str, str]] = field(default_factory=list) # ref, pad, reason
|
|
73
|
+
seconds: float = 0.0
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def layer_hints(self) -> dict[tuple[str, str], str]:
|
|
77
|
+
return {
|
|
78
|
+
("~FANOUT", f"{f.reference}.{f.pad}"): f.layer_hint
|
|
79
|
+
for f in self.fanouts
|
|
80
|
+
if f.layer_hint
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict:
|
|
84
|
+
groups: dict[tuple[str, str], list[str]] = {}
|
|
85
|
+
for ref, pad, why in self.unplaced:
|
|
86
|
+
groups.setdefault((ref, why), []).append(pad)
|
|
87
|
+
return {
|
|
88
|
+
"packages": self.packages,
|
|
89
|
+
"vias": sum(1 for f in self.fanouts if f.via),
|
|
90
|
+
"pins_fanned_out": len(self.fanouts),
|
|
91
|
+
"pins_not_placed": [
|
|
92
|
+
{"part": ref, "pins": pads, "why": why} for (ref, why), pads in groups.items()
|
|
93
|
+
],
|
|
94
|
+
"seconds": round(self.seconds, 2),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class EscapeOptions:
|
|
100
|
+
clearance: float = 0.2
|
|
101
|
+
track_width: float = 0.25
|
|
102
|
+
min_track_width: float = 0.15
|
|
103
|
+
via_size: float = 0.6
|
|
104
|
+
via_drill: float = 0.3
|
|
105
|
+
edge_clearance: float = 0.5
|
|
106
|
+
hole_clearance: float = 0.25
|
|
107
|
+
via_in_pad: bool = False
|
|
108
|
+
fab: FabProfile = field(default_factory=lambda: get_fab("jlcpcb"))
|
|
109
|
+
net_clearance: dict[str, float] = field(default_factory=dict)
|
|
110
|
+
net_width: dict[str, float] = field(default_factory=dict)
|
|
111
|
+
plane_nets: frozenset[str] = frozenset()
|
|
112
|
+
signal_layers: tuple[str, ...] = ()
|
|
113
|
+
nets: tuple[str, ...] | None = None # fan out only these nets
|
|
114
|
+
skip_nets: tuple[str, ...] = ()
|
|
115
|
+
max_pitch_perimeter: float = 0.55 # 0.5 mm parts and finer
|
|
116
|
+
max_pitch_grid: float = 1.0
|
|
117
|
+
min_perimeter_pins: int = 20 # a USB-C receptacle is not a candidate
|
|
118
|
+
|
|
119
|
+
def via_tiers(self) -> list[tuple[float, float]]:
|
|
120
|
+
"""Via sizes to try, largest first: the board's own, then the fab's smallest."""
|
|
121
|
+
fab = self.fab
|
|
122
|
+
small_drill = max(fab.min_drill, 0.0)
|
|
123
|
+
small = max(fab.min_via_diameter, small_drill + 2 * fab.min_annular_ring + 0.01)
|
|
124
|
+
tiers = [(self.via_size, self.via_drill)]
|
|
125
|
+
if small < self.via_size - 0.01:
|
|
126
|
+
tiers.append((round(small, 3), small_drill))
|
|
127
|
+
return tiers
|
|
128
|
+
|
|
129
|
+
def stub_widths(self, net: str) -> list[float]:
|
|
130
|
+
want = max(self.net_width.get(net, 0.0), self.track_width)
|
|
131
|
+
widths = [want, self.track_width, self.min_track_width, self.fab.min_track]
|
|
132
|
+
out: list[float] = []
|
|
133
|
+
for w in widths:
|
|
134
|
+
w = max(w, self.fab.min_track)
|
|
135
|
+
if not any(abs(w - o) < 1e-9 for o in out):
|
|
136
|
+
out.append(w)
|
|
137
|
+
return sorted(out, reverse=True)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------- project rules
|
|
141
|
+
def _project(pcb: Path) -> dict:
|
|
142
|
+
try:
|
|
143
|
+
return json.loads(pcb.with_suffix(".kicad_pro").read_text(encoding="utf-8"))
|
|
144
|
+
except (OSError, ValueError):
|
|
145
|
+
return {}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def options_for(pcb: Path, route_options, copper_layers: list[str]) -> EscapeOptions:
|
|
149
|
+
"""Escape options from the router options and the project's design rules."""
|
|
150
|
+
data = _project(pcb)
|
|
151
|
+
rules = ((data.get("board") or {}).get("design_settings") or {}).get("rules") or {}
|
|
152
|
+
base = get_fab(getattr(route_options, "fab", None))
|
|
153
|
+
fab = FabProfile(
|
|
154
|
+
**{
|
|
155
|
+
**base.to_dict(),
|
|
156
|
+
"min_track": float(rules.get("min_track_width") or base.min_track),
|
|
157
|
+
"min_clearance": float(rules.get("min_clearance") or base.min_clearance),
|
|
158
|
+
"min_via_diameter": float(rules.get("min_via_diameter") or base.min_via_diameter),
|
|
159
|
+
"min_drill": float(rules.get("min_through_hole_diameter") or base.min_drill),
|
|
160
|
+
"min_annular_ring": float(rules.get("min_via_annular_width") or base.min_annular_ring),
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
settings = data.get("net_settings") or {}
|
|
164
|
+
classes = {c.get("name"): c for c in settings.get("classes", [])}
|
|
165
|
+
clearance: dict[str, float] = {}
|
|
166
|
+
width: dict[str, float] = {}
|
|
167
|
+
for net, cls in (settings.get("netclass_assignments") or {}).items():
|
|
168
|
+
info = classes.get(cls) or {}
|
|
169
|
+
if info.get("clearance"):
|
|
170
|
+
clearance[net] = float(info["clearance"])
|
|
171
|
+
if info.get("track_width"):
|
|
172
|
+
width[net] = float(info["track_width"])
|
|
173
|
+
roles = ((data.get("traceforge") or {}).get("stackup") or {}).get("roles") or {}
|
|
174
|
+
planes = ((data.get("traceforge") or {}).get("stackup") or {}).get("planes") or {}
|
|
175
|
+
plane_layers = {n for n, r in roles.items() if r == "plane"} | set(planes)
|
|
176
|
+
signal = tuple(n for n in copper_layers if n not in plane_layers)
|
|
177
|
+
layers = getattr(route_options, "layers", None)
|
|
178
|
+
if layers:
|
|
179
|
+
signal = tuple(n for n in layers if n in copper_layers)
|
|
180
|
+
plane_nets = set(getattr(route_options, "plane_nets", []) or []) | {
|
|
181
|
+
str(n) for n in planes.values()
|
|
182
|
+
}
|
|
183
|
+
assigned = getattr(route_options, "plane_assign", None)
|
|
184
|
+
if assigned:
|
|
185
|
+
plane_nets |= set(assigned)
|
|
186
|
+
return EscapeOptions(
|
|
187
|
+
clearance=route_options.clearance,
|
|
188
|
+
track_width=route_options.track_width,
|
|
189
|
+
min_track_width=min(route_options.min_track_width, route_options.track_width),
|
|
190
|
+
via_size=route_options.via_size,
|
|
191
|
+
via_drill=route_options.via_drill,
|
|
192
|
+
edge_clearance=route_options.edge_clearance,
|
|
193
|
+
hole_clearance=route_options.hole_clearance,
|
|
194
|
+
via_in_pad=route_options.via_in_pad,
|
|
195
|
+
fab=fab,
|
|
196
|
+
net_clearance=clearance,
|
|
197
|
+
net_width=width,
|
|
198
|
+
plane_nets=frozenset(plane_nets),
|
|
199
|
+
signal_layers=signal,
|
|
200
|
+
nets=tuple(route_options.nets) if route_options.nets is not None else None,
|
|
201
|
+
skip_nets=tuple(route_options.skip_nets),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --------------------------------------------------------------------------- world
|
|
206
|
+
def _pad_shape(pad: Pad, net: int, layers: int) -> Shape:
|
|
207
|
+
hw, hh = pad.width / 2, pad.height / 2
|
|
208
|
+
if pad.shape == "circle":
|
|
209
|
+
r = min(hw, hh)
|
|
210
|
+
return Shape.capsule(pad.x, pad.y, pad.x, pad.y, r, net, layers, None)
|
|
211
|
+
if pad.shape == "oval":
|
|
212
|
+
if hw >= hh:
|
|
213
|
+
return Shape.capsule(pad.x - hw + hh, pad.y, pad.x + hw - hh, pad.y, hh, net, layers)
|
|
214
|
+
return Shape.capsule(pad.x, pad.y - hh + hw, pad.x, pad.y + hh - hw, hw, net, layers)
|
|
215
|
+
return Shape.rect(pad.x - hw, pad.y - hh, pad.x + hw, pad.y + hh, net, layers)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class _World:
|
|
219
|
+
"""All copper on the board as exact shapes, plus what the plan adds."""
|
|
220
|
+
|
|
221
|
+
def __init__(self, board: Board, opt: EscapeOptions):
|
|
222
|
+
self.opt = opt
|
|
223
|
+
self.board = board
|
|
224
|
+
self.layers = list(board.copper_layers)
|
|
225
|
+
self.bit = {name: 1 << i for i, name in enumerate(self.layers)}
|
|
226
|
+
self.all = (1 << len(self.layers)) - 1
|
|
227
|
+
self.ids: dict[str, int] = {}
|
|
228
|
+
self.index = SpatialIndex()
|
|
229
|
+
self.names: dict[int, str] = {}
|
|
230
|
+
self.holes: dict[tuple[int, int], list[tuple[float, float, float]]] = {}
|
|
231
|
+
self.hole_r: dict[int, float] = {} # shape id -> drill radius, for vias
|
|
232
|
+
self.log: list[tuple] = [] # what the plan added, to roll back a trial
|
|
233
|
+
self.outline = board.outline
|
|
234
|
+
self.geom = board.edge_geometry
|
|
235
|
+
self.max_clear = max([opt.clearance, *opt.net_clearance.values()])
|
|
236
|
+
hole_extra = max(0.0, opt.hole_clearance - opt.clearance)
|
|
237
|
+
for fp in board.footprints:
|
|
238
|
+
for pad in fp.pads:
|
|
239
|
+
nid = self.nid(pad.net)
|
|
240
|
+
if pad.kind == "np_thru_hole":
|
|
241
|
+
r = max(max(pad.width, pad.height) / 2, pad.drill / 2 + hole_extra)
|
|
242
|
+
self.index.add(Shape.capsule(pad.x, pad.y, pad.x, pad.y, r, 0, self.all))
|
|
243
|
+
else:
|
|
244
|
+
mask = 0
|
|
245
|
+
for name, bit in self.bit.items():
|
|
246
|
+
if pad.on_layer(name):
|
|
247
|
+
mask |= bit
|
|
248
|
+
if mask:
|
|
249
|
+
self.index.add(_pad_shape(pad, nid, mask))
|
|
250
|
+
if pad.drill:
|
|
251
|
+
self._add_hole(pad.x, pad.y, pad.drill)
|
|
252
|
+
for track in board.tracks:
|
|
253
|
+
bit = self.bit.get(track.layer)
|
|
254
|
+
if bit:
|
|
255
|
+
self.index.add(
|
|
256
|
+
Shape.capsule(
|
|
257
|
+
track.x1,
|
|
258
|
+
track.y1,
|
|
259
|
+
track.x2,
|
|
260
|
+
track.y2,
|
|
261
|
+
track.width / 2,
|
|
262
|
+
self.nid(track.net),
|
|
263
|
+
bit,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
for via in board.vias:
|
|
267
|
+
self._add_via(via.x, via.y, via.size, via.drill, self.nid(via.net))
|
|
268
|
+
|
|
269
|
+
def nid(self, net: str) -> int:
|
|
270
|
+
if not net:
|
|
271
|
+
return 0
|
|
272
|
+
if net not in self.ids:
|
|
273
|
+
self.ids[net] = len(self.ids) + 1
|
|
274
|
+
self.names[self.ids[net]] = net
|
|
275
|
+
return self.ids[net]
|
|
276
|
+
|
|
277
|
+
def _add_hole(self, x: float, y: float, drill: float) -> None:
|
|
278
|
+
key = (int(x // 2), int(y // 2))
|
|
279
|
+
self.holes.setdefault(key, []).append((x, y, drill))
|
|
280
|
+
self.log.append(("hole", (key, (x, y, drill))))
|
|
281
|
+
|
|
282
|
+
def _add_shape(self, shape: Shape) -> None:
|
|
283
|
+
self.index.add(shape)
|
|
284
|
+
self.log.append(("shape", shape))
|
|
285
|
+
|
|
286
|
+
def checkpoint(self) -> int:
|
|
287
|
+
return len(self.log)
|
|
288
|
+
|
|
289
|
+
def rollback(self, mark: int) -> None:
|
|
290
|
+
while len(self.log) > mark:
|
|
291
|
+
kind, item = self.log.pop()
|
|
292
|
+
if kind == "shape":
|
|
293
|
+
self.index.remove(item)
|
|
294
|
+
self.hole_r.pop(id(item), None)
|
|
295
|
+
else:
|
|
296
|
+
key, tup = item
|
|
297
|
+
self.holes[key].remove(tup)
|
|
298
|
+
|
|
299
|
+
def unlog(self, entries: list) -> None:
|
|
300
|
+
"""Take specific earlier additions back out (not just the latest ones)."""
|
|
301
|
+
for entry in entries:
|
|
302
|
+
for i in range(len(self.log) - 1, -1, -1):
|
|
303
|
+
if self.log[i] is entry:
|
|
304
|
+
del self.log[i]
|
|
305
|
+
break
|
|
306
|
+
kind, item = entry
|
|
307
|
+
if kind == "shape":
|
|
308
|
+
self.index.remove(item)
|
|
309
|
+
self.hole_r.pop(id(item), None)
|
|
310
|
+
else:
|
|
311
|
+
key, tup = item
|
|
312
|
+
self.holes[key].remove(tup)
|
|
313
|
+
|
|
314
|
+
def clear(self, a: str, b: str) -> float:
|
|
315
|
+
nc = self.opt.net_clearance
|
|
316
|
+
return max(self.opt.clearance, nc.get(a, 0.0), nc.get(b, 0.0)) + _MARGIN
|
|
317
|
+
|
|
318
|
+
# -- adding
|
|
319
|
+
def _add_via(self, x, y, size, drill, nid) -> None:
|
|
320
|
+
shape = Shape.capsule(x, y, x, y, size / 2, nid, self.all)
|
|
321
|
+
self.hole_r[id(shape)] = drill / 2
|
|
322
|
+
self._add_shape(shape)
|
|
323
|
+
self._add_hole(x, y, drill)
|
|
324
|
+
|
|
325
|
+
def add_via(self, x: float, y: float, size: float, drill: float, net: str) -> None:
|
|
326
|
+
self._add_via(x, y, size, drill, self.nid(net))
|
|
327
|
+
|
|
328
|
+
def add_poly(self, points, width: float, layer: str, net: str) -> None:
|
|
329
|
+
for (ax, ay), (bx, by) in zip(points, points[1:]):
|
|
330
|
+
self._add_shape(
|
|
331
|
+
Shape.capsule(ax, ay, bx, by, width / 2, self.nid(net), self.bit[layer])
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# -- queries
|
|
335
|
+
def edge_ok(self, x: float, y: float, margin: float) -> bool:
|
|
336
|
+
return self.geom.room(x, y) >= margin - _EPS
|
|
337
|
+
|
|
338
|
+
def via_ok(self, x: float, y: float, size: float, drill: float, net: str) -> bool:
|
|
339
|
+
opt = self.opt
|
|
340
|
+
if not self.edge_ok(x, y, opt.edge_clearance + size / 2):
|
|
341
|
+
return False
|
|
342
|
+
nid = self.ids.get(net, 0)
|
|
343
|
+
names = self.names
|
|
344
|
+
reach = size / 2 + self.max_clear + opt.hole_clearance + 0.2
|
|
345
|
+
for shape in self.index.near((x, y, x, y), reach):
|
|
346
|
+
if nid and shape.net == nid:
|
|
347
|
+
continue
|
|
348
|
+
other = names.get(shape.net, "")
|
|
349
|
+
d = shape.dist_point(x, y)
|
|
350
|
+
need = max(self.clear(net, other) + size / 2, drill / 2 + opt.hole_clearance)
|
|
351
|
+
if d < need - _EPS:
|
|
352
|
+
return False
|
|
353
|
+
drill_r = self.hole_r.get(id(shape))
|
|
354
|
+
if (
|
|
355
|
+
drill_r is not None
|
|
356
|
+
and d + shape.c[4] - drill_r - size / 2 < opt.hole_clearance - _EPS
|
|
357
|
+
):
|
|
358
|
+
return False
|
|
359
|
+
cx, cy = int(x // 2), int(y // 2)
|
|
360
|
+
for gx in (cx - 1, cx, cx + 1):
|
|
361
|
+
for gy in (cy - 1, cy, cy + 1):
|
|
362
|
+
for hx, hy, hd in self.holes.get((gx, gy), ()):
|
|
363
|
+
if math.hypot(hx - x, hy - y) < (hd + drill) / 2 + opt.hole_clearance - _EPS:
|
|
364
|
+
return False
|
|
365
|
+
return True
|
|
366
|
+
|
|
367
|
+
def seg_ok(self, ax, ay, bx, by, width: float, layer: str, net: str) -> bool:
|
|
368
|
+
opt = self.opt
|
|
369
|
+
half = width / 2
|
|
370
|
+
margin = opt.edge_clearance + half
|
|
371
|
+
if not (self.edge_ok(ax, ay, margin) and self.edge_ok(bx, by, margin)):
|
|
372
|
+
return False
|
|
373
|
+
bit = self.bit[layer]
|
|
374
|
+
nid = self.ids.get(net, 0)
|
|
375
|
+
names = self.names
|
|
376
|
+
reach = half + self.max_clear + opt.hole_clearance + 0.2
|
|
377
|
+
bbox = (min(ax, bx), min(ay, by), max(ax, bx), max(ay, by))
|
|
378
|
+
for shape in self.index.near(bbox, reach):
|
|
379
|
+
if nid and shape.net == nid:
|
|
380
|
+
continue
|
|
381
|
+
other = names.get(shape.net, "")
|
|
382
|
+
need = 0.0
|
|
383
|
+
if shape.layers & bit:
|
|
384
|
+
need = self.clear(net, other) + half
|
|
385
|
+
dist = shape.dist_seg(ax, ay, bx, by)
|
|
386
|
+
drill_r = self.hole_r.get(id(shape))
|
|
387
|
+
if drill_r is not None: # a via's hole: edge of the hole to the track's edge
|
|
388
|
+
if dist + shape.c[4] - drill_r - half < opt.hole_clearance - _EPS:
|
|
389
|
+
return False
|
|
390
|
+
if need and dist < need - _EPS:
|
|
391
|
+
return False
|
|
392
|
+
return True
|
|
393
|
+
|
|
394
|
+
def poly_ok(self, points, width: float, layer: str, net: str) -> bool:
|
|
395
|
+
return all(
|
|
396
|
+
self.seg_ok(a[0], a[1], b[0], b[1], width, layer, net)
|
|
397
|
+
for a, b in zip(points, points[1:])
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ------------------------------------------------------------------- detection
|
|
402
|
+
@dataclass
|
|
403
|
+
class _Package:
|
|
404
|
+
footprint: Footprint
|
|
405
|
+
kind: str # perimeter | grid
|
|
406
|
+
pitch: float
|
|
407
|
+
pads: list[Pad] # netted ring/grid pads, candidates for fan-out
|
|
408
|
+
layer: str
|
|
409
|
+
sides: dict[int, tuple[float, float]] = field(default_factory=dict) # id(pad) -> outward
|
|
410
|
+
ring: dict[int, int] = field(default_factory=dict)
|
|
411
|
+
lattice: tuple[list[float], list[float]] | None = None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _pitch(pads: list[Pad]) -> float:
|
|
415
|
+
"""Most common nearest-neighbour distance between pad centres."""
|
|
416
|
+
if len(pads) < 2:
|
|
417
|
+
return 0.0
|
|
418
|
+
counts: dict[float, int] = {}
|
|
419
|
+
for a in pads:
|
|
420
|
+
best = min(math.hypot(a.x - b.x, a.y - b.y) for b in pads if b is not a)
|
|
421
|
+
if best > 1e-3:
|
|
422
|
+
key = round(best / 0.01) * 0.01
|
|
423
|
+
counts[key] = counts.get(key, 0) + 1
|
|
424
|
+
if not counts:
|
|
425
|
+
return 0.0
|
|
426
|
+
return max(counts.items(), key=lambda kv: (kv[1], -kv[0]))[0]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _cluster(values: list[float], tol: float = 0.05) -> list[float]:
|
|
430
|
+
out: list[float] = []
|
|
431
|
+
for v in sorted(values):
|
|
432
|
+
if not out or v - out[-1] > tol:
|
|
433
|
+
out.append(v)
|
|
434
|
+
return out
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def detect_packages(board: Board, opt: EscapeOptions) -> list[_Package]:
|
|
438
|
+
found: list[_Package] = []
|
|
439
|
+
for fp in board.footprints:
|
|
440
|
+
smd = [
|
|
441
|
+
p
|
|
442
|
+
for p in fp.pads
|
|
443
|
+
if p.kind == "smd"
|
|
444
|
+
and p.width > 0
|
|
445
|
+
and p.height > 0
|
|
446
|
+
and any(name.endswith(".Cu") for name in p.layers) # not a paste-only aperture
|
|
447
|
+
]
|
|
448
|
+
if len(smd) < 12:
|
|
449
|
+
continue
|
|
450
|
+
layer = "B.Cu" if fp.layer.startswith("B") else "F.Cu"
|
|
451
|
+
pitch = _pitch(smd)
|
|
452
|
+
if pitch <= 0:
|
|
453
|
+
continue
|
|
454
|
+
ring = [
|
|
455
|
+
p
|
|
456
|
+
for p in smd
|
|
457
|
+
if max(p.width, p.height) >= 1.6 * min(p.width, p.height)
|
|
458
|
+
and min(p.width, p.height) <= pitch * 0.9
|
|
459
|
+
]
|
|
460
|
+
grid = [
|
|
461
|
+
p
|
|
462
|
+
for p in smd
|
|
463
|
+
if max(p.width, p.height) <= 1.3 * min(p.width, p.height)
|
|
464
|
+
and max(p.width, p.height) < pitch
|
|
465
|
+
]
|
|
466
|
+
if len(ring) >= opt.min_perimeter_pins and pitch <= opt.max_pitch_perimeter:
|
|
467
|
+
pkg = _perimeter(fp, ring, pitch, layer)
|
|
468
|
+
if pkg is not None:
|
|
469
|
+
found.append(pkg)
|
|
470
|
+
elif len(grid) >= 16 and pitch <= opt.max_pitch_grid:
|
|
471
|
+
pkg = _grid(fp, grid, pitch, layer)
|
|
472
|
+
if pkg is not None:
|
|
473
|
+
found.append(pkg)
|
|
474
|
+
return found
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _perimeter(fp: Footprint, ring: list[Pad], pitch: float, layer: str) -> _Package | None:
|
|
478
|
+
cx = sum(p.x for p in ring) / len(ring)
|
|
479
|
+
cy = sum(p.y for p in ring) / len(ring)
|
|
480
|
+
sides: dict[int, tuple[float, float]] = {}
|
|
481
|
+
for p in ring:
|
|
482
|
+
if p.width >= p.height:
|
|
483
|
+
sides[id(p)] = (1.0 if p.x >= cx else -1.0, 0.0)
|
|
484
|
+
else:
|
|
485
|
+
sides[id(p)] = (0.0, 1.0 if p.y >= cy else -1.0)
|
|
486
|
+
if len(set(sides.values())) < 2:
|
|
487
|
+
return None
|
|
488
|
+
pads = [p for p in ring if p.net]
|
|
489
|
+
return _Package(fp, "perimeter", pitch, pads, layer, sides)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _grid(fp: Footprint, grid: list[Pad], pitch: float, layer: str) -> _Package | None:
|
|
493
|
+
xs = _cluster([p.x for p in grid])
|
|
494
|
+
ys = _cluster([p.y for p in grid])
|
|
495
|
+
if len(xs) < 4 or len(ys) < 4:
|
|
496
|
+
return None
|
|
497
|
+
pkg = _Package(fp, "grid", pitch, [p for p in grid if p.net], layer, lattice=(xs, ys))
|
|
498
|
+
|
|
499
|
+
def index(values: list[float], v: float) -> int:
|
|
500
|
+
return min(range(len(values)), key=lambda i: abs(values[i] - v))
|
|
501
|
+
|
|
502
|
+
nx, ny = len(xs), len(ys)
|
|
503
|
+
for p in grid:
|
|
504
|
+
ix, iy = index(xs, p.x), index(ys, p.y)
|
|
505
|
+
pkg.ring[id(p)] = min(ix, iy, nx - 1 - ix, ny - 1 - iy)
|
|
506
|
+
return pkg
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# ------------------------------------------------------------------- planning
|
|
510
|
+
def _pad_connected(board: Board, fp: Footprint, pad: Pad) -> bool:
|
|
511
|
+
for t in board.tracks:
|
|
512
|
+
if t.net == pad.net and pad.on_layer(t.layer):
|
|
513
|
+
if pad.contains(t.x1, t.y1, 0.005) or pad.contains(t.x2, t.y2, 0.005):
|
|
514
|
+
return True
|
|
515
|
+
for v in board.vias:
|
|
516
|
+
if v.net == pad.net and pad.contains(v.x, v.y, 0.005):
|
|
517
|
+
return True
|
|
518
|
+
return False
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _wanted_pads(board: Board, pkg: _Package, opt: EscapeOptions) -> list[Pad]:
|
|
522
|
+
net_pads: dict[str, int] = {}
|
|
523
|
+
for fp in board.footprints:
|
|
524
|
+
for pad in fp.pads:
|
|
525
|
+
if pad.net:
|
|
526
|
+
net_pads[pad.net] = net_pads.get(pad.net, 0) + 1
|
|
527
|
+
out = []
|
|
528
|
+
for pad in pkg.pads:
|
|
529
|
+
if opt.nets is not None and pad.net not in opt.nets:
|
|
530
|
+
continue
|
|
531
|
+
if pad.net in opt.skip_nets:
|
|
532
|
+
continue
|
|
533
|
+
if net_pads.get(pad.net, 0) < 2:
|
|
534
|
+
continue
|
|
535
|
+
if _pad_connected(board, pkg.footprint, pad):
|
|
536
|
+
continue
|
|
537
|
+
out.append(pad)
|
|
538
|
+
return out
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _hint(opt: EscapeOptions, pad_layer: str, ring: int) -> str | None:
|
|
542
|
+
"""Layer a ring should leave on: the inner signal layers in turn, the far outer layer last."""
|
|
543
|
+
others = [n for n in opt.signal_layers if n != pad_layer]
|
|
544
|
+
order = [n for n in others if n not in ("F.Cu", "B.Cu")] + [
|
|
545
|
+
n for n in others if n in ("F.Cu", "B.Cu")
|
|
546
|
+
]
|
|
547
|
+
return order[ring % len(order)] if order else None
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@dataclass
|
|
551
|
+
class _Term:
|
|
552
|
+
"""Where a pin's pattern stub starts: the pad itself, or the end of a breakout lane."""
|
|
553
|
+
|
|
554
|
+
pad: Pad
|
|
555
|
+
x: float
|
|
556
|
+
y: float
|
|
557
|
+
e: float # half-length of the copper at (x, y) along the outward axis
|
|
558
|
+
lat: float # coordinate across the outward axis
|
|
559
|
+
lead: list[tuple[float, float]] # points from the pad centre to (x, y), both included
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class _Planner:
|
|
563
|
+
def __init__(self, board: Board, opt: EscapeOptions):
|
|
564
|
+
self.logs: dict[int, list] = {}
|
|
565
|
+
self.board = board
|
|
566
|
+
self.opt = opt
|
|
567
|
+
self.note = "" # why pins were left out, for the report
|
|
568
|
+
|
|
569
|
+
def _why_not(self, pitch: float) -> str:
|
|
570
|
+
"""What the rules would have to be for the row pattern to fit at this pitch."""
|
|
571
|
+
opt = self.opt
|
|
572
|
+
fab = opt.fab
|
|
573
|
+
via = max(fab.min_via_diameter, fab.min_drill + 2 * fab.min_annular_ring + 0.01)
|
|
574
|
+
if _rows_needed(pitch, via, opt.clearance + _MARGIN, fab.min_track) is not None:
|
|
575
|
+
return "other copper near the part leaves no room for the via rows"
|
|
576
|
+
c = opt.clearance
|
|
577
|
+
while c > fab.min_clearance - 1e-9:
|
|
578
|
+
if _rows_needed(pitch, via, c + _MARGIN, fab.min_track) is not None:
|
|
579
|
+
return (
|
|
580
|
+
f"pitch {pitch:g} mm needs about {c:.3f} mm clearance and {via:g} mm vias "
|
|
581
|
+
f"(rules here: {opt.clearance:g} mm); set that clearance on the part's nets"
|
|
582
|
+
)
|
|
583
|
+
c -= 0.005
|
|
584
|
+
return (
|
|
585
|
+
f"pitch {pitch:g} mm is too fine for through vias at the fab's minimum rules; "
|
|
586
|
+
"it needs via in pad or microvias"
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
# -------------------------------------------------------------- perimeter
|
|
590
|
+
def perimeter(self, pkg: _Package, pads: list[Pad]) -> tuple[list[Fanout], list[Pad]]:
|
|
591
|
+
"""Fan-out of one perimeter part: (fanouts, pads that did not fit)."""
|
|
592
|
+
world = _World(self.board, self.opt)
|
|
593
|
+
sides: dict[tuple[float, float], list[Pad]] = {}
|
|
594
|
+
for pad in pads:
|
|
595
|
+
sides.setdefault(pkg.sides[id(pad)], []).append(pad)
|
|
596
|
+
placed: list[Fanout] = []
|
|
597
|
+
left: list[Pad] = []
|
|
598
|
+
for u, items in sides.items():
|
|
599
|
+
w_axis = (-u[1], u[0])
|
|
600
|
+
terms = [
|
|
601
|
+
_Term(
|
|
602
|
+
p,
|
|
603
|
+
p.x,
|
|
604
|
+
p.y,
|
|
605
|
+
(p.width if u[0] else p.height) / 2,
|
|
606
|
+
p.x * w_axis[0] + p.y * w_axis[1],
|
|
607
|
+
[(round(p.x, 4), round(p.y, 4))],
|
|
608
|
+
)
|
|
609
|
+
for p in items
|
|
610
|
+
]
|
|
611
|
+
terms.sort(key=lambda t: t.lat)
|
|
612
|
+
pool: list[dict[str, Fanout]] = []
|
|
613
|
+
got = self._staggered(world, pkg, u, terms, pkg.pitch, pool)
|
|
614
|
+
if got and len(got) < len(terms):
|
|
615
|
+
got = self._best_with_gaps(world, pkg, u, terms, pkg.pitch, pool)
|
|
616
|
+
for fan in got.values():
|
|
617
|
+
self._commit(world, fan)
|
|
618
|
+
if got:
|
|
619
|
+
self._fill_gaps(world, pkg, u, terms, pkg.pitch, got)
|
|
620
|
+
if not got and not self.opt.via_in_pad:
|
|
621
|
+
# No row pattern fits at these rules; single vias would only block the router.
|
|
622
|
+
left.extend(items)
|
|
623
|
+
self.note = self._why_not(pkg.pitch)
|
|
624
|
+
continue
|
|
625
|
+
missed: list[Pad] = []
|
|
626
|
+
for pad in items:
|
|
627
|
+
if pad.number in got:
|
|
628
|
+
continue
|
|
629
|
+
fan = self._dogbone(world, pkg, pad)
|
|
630
|
+
if fan is None:
|
|
631
|
+
missed.append(pad)
|
|
632
|
+
else:
|
|
633
|
+
self._commit(world, fan)
|
|
634
|
+
got[pad.number] = fan
|
|
635
|
+
for pad in missed:
|
|
636
|
+
if not self._replan(world, pkg, u, terms, got, pad):
|
|
637
|
+
left.append(pad)
|
|
638
|
+
placed.extend(got.values())
|
|
639
|
+
return placed, left
|
|
640
|
+
|
|
641
|
+
def _staggered(
|
|
642
|
+
self, world, pkg, u, terms, pitch: float, collect: list | None = None
|
|
643
|
+
) -> dict[str, Fanout]:
|
|
644
|
+
"""The row pattern: every ``m``-th pin shares a row of vias; the stubs of the outer rows
|
|
645
|
+
run through the lanes between the vias of the inner ones (see ``_lane_polyline``)."""
|
|
646
|
+
opt = self.opt
|
|
647
|
+
best: dict[str, Fanout] = {}
|
|
648
|
+
for size, drill in opt.via_tiers():
|
|
649
|
+
for width in opt.stub_widths(""):
|
|
650
|
+
m = _rows_needed(pitch, size, opt.clearance + _MARGIN, width)
|
|
651
|
+
if m is None:
|
|
652
|
+
continue
|
|
653
|
+
for offset in range(m):
|
|
654
|
+
for d0 in _D0:
|
|
655
|
+
for h in _ROW_GAP:
|
|
656
|
+
for bf in _ENTRY:
|
|
657
|
+
params = (m, offset, d0, h, bf, size, drill, width)
|
|
658
|
+
got = self._try_pattern(world, pkg, u, terms, pitch, params)
|
|
659
|
+
if collect is not None and got:
|
|
660
|
+
collect.append(got)
|
|
661
|
+
if len(got) > len(best):
|
|
662
|
+
best = got
|
|
663
|
+
if len(best) == len(terms):
|
|
664
|
+
return best
|
|
665
|
+
if len(best) >= 0.9 * len(terms):
|
|
666
|
+
break
|
|
667
|
+
return best
|
|
668
|
+
|
|
669
|
+
def _replan(self, world, pkg, u, terms, got: dict[str, Fanout], pad: Pad) -> bool:
|
|
670
|
+
"""A pin the pattern and the single-pin search both missed: take out the fan-outs of
|
|
671
|
+
the pins around it, place it first, then place those pins again one by one. Kept only
|
|
672
|
+
if all of them fit; otherwise the board of copper goes back as it was."""
|
|
673
|
+
pitch = pkg.pitch
|
|
674
|
+
by_number = {t.pad.number: t for t in terms}
|
|
675
|
+
me = by_number.get(pad.number)
|
|
676
|
+
if me is None:
|
|
677
|
+
return False
|
|
678
|
+
near = [
|
|
679
|
+
n
|
|
680
|
+
for n, f in got.items()
|
|
681
|
+
if n in by_number and abs(by_number[n].lat - me.lat) <= 5.01 * pitch
|
|
682
|
+
]
|
|
683
|
+
near.sort(key=lambda n: abs(by_number[n].lat - me.lat))
|
|
684
|
+
old = {n: got[n] for n in near}
|
|
685
|
+
for n in near:
|
|
686
|
+
self._uncommit(world, old[n])
|
|
687
|
+
pads = {n: by_number[n].pad for n in near}
|
|
688
|
+
orders = [
|
|
689
|
+
[pad, *[pads[n] for n in near]],
|
|
690
|
+
[pad, *[pads[n] for n in reversed(near)]],
|
|
691
|
+
[*sorted([pad, *pads.values()], key=lambda q: by_number[q.number].lat)],
|
|
692
|
+
[*sorted([pad, *pads.values()], key=lambda q: -by_number[q.number].lat)],
|
|
693
|
+
[*[pads[n] for n in near], pad],
|
|
694
|
+
]
|
|
695
|
+
window = [by_number[n] for n in near]
|
|
696
|
+
window.sort(key=lambda t: t.lat)
|
|
697
|
+
first = self._dogbone(world, pkg, pad)
|
|
698
|
+
if first is not None:
|
|
699
|
+
# The stuck pin first, then the row pattern again for the pins around it.
|
|
700
|
+
self._commit(world, first)
|
|
701
|
+
fresh = {pad.number: first}
|
|
702
|
+
pool = self._staggered(world, pkg, u, window, pitch)
|
|
703
|
+
for fan in pool.values():
|
|
704
|
+
self._commit(world, fan)
|
|
705
|
+
fresh.update(pool)
|
|
706
|
+
ok = True
|
|
707
|
+
for t in window:
|
|
708
|
+
if t.pad.number in fresh:
|
|
709
|
+
continue
|
|
710
|
+
fan = self._dogbone(world, pkg, t.pad)
|
|
711
|
+
if fan is None:
|
|
712
|
+
ok = False
|
|
713
|
+
break
|
|
714
|
+
self._commit(world, fan)
|
|
715
|
+
fresh[t.pad.number] = fan
|
|
716
|
+
if ok:
|
|
717
|
+
got.update(fresh)
|
|
718
|
+
return True
|
|
719
|
+
for fan in fresh.values():
|
|
720
|
+
self._uncommit(world, fan)
|
|
721
|
+
for order in orders:
|
|
722
|
+
fresh = {}
|
|
723
|
+
ok = True
|
|
724
|
+
for target in order:
|
|
725
|
+
fan = self._dogbone(world, pkg, target)
|
|
726
|
+
if fan is None:
|
|
727
|
+
ok = False
|
|
728
|
+
break
|
|
729
|
+
self._commit(world, fan)
|
|
730
|
+
fresh[target.number] = fan
|
|
731
|
+
if ok:
|
|
732
|
+
got.update(fresh)
|
|
733
|
+
return True
|
|
734
|
+
for fan in fresh.values():
|
|
735
|
+
self._uncommit(world, fan)
|
|
736
|
+
for n in near:
|
|
737
|
+
self._commit(world, old[n])
|
|
738
|
+
return False
|
|
739
|
+
|
|
740
|
+
def _best_with_gaps(self, world, pkg, u, terms, pitch: float, pool) -> dict[str, Fanout]:
|
|
741
|
+
"""Of the row patterns that placed the most pins, the one that places the most once
|
|
742
|
+
the missed pins have been filled in (a different pattern can leave room for them)."""
|
|
743
|
+
seen: set[tuple] = set()
|
|
744
|
+
ranked = []
|
|
745
|
+
for cand in sorted(pool, key=len, reverse=True):
|
|
746
|
+
key = tuple(sorted((n, tuple(f.via[:2])) for n, f in cand.items()))
|
|
747
|
+
if key in seen:
|
|
748
|
+
continue
|
|
749
|
+
seen.add(key)
|
|
750
|
+
ranked.append(cand)
|
|
751
|
+
if len(ranked) >= 8:
|
|
752
|
+
break
|
|
753
|
+
best, best_n = ranked[0], -1
|
|
754
|
+
for cand in ranked:
|
|
755
|
+
mark = world.checkpoint()
|
|
756
|
+
for fan in cand.values():
|
|
757
|
+
self._commit(world, fan)
|
|
758
|
+
trial = dict(cand)
|
|
759
|
+
self._fill_gaps(world, pkg, u, terms, pitch, trial)
|
|
760
|
+
world.rollback(mark)
|
|
761
|
+
if len(trial) > best_n:
|
|
762
|
+
best, best_n = cand, len(trial)
|
|
763
|
+
if best_n == len(terms):
|
|
764
|
+
break
|
|
765
|
+
return dict(best)
|
|
766
|
+
|
|
767
|
+
def _fill_gaps(self, world, pkg, u, terms, pitch: float, got: dict[str, Fanout]) -> None:
|
|
768
|
+
"""Pins the best row pattern missed: try every pattern again for just those pins,
|
|
769
|
+
against the copper already placed (a pin that is boxed in by its neighbours' stubs
|
|
770
|
+
often fits under a different row offset or entry)."""
|
|
771
|
+
opt = self.opt
|
|
772
|
+
for _ in range(len(terms)):
|
|
773
|
+
missing = {t.pad.number for t in terms if t.pad.number not in got}
|
|
774
|
+
if not missing:
|
|
775
|
+
return
|
|
776
|
+
gained: dict[str, Fanout] = {}
|
|
777
|
+
for size, drill in opt.via_tiers():
|
|
778
|
+
for width in opt.stub_widths(""):
|
|
779
|
+
m = _rows_needed(pitch, size, opt.clearance + _MARGIN, width)
|
|
780
|
+
if m is None:
|
|
781
|
+
continue
|
|
782
|
+
for offset in range(m):
|
|
783
|
+
for d0 in (*_D0, *_D0_FAR):
|
|
784
|
+
for h in (*_ROW_GAP, *_ROW_GAP_FAR):
|
|
785
|
+
for bf in _ENTRY:
|
|
786
|
+
params = (m, offset, d0, h, bf, size, drill, width)
|
|
787
|
+
gained = self._try_pattern(
|
|
788
|
+
world, pkg, u, terms, pitch, params, only=missing
|
|
789
|
+
)
|
|
790
|
+
if gained:
|
|
791
|
+
break
|
|
792
|
+
if gained:
|
|
793
|
+
break
|
|
794
|
+
if gained:
|
|
795
|
+
break
|
|
796
|
+
if gained:
|
|
797
|
+
break
|
|
798
|
+
if gained:
|
|
799
|
+
break
|
|
800
|
+
if gained:
|
|
801
|
+
break
|
|
802
|
+
if not gained:
|
|
803
|
+
return
|
|
804
|
+
for fan in gained.values():
|
|
805
|
+
self._commit(world, fan)
|
|
806
|
+
got.update(gained)
|
|
807
|
+
|
|
808
|
+
def _try_pattern(
|
|
809
|
+
self, world, pkg, u, terms, pitch: float, params, only: set[str] | None = None
|
|
810
|
+
) -> dict[str, Fanout]:
|
|
811
|
+
opt = self.opt
|
|
812
|
+
m, offset, d0, h, bf, size, drill, width = params
|
|
813
|
+
w_axis = (-u[1], u[0])
|
|
814
|
+
origin = terms[0].lat
|
|
815
|
+
mark = world.checkpoint()
|
|
816
|
+
got: dict[str, Fanout] = {}
|
|
817
|
+
|
|
818
|
+
def phase(t: _Term) -> int:
|
|
819
|
+
return (round((t.lat - origin) / pitch) + offset) % m
|
|
820
|
+
|
|
821
|
+
order = sorted(terms, key=lambda t: (0 if t.pad.net in opt.plane_nets else 1, phase(t)))
|
|
822
|
+
for term in order:
|
|
823
|
+
pad = term.pad
|
|
824
|
+
if only is not None and pad.number not in only:
|
|
825
|
+
continue
|
|
826
|
+
k = phase(term)
|
|
827
|
+
clear = world.clear(pad.net, "")
|
|
828
|
+
half = size / 2 + clear + width / 2
|
|
829
|
+
lanes = []
|
|
830
|
+
for j in range(k):
|
|
831
|
+
row_lat = -(k - j) * pitch # lateral of the row-j via before this pin
|
|
832
|
+
lo, hi = row_lat + half, row_lat + m * pitch - half
|
|
833
|
+
n = m - 1 - j
|
|
834
|
+
idx = k - j - 1
|
|
835
|
+
lanes.append((lo + hi) / 2 if n == 1 else lo + idx * (hi - lo) / (n - 1))
|
|
836
|
+
rows = [term.e + d0 + j * h for j in range(k + 1)]
|
|
837
|
+
local = _lane_polyline(term.e, rows, lanes, bf)
|
|
838
|
+
poly = [
|
|
839
|
+
(
|
|
840
|
+
round(term.x + u[0] * r + w_axis[0] * lat, 4),
|
|
841
|
+
round(term.y + u[1] * r + w_axis[1] * lat, 4),
|
|
842
|
+
)
|
|
843
|
+
for r, lat in local
|
|
844
|
+
]
|
|
845
|
+
vx, vy = poly[-1]
|
|
846
|
+
if not world.via_ok(vx, vy, size, drill, pad.net):
|
|
847
|
+
continue
|
|
848
|
+
points = _clean(term.lead + poly[1:])
|
|
849
|
+
if not world.poly_ok(points, width, pkg.layer, pad.net):
|
|
850
|
+
continue
|
|
851
|
+
fan = Fanout(
|
|
852
|
+
pkg.footprint.reference,
|
|
853
|
+
pad.number,
|
|
854
|
+
pad.net,
|
|
855
|
+
pkg.layer,
|
|
856
|
+
points,
|
|
857
|
+
width,
|
|
858
|
+
(vx, vy, size, drill),
|
|
859
|
+
"dogbone",
|
|
860
|
+
)
|
|
861
|
+
self._commit(world, fan)
|
|
862
|
+
got[pad.number] = fan
|
|
863
|
+
world.rollback(mark)
|
|
864
|
+
return got
|
|
865
|
+
|
|
866
|
+
def _commit(self, world: _World, fan: Fanout) -> None:
|
|
867
|
+
start = len(world.log)
|
|
868
|
+
self._commit_raw(world, fan)
|
|
869
|
+
self.logs[id(fan)] = world.log[start:]
|
|
870
|
+
|
|
871
|
+
def _uncommit(self, world: _World, fan: Fanout) -> None:
|
|
872
|
+
world.unlog(self.logs.pop(id(fan), []))
|
|
873
|
+
|
|
874
|
+
def _commit_raw(self, world: _World, fan: Fanout) -> None:
|
|
875
|
+
if fan.points:
|
|
876
|
+
world.add_poly(fan.points, fan.width, fan.layer, fan.net)
|
|
877
|
+
if fan.via:
|
|
878
|
+
x, y, size, drill = fan.via
|
|
879
|
+
world.add_via(x, y, size, drill, fan.net)
|
|
880
|
+
|
|
881
|
+
def _dogbone(self, world: _World, pkg: _Package, pad: Pad) -> Fanout | None:
|
|
882
|
+
"""One pin on its own: the nearest legal via, straight out or after one 45 degree jog."""
|
|
883
|
+
opt = self.opt
|
|
884
|
+
u = pkg.sides[id(pad)]
|
|
885
|
+
w = (-u[1], u[0])
|
|
886
|
+
e = (pad.width if u[0] else pad.height) / 2
|
|
887
|
+
cx, cy = pad.x, pad.y
|
|
888
|
+
layer = pkg.layer
|
|
889
|
+
|
|
890
|
+
def at(r: float, lat: float) -> tuple[float, float]:
|
|
891
|
+
return (round(cx + u[0] * r + w[0] * lat, 4), round(cy + u[1] * r + w[1] * lat, 4))
|
|
892
|
+
|
|
893
|
+
for size, drill in opt.via_tiers():
|
|
894
|
+
if opt.via_in_pad:
|
|
895
|
+
fan = self._via_in_pad(world, pkg, pad, size, drill)
|
|
896
|
+
if fan is not None:
|
|
897
|
+
return fan
|
|
898
|
+
cands = []
|
|
899
|
+
t = e + 0.15
|
|
900
|
+
while t <= e + 7.0:
|
|
901
|
+
for o in _OFFSETS:
|
|
902
|
+
cands.append((t + 0.414 * abs(o) + (0.15 if o else 0.0), t, o))
|
|
903
|
+
t += 0.05
|
|
904
|
+
cands.sort()
|
|
905
|
+
widths = opt.stub_widths(pad.net)
|
|
906
|
+
seg_memo: dict[tuple, bool] = {}
|
|
907
|
+
|
|
908
|
+
def seg_ok(a, b, width, seg_memo=seg_memo):
|
|
909
|
+
key = (a, b, width)
|
|
910
|
+
if key not in seg_memo:
|
|
911
|
+
seg_memo[key] = world.seg_ok(a[0], a[1], b[0], b[1], width, layer, pad.net)
|
|
912
|
+
return seg_memo[key]
|
|
913
|
+
|
|
914
|
+
dbg = {"cands": len(cands), "via_bad": 0, "stub_bad": 0}
|
|
915
|
+
for _cost, t, o in cands:
|
|
916
|
+
vx, vy = at(t, o)
|
|
917
|
+
if not world.via_ok(vx, vy, size, drill, pad.net):
|
|
918
|
+
dbg["via_bad"] += 1
|
|
919
|
+
continue
|
|
920
|
+
for poly in _stubs(at, e, t, o):
|
|
921
|
+
segs = list(zip(poly, poly[1:]))
|
|
922
|
+
if not all(seg_ok(a, b, widths[-1]) for a, b in segs):
|
|
923
|
+
dbg["stub_bad"] += 1
|
|
924
|
+
continue # not even the narrowest stub fits
|
|
925
|
+
for width in widths:
|
|
926
|
+
if all(seg_ok(a, b, width) for a, b in segs):
|
|
927
|
+
return Fanout(
|
|
928
|
+
pkg.footprint.reference,
|
|
929
|
+
pad.number,
|
|
930
|
+
pad.net,
|
|
931
|
+
layer,
|
|
932
|
+
poly,
|
|
933
|
+
width,
|
|
934
|
+
(vx, vy, size, drill),
|
|
935
|
+
"dogbone",
|
|
936
|
+
)
|
|
937
|
+
if os.environ.get("TF_ROUTE_DEBUG"):
|
|
938
|
+
print(" dogbone fail", pad.number, pad.net, size, widths, dbg, flush=True)
|
|
939
|
+
run = 0.0
|
|
940
|
+
tt = e
|
|
941
|
+
while tt <= e + 7.0:
|
|
942
|
+
x2, y2 = at(tt, 0.0)
|
|
943
|
+
if not world.seg_ok(cx, cy, x2, y2, widths[-1], layer, pad.net):
|
|
944
|
+
break
|
|
945
|
+
run = tt - e
|
|
946
|
+
tt += 0.05
|
|
947
|
+
vias_ok = [
|
|
948
|
+
round(t2, 2)
|
|
949
|
+
for t2 in [e + 0.15 + 0.1 * k for k in range(70)]
|
|
950
|
+
if world.via_ok(*at(t2, 0.0), size, drill, pad.net)
|
|
951
|
+
]
|
|
952
|
+
print(
|
|
953
|
+
" straight stub clear for",
|
|
954
|
+
round(run, 2),
|
|
955
|
+
"mm; via ok at t:",
|
|
956
|
+
vias_ok[:20],
|
|
957
|
+
flush=True,
|
|
958
|
+
)
|
|
959
|
+
px, py = at(e + run + 0.1, 0.0)
|
|
960
|
+
near = world.index.near((px - 0.3, py - 0.3, px + 0.3, py + 0.3), 0.4)
|
|
961
|
+
print(
|
|
962
|
+
" blocked near",
|
|
963
|
+
(round(px, 2), round(py, 2)),
|
|
964
|
+
[(world.names.get(sh.net, sh.net), [round(v, 2) for v in sh.c]) for sh in near][
|
|
965
|
+
:8
|
|
966
|
+
],
|
|
967
|
+
flush=True,
|
|
968
|
+
)
|
|
969
|
+
return None
|
|
970
|
+
|
|
971
|
+
def _via_in_pad(self, world, pkg, pad: Pad, size: float, drill: float) -> Fanout | None:
|
|
972
|
+
"""A via inside the pad when the pad can hold it with the drill clear of the edge."""
|
|
973
|
+
if min(pad.width, pad.height) < size + 0.05:
|
|
974
|
+
return None
|
|
975
|
+
if not world.via_ok(pad.x, pad.y, size, drill, pad.net):
|
|
976
|
+
return None
|
|
977
|
+
return Fanout(
|
|
978
|
+
pkg.footprint.reference,
|
|
979
|
+
pad.number,
|
|
980
|
+
pad.net,
|
|
981
|
+
pkg.layer,
|
|
982
|
+
[],
|
|
983
|
+
0.0,
|
|
984
|
+
(round(pad.x, 4), round(pad.y, 4), size, drill),
|
|
985
|
+
"via-in-pad",
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
# ------------------------------------------------------------------- grid
|
|
989
|
+
def grid(self, pkg: _Package, pads: list[Pad]) -> tuple[list[Fanout], list[Pad]]:
|
|
990
|
+
"""BGA: outer rings break out on the surface between balls to lanes outside the part,
|
|
991
|
+
where the row pattern puts their vias; the other balls get a via in the pad or a
|
|
992
|
+
dogbone on the lattice point between four balls."""
|
|
993
|
+
world = _World(self.board, self.opt)
|
|
994
|
+
assert pkg.lattice is not None
|
|
995
|
+
xs, ys = pkg.lattice
|
|
996
|
+
pitch = pkg.pitch
|
|
997
|
+
radius = max(max(p.width, p.height) for p in pkg.pads) / 2
|
|
998
|
+
nx, ny = len(xs), len(ys)
|
|
999
|
+
out_gap = radius + 0.3
|
|
1000
|
+
bound = {
|
|
1001
|
+
(-1.0, 0.0): xs[0] - out_gap,
|
|
1002
|
+
(1.0, 0.0): xs[-1] + out_gap,
|
|
1003
|
+
(0.0, -1.0): ys[0] - out_gap,
|
|
1004
|
+
(0.0, 1.0): ys[-1] + out_gap,
|
|
1005
|
+
}
|
|
1006
|
+
widths = self.opt.stub_widths("")
|
|
1007
|
+
leads: dict[tuple[float, float], list[_Term]] = {}
|
|
1008
|
+
placed: dict[str, Fanout] = {}
|
|
1009
|
+
ring_pads = sorted(
|
|
1010
|
+
(pad for pad in pads if pkg.ring[id(pad)] <= 1),
|
|
1011
|
+
key=lambda p: (pkg.ring[id(p)], p.number),
|
|
1012
|
+
)
|
|
1013
|
+
for pad in ring_pads:
|
|
1014
|
+
ix = min(range(nx), key=lambda i: abs(xs[i] - pad.x))
|
|
1015
|
+
iy = min(range(ny), key=lambda i: abs(ys[i] - pad.y))
|
|
1016
|
+
ring = pkg.ring[id(pad)]
|
|
1017
|
+
dirs = []
|
|
1018
|
+
if ix == ring:
|
|
1019
|
+
dirs.append((-1.0, 0.0))
|
|
1020
|
+
if nx - 1 - ix == ring:
|
|
1021
|
+
dirs.append((1.0, 0.0))
|
|
1022
|
+
if iy == ring:
|
|
1023
|
+
dirs.append((0.0, -1.0))
|
|
1024
|
+
if ny - 1 - iy == ring:
|
|
1025
|
+
dirs.append((0.0, 1.0))
|
|
1026
|
+
options = []
|
|
1027
|
+
for d in dirs:
|
|
1028
|
+
if ring == 0:
|
|
1029
|
+
options.append((d, 0.0))
|
|
1030
|
+
else:
|
|
1031
|
+
options.append((d, -0.5 * pitch))
|
|
1032
|
+
options.append((d, 0.5 * pitch))
|
|
1033
|
+
for d, shift in options:
|
|
1034
|
+
lat_axis = (-d[1], d[0])
|
|
1035
|
+
if shift == 0.0:
|
|
1036
|
+
first = None
|
|
1037
|
+
lane = (pad.x, pad.y)
|
|
1038
|
+
else:
|
|
1039
|
+
first = (
|
|
1040
|
+
pad.x + d[0] * 0.5 * pitch + lat_axis[0] * shift,
|
|
1041
|
+
pad.y + d[1] * 0.5 * pitch + lat_axis[1] * shift,
|
|
1042
|
+
)
|
|
1043
|
+
lane = first
|
|
1044
|
+
if d[0]:
|
|
1045
|
+
end = (bound[d], lane[1])
|
|
1046
|
+
else:
|
|
1047
|
+
end = (lane[0], bound[d])
|
|
1048
|
+
pts = [(round(pad.x, 4), round(pad.y, 4))]
|
|
1049
|
+
if first is not None:
|
|
1050
|
+
pts.append((round(first[0], 4), round(first[1], 4)))
|
|
1051
|
+
pts.append((round(end[0], 4), round(end[1], 4)))
|
|
1052
|
+
width = next(
|
|
1053
|
+
(w for w in widths if world.poly_ok(pts, w, pkg.layer, pad.net)),
|
|
1054
|
+
None,
|
|
1055
|
+
)
|
|
1056
|
+
if width is None:
|
|
1057
|
+
continue
|
|
1058
|
+
lat = end[0] * lat_axis[0] + end[1] * lat_axis[1]
|
|
1059
|
+
term = _Term(pad, end[0], end[1], 0.0, lat, pts)
|
|
1060
|
+
world.add_poly(pts, width, pkg.layer, pad.net)
|
|
1061
|
+
leads.setdefault(d, []).append(term)
|
|
1062
|
+
placed[pad.number] = Fanout(
|
|
1063
|
+
pkg.footprint.reference,
|
|
1064
|
+
pad.number,
|
|
1065
|
+
pad.net,
|
|
1066
|
+
pkg.layer,
|
|
1067
|
+
pts,
|
|
1068
|
+
width,
|
|
1069
|
+
None,
|
|
1070
|
+
"surface",
|
|
1071
|
+
)
|
|
1072
|
+
break
|
|
1073
|
+
# Vias for the lane ends: the row pattern on every side.
|
|
1074
|
+
final: dict[str, Fanout] = {}
|
|
1075
|
+
for d, terms in leads.items():
|
|
1076
|
+
terms.sort(key=lambda t: t.lat)
|
|
1077
|
+
got = self._staggered(world, pkg, d, terms, pitch / 2)
|
|
1078
|
+
for number, fan in got.items():
|
|
1079
|
+
fan.style = "surface+dogbone"
|
|
1080
|
+
self._commit(world, fan)
|
|
1081
|
+
final[number] = fan
|
|
1082
|
+
# A breakout lane that got no via leads nowhere: take it out again.
|
|
1083
|
+
world = _World(self.board, self.opt)
|
|
1084
|
+
for fan in final.values():
|
|
1085
|
+
self._commit(world, fan)
|
|
1086
|
+
# Everything else: via in pad, then a dogbone on a lattice point.
|
|
1087
|
+
left: list[Pad] = []
|
|
1088
|
+
for pad in pads:
|
|
1089
|
+
if pad.number in final:
|
|
1090
|
+
continue
|
|
1091
|
+
fan = self._grid_dogbone(world, pkg, pad, pitch)
|
|
1092
|
+
if fan is None:
|
|
1093
|
+
left.append(pad)
|
|
1094
|
+
else:
|
|
1095
|
+
self._commit(world, fan)
|
|
1096
|
+
final[pad.number] = fan
|
|
1097
|
+
if left:
|
|
1098
|
+
self.note = (
|
|
1099
|
+
f"no dogbone via fits between {pitch:g} mm balls at {self.opt.clearance:g} mm "
|
|
1100
|
+
"clearance and the fab's via sizes: needs via in pad (via_in_pad) or finer rules"
|
|
1101
|
+
)
|
|
1102
|
+
return list(final.values()), left
|
|
1103
|
+
|
|
1104
|
+
def _grid_dogbone(self, world, pkg, pad: Pad, pitch: float) -> Fanout | None:
|
|
1105
|
+
opt = self.opt
|
|
1106
|
+
for size, drill in opt.via_tiers():
|
|
1107
|
+
fan = self._via_in_pad(world, pkg, pad, size, drill) if opt.via_in_pad else None
|
|
1108
|
+
if fan is not None:
|
|
1109
|
+
return fan
|
|
1110
|
+
xs, ys = pkg.lattice # type: ignore[misc]
|
|
1111
|
+
cx = (xs[0] + xs[-1]) / 2
|
|
1112
|
+
cy = (ys[0] + ys[-1]) / 2
|
|
1113
|
+
options = []
|
|
1114
|
+
for sx in (-1, 1):
|
|
1115
|
+
for sy in (-1, 1):
|
|
1116
|
+
outward = (pad.x - cx) * sx + (pad.y - cy) * sy
|
|
1117
|
+
options.append((-outward, sx, sy))
|
|
1118
|
+
options.sort()
|
|
1119
|
+
for _o, sx, sy in options:
|
|
1120
|
+
vx, vy = round(pad.x + sx * pitch / 2, 4), round(pad.y + sy * pitch / 2, 4)
|
|
1121
|
+
if not world.via_ok(vx, vy, size, drill, pad.net):
|
|
1122
|
+
continue
|
|
1123
|
+
poly = [(round(pad.x, 4), round(pad.y, 4)), (vx, vy)]
|
|
1124
|
+
for width in opt.stub_widths(pad.net):
|
|
1125
|
+
if world.poly_ok(poly, width, pkg.layer, pad.net):
|
|
1126
|
+
return Fanout(
|
|
1127
|
+
pkg.footprint.reference,
|
|
1128
|
+
pad.number,
|
|
1129
|
+
pad.net,
|
|
1130
|
+
pkg.layer,
|
|
1131
|
+
poly,
|
|
1132
|
+
width,
|
|
1133
|
+
(vx, vy, size, drill),
|
|
1134
|
+
"dogbone",
|
|
1135
|
+
)
|
|
1136
|
+
return None
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
_D0 = (0.55, 0.75, 1.0) # first via row: distance beyond the pad end
|
|
1140
|
+
_ROW_GAP = (0.9, 1.2, 1.6) # between via rows
|
|
1141
|
+
_D0_FAR = (1.4, 1.9, 2.5, 3.2) # farther rows, only to place pins the usual rows could not
|
|
1142
|
+
_ROW_GAP_FAR = (1.6, 2.2, 3.0)
|
|
1143
|
+
_ENTRY = (0.5, 0.35) # where along a via the stub must be in its lane
|
|
1144
|
+
_OFFSETS = [0.0] + [s * k * 0.1 for k in range(1, 11) for s in (1, -1)]
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _stubs(at, e: float, t: float, o: float) -> list[list[tuple[float, float]]]:
|
|
1148
|
+
"""Stub centrelines from the pad centre to the via at (t, o): straight or one 45 degree jog."""
|
|
1149
|
+
if abs(o) < 1e-9:
|
|
1150
|
+
return [[at(0, 0), at(t, 0)]]
|
|
1151
|
+
polys = []
|
|
1152
|
+
for s in (e + 0.1, e + 0.3, e + 0.6, e + 1.0):
|
|
1153
|
+
if s + abs(o) <= t + 1e-9:
|
|
1154
|
+
polys.append([at(0, 0), at(s, 0), at(s + abs(o), o), at(t, o)])
|
|
1155
|
+
return polys
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _clean(points: list[tuple[float, float]], tol: float = 0.02) -> list[tuple[float, float]]:
|
|
1159
|
+
"""Drop points that leave a segment shorter than ``tol`` (the last point always stays)."""
|
|
1160
|
+
out = [points[0]]
|
|
1161
|
+
for i, q in enumerate(points[1:], start=1):
|
|
1162
|
+
last = i == len(points) - 1
|
|
1163
|
+
if math.hypot(q[0] - out[-1][0], q[1] - out[-1][1]) < tol:
|
|
1164
|
+
if last and len(out) > 1:
|
|
1165
|
+
out[-1] = q
|
|
1166
|
+
continue
|
|
1167
|
+
out.append(q)
|
|
1168
|
+
return out
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def _rows_needed(pitch: float, via: float, clearance: float, width: float) -> int | None:
|
|
1172
|
+
"""Fewest via rows (3 to 6) whose lanes carry the tracks of the others, or None.
|
|
1173
|
+
|
|
1174
|
+
Between two vias of the same row (``m`` pitches apart) the ``m - 1`` stubs of the other
|
|
1175
|
+
rows must fit: ``m * pitch - via - 2 * clearance >= (m - 1) * width + (m - 2) * clearance``.
|
|
1176
|
+
"""
|
|
1177
|
+
for m in range(3, 7):
|
|
1178
|
+
if m * pitch - via - 2 * clearance >= (m - 1) * width + (m - 2) * clearance - 1e-9:
|
|
1179
|
+
return m
|
|
1180
|
+
return None
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _lane_polyline(
|
|
1184
|
+
e: float, rows: list[float], lanes: list[float], entry: float
|
|
1185
|
+
) -> list[tuple[float, float]]:
|
|
1186
|
+
"""A stub in (radial, lateral) coordinates from the pad centre to the via of its row.
|
|
1187
|
+
|
|
1188
|
+
``rows[j]`` is the radial position of via row ``j``; the stub is in lane ``lanes[j]`` while it
|
|
1189
|
+
passes row ``j`` (``entry`` before and after the row's vias) and ends at the pad's own
|
|
1190
|
+
lateral position (0) on row ``len(lanes)``.
|
|
1191
|
+
"""
|
|
1192
|
+
pts = [(0.0, 0.0)]
|
|
1193
|
+
r, lat = 0.0, 0.0
|
|
1194
|
+
if lanes:
|
|
1195
|
+
pts.append((e + 0.1, 0.0))
|
|
1196
|
+
r = e + 0.1
|
|
1197
|
+
for j, lane in enumerate(lanes):
|
|
1198
|
+
d = abs(lane - lat)
|
|
1199
|
+
r_fin = rows[j] - entry
|
|
1200
|
+
r0 = max(r, r_fin - d)
|
|
1201
|
+
if r0 > r:
|
|
1202
|
+
pts.append((r0, lat))
|
|
1203
|
+
pts.append((r0 + d, lane))
|
|
1204
|
+
r, lat = r0 + d, lane
|
|
1205
|
+
r_out = max(r, rows[j] + entry)
|
|
1206
|
+
pts.append((r_out, lane))
|
|
1207
|
+
r = r_out
|
|
1208
|
+
d = abs(lat)
|
|
1209
|
+
target = rows[len(lanes)]
|
|
1210
|
+
r0 = max(r, target - d)
|
|
1211
|
+
if r0 > r:
|
|
1212
|
+
pts.append((r0, lat))
|
|
1213
|
+
pts.append((r0 + d, 0.0))
|
|
1214
|
+
out = [pts[0]]
|
|
1215
|
+
for q in pts[1:]:
|
|
1216
|
+
if math.hypot(q[0] - out[-1][0], q[1] - out[-1][1]) > 1e-9:
|
|
1217
|
+
out.append(q)
|
|
1218
|
+
return out
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
# ----------------------------------------------------------------------- driver
|
|
1222
|
+
def plan(board: Board, opt: EscapeOptions) -> EscapeReport:
|
|
1223
|
+
"""Plan the fan-out of every dense package on ``board`` (nothing is written)."""
|
|
1224
|
+
import time
|
|
1225
|
+
|
|
1226
|
+
started = time.monotonic()
|
|
1227
|
+
report = EscapeReport()
|
|
1228
|
+
planner = _Planner(board, opt)
|
|
1229
|
+
for pkg in detect_packages(board, opt):
|
|
1230
|
+
pads = _wanted_pads(board, pkg, opt)
|
|
1231
|
+
info = {
|
|
1232
|
+
"reference": pkg.footprint.reference,
|
|
1233
|
+
"kind": "grid (BGA)" if pkg.kind == "grid" else "perimeter (QFN/QFP/connector)",
|
|
1234
|
+
"pitch_mm": round(pkg.pitch, 3),
|
|
1235
|
+
"pins": len(pads),
|
|
1236
|
+
}
|
|
1237
|
+
if not pads:
|
|
1238
|
+
continue
|
|
1239
|
+
if pkg.kind == "perimeter":
|
|
1240
|
+
placed, left = planner.perimeter(pkg, pads)
|
|
1241
|
+
else:
|
|
1242
|
+
placed, left = planner.grid(pkg, pads)
|
|
1243
|
+
for fan in placed:
|
|
1244
|
+
fan.ring = _ring_of(pkg, fan)
|
|
1245
|
+
fan.layer_hint = _hint(opt, fan.layer, fan.ring) if fan.via else None
|
|
1246
|
+
report.fanouts.extend(placed)
|
|
1247
|
+
for pad in left:
|
|
1248
|
+
why = planner.note or "no legal stub and via at these rules"
|
|
1249
|
+
report.unplaced.append((pkg.footprint.reference, pad.number, why))
|
|
1250
|
+
planner.note = ""
|
|
1251
|
+
info["placed"] = len(placed)
|
|
1252
|
+
info["vias"] = sum(1 for f in placed if f.via)
|
|
1253
|
+
report.packages.append(info)
|
|
1254
|
+
report.seconds = time.monotonic() - started
|
|
1255
|
+
return report
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _ring_of(pkg: _Package, fan: Fanout) -> int:
|
|
1259
|
+
if pkg.kind == "grid":
|
|
1260
|
+
pad = next((p for p in pkg.footprint.pads if p.number == fan.pad), None)
|
|
1261
|
+
return pkg.ring.get(id(pad), 0) if pad is not None else 0
|
|
1262
|
+
if not fan.via or not fan.points:
|
|
1263
|
+
return 0
|
|
1264
|
+
x0, y0 = fan.points[0]
|
|
1265
|
+
dist = math.hypot(fan.via[0] - x0, fan.via[1] - y0)
|
|
1266
|
+
return int(max(0.0, dist - 0.5) // 0.6)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def apply(pcb: Path, report: EscapeReport) -> int:
|
|
1270
|
+
"""Write the planned stubs and vias into the board file."""
|
|
1271
|
+
tracks = [(f.net, f.layer, f.width, f.points) for f in report.fanouts if len(f.points) > 1]
|
|
1272
|
+
if tracks:
|
|
1273
|
+
edit.pcb_add_routing(pcb, tracks, [], 0.6, 0.3)
|
|
1274
|
+
by_size: dict[tuple[float, float], list[tuple[str, float, float]]] = {}
|
|
1275
|
+
for f in report.fanouts:
|
|
1276
|
+
if f.via:
|
|
1277
|
+
by_size.setdefault((f.via[2], f.via[3]), []).append((f.net, f.via[0], f.via[1]))
|
|
1278
|
+
for (size, drill), vias in by_size.items():
|
|
1279
|
+
edit.pcb_add_routing(pcb, [], vias, size, drill)
|
|
1280
|
+
return len(tracks) + sum(len(v) for v in by_size.values())
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def fan_out(pcb: str | Path, route_options, enabled: bool | None = None) -> EscapeReport:
|
|
1284
|
+
"""Plan and write the fan-out of dense packages. ``enabled``: None switches it on from
|
|
1285
|
+
four copper layers up (two-layer boards keep their pads as they are)."""
|
|
1286
|
+
pcb = Path(pcb)
|
|
1287
|
+
board = read_board(pcb)
|
|
1288
|
+
if enabled is None:
|
|
1289
|
+
enabled = len(board.copper_layers) >= 4
|
|
1290
|
+
if not enabled or board.outline is None:
|
|
1291
|
+
return EscapeReport()
|
|
1292
|
+
opt = options_for(pcb, route_options, board.copper_layers)
|
|
1293
|
+
report = plan(board, opt)
|
|
1294
|
+
if report.fanouts:
|
|
1295
|
+
apply(pcb, report)
|
|
1296
|
+
return report
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
# ------------------------------------------------------------- router handover
|
|
1300
|
+
_TOL = 0.002
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def attach_fanout(board: Board) -> set[int]:
|
|
1304
|
+
"""Make each fanned-out pin start at the end of its stub.
|
|
1305
|
+
|
|
1306
|
+
A pad whose only copper is a short chain of tracks ending in a via (or free end) gets a
|
|
1307
|
+
stand-in pad at that end, appended to the board in memory as a footprint ``~FANOUT``.
|
|
1308
|
+
Returns the ids of the pads that stand-ins replace; the router treats those as connected.
|
|
1309
|
+
"""
|
|
1310
|
+
by_net_tracks: dict[str, list] = {}
|
|
1311
|
+
for t in board.tracks:
|
|
1312
|
+
by_net_tracks.setdefault(t.net, []).append(t)
|
|
1313
|
+
vias_by_net: dict[str, list] = {}
|
|
1314
|
+
for v in board.vias:
|
|
1315
|
+
vias_by_net.setdefault(v.net, []).append(v)
|
|
1316
|
+
replaced: set[int] = set()
|
|
1317
|
+
stand_ins: list[Pad] = []
|
|
1318
|
+
pad_list = [(fp, p) for fp in board.footprints for p in fp.pads if p.net and p.kind == "smd"]
|
|
1319
|
+
all_smd = [p for _fp, p in pad_list]
|
|
1320
|
+
for fp, pad in pad_list:
|
|
1321
|
+
tracks = by_net_tracks.get(pad.net)
|
|
1322
|
+
if not tracks:
|
|
1323
|
+
continue
|
|
1324
|
+
first = [
|
|
1325
|
+
t
|
|
1326
|
+
for t in tracks
|
|
1327
|
+
if pad.on_layer(t.layer)
|
|
1328
|
+
and (pad.contains(t.x1, t.y1, _TOL) or pad.contains(t.x2, t.y2, _TOL))
|
|
1329
|
+
]
|
|
1330
|
+
if len(first) != 1:
|
|
1331
|
+
continue
|
|
1332
|
+
chain = [first[0]]
|
|
1333
|
+
a = first[0]
|
|
1334
|
+
end = (a.x2, a.y2) if not pad.contains(a.x2, a.y2, _TOL) else (a.x1, a.y1)
|
|
1335
|
+
if pad.contains(a.x1, a.y1, _TOL) and pad.contains(a.x2, a.y2, _TOL):
|
|
1336
|
+
continue
|
|
1337
|
+
ok = True
|
|
1338
|
+
for _ in range(40):
|
|
1339
|
+
if any(
|
|
1340
|
+
math.hypot(v.x - end[0], v.y - end[1]) < _TOL for v in vias_by_net.get(pad.net, [])
|
|
1341
|
+
):
|
|
1342
|
+
break # a via ends the stub, or joins it to other copper
|
|
1343
|
+
nxt = [
|
|
1344
|
+
t
|
|
1345
|
+
for t in tracks
|
|
1346
|
+
if t not in chain
|
|
1347
|
+
and t.layer == a.layer
|
|
1348
|
+
and (
|
|
1349
|
+
math.hypot(t.x1 - end[0], t.y1 - end[1]) < _TOL
|
|
1350
|
+
or math.hypot(t.x2 - end[0], t.y2 - end[1]) < _TOL
|
|
1351
|
+
)
|
|
1352
|
+
]
|
|
1353
|
+
if not nxt:
|
|
1354
|
+
break
|
|
1355
|
+
if len(nxt) > 1:
|
|
1356
|
+
ok = False
|
|
1357
|
+
break
|
|
1358
|
+
t = nxt[0]
|
|
1359
|
+
chain.append(t)
|
|
1360
|
+
end = (t.x2, t.y2) if math.hypot(t.x1 - end[0], t.y1 - end[1]) < _TOL else (t.x1, t.y1)
|
|
1361
|
+
if not ok:
|
|
1362
|
+
continue
|
|
1363
|
+
end = (round(end[0], 4), round(end[1], 4))
|
|
1364
|
+
others = [
|
|
1365
|
+
t
|
|
1366
|
+
for t in tracks
|
|
1367
|
+
if t not in chain
|
|
1368
|
+
and (
|
|
1369
|
+
math.hypot(t.x1 - end[0], t.y1 - end[1]) < _TOL
|
|
1370
|
+
or math.hypot(t.x2 - end[0], t.y2 - end[1]) < _TOL
|
|
1371
|
+
)
|
|
1372
|
+
]
|
|
1373
|
+
via = next(
|
|
1374
|
+
(
|
|
1375
|
+
v
|
|
1376
|
+
for v in vias_by_net.get(pad.net, [])
|
|
1377
|
+
if math.hypot(v.x - end[0], v.y - end[1]) < _TOL
|
|
1378
|
+
),
|
|
1379
|
+
None,
|
|
1380
|
+
)
|
|
1381
|
+
if others:
|
|
1382
|
+
continue
|
|
1383
|
+
name = f"{fp.reference}.{pad.number}"
|
|
1384
|
+
stub = sum(t.length for t in chain)
|
|
1385
|
+
if via is not None:
|
|
1386
|
+
stand = Pad(
|
|
1387
|
+
name, "thru_hole", "circle", via.x, via.y, via.size, via.size, ["*.Cu"], pad.net
|
|
1388
|
+
)
|
|
1389
|
+
stand.stub_mm = stub # type: ignore[attr-defined]
|
|
1390
|
+
stand.stub_of = (fp.reference, pad.number) # type: ignore[attr-defined]
|
|
1391
|
+
stand_ins.append(stand)
|
|
1392
|
+
else:
|
|
1393
|
+
if any(
|
|
1394
|
+
q is not pad and q.on_layer(a.layer) and q.contains(end[0], end[1], _TOL)
|
|
1395
|
+
for q in all_smd
|
|
1396
|
+
):
|
|
1397
|
+
continue # runs on to another pad: ordinary routing
|
|
1398
|
+
width = chain[-1].width
|
|
1399
|
+
stand = Pad(
|
|
1400
|
+
name, "thru_hole", "circle", end[0], end[1], width, width, [a.layer], pad.net
|
|
1401
|
+
)
|
|
1402
|
+
stand.stub_mm = stub # type: ignore[attr-defined]
|
|
1403
|
+
stand.stub_of = (fp.reference, pad.number) # type: ignore[attr-defined]
|
|
1404
|
+
stand_ins.append(stand)
|
|
1405
|
+
replaced.add(id(pad))
|
|
1406
|
+
if stand_ins:
|
|
1407
|
+
board.footprints.append(
|
|
1408
|
+
Footprint("traceforge:fanout", "~FANOUT", "", 0.0, 0.0, 0.0, "F.Cu", stand_ins)
|
|
1409
|
+
)
|
|
1410
|
+
return replaced
|