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.
Files changed (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. 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