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
traceforge/kicad/pcb.py
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
"""Read KiCad boards (``.kicad_pcb``, KiCad 6 to 10) and compute connectivity.
|
|
2
|
+
|
|
3
|
+
Handles every syntax variant in use: ``fp_text`` (KiCad 6/7) and ``property``
|
|
4
|
+
(KiCad 8+) footprint fields, ``tstamp``/``uuid``, bare ``hide`` and ``(hide yes)``,
|
|
5
|
+
net codes with a net table (KiCad 6-9) and net names only (KiCad 10), arcs, rule
|
|
6
|
+
areas (keepout zones), component classes, jumper pad groups and embedded files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from traceforge import sexpr
|
|
16
|
+
from traceforge.kicad.edgegeom import EdgeGeometry, arc_points, circle_points
|
|
17
|
+
from traceforge.kicad.formats import flag, is_hidden, major_for
|
|
18
|
+
from traceforge.sexpr import Node, find, find_all, num, prop, value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Pad:
|
|
23
|
+
number: str
|
|
24
|
+
kind: str # smd | thru_hole | np_thru_hole | connect
|
|
25
|
+
shape: str
|
|
26
|
+
x: float # absolute board coordinates
|
|
27
|
+
y: float
|
|
28
|
+
width: float
|
|
29
|
+
height: float
|
|
30
|
+
layers: list[str]
|
|
31
|
+
net: str
|
|
32
|
+
drill: float = 0.0
|
|
33
|
+
function: str = "" # schematic pin name ("pinfunction"), when KiCad recorded it
|
|
34
|
+
castellated: bool = False # half-cut plated hole on the board edge
|
|
35
|
+
|
|
36
|
+
def contains(self, x: float, y: float, slack: float = 0.0) -> bool:
|
|
37
|
+
dx, dy = abs(x - self.x), abs(y - self.y)
|
|
38
|
+
half_w, half_h = self.width / 2 + slack, self.height / 2 + slack
|
|
39
|
+
if dx > half_w or dy > half_h:
|
|
40
|
+
return False
|
|
41
|
+
if self.shape == "circle":
|
|
42
|
+
return math.hypot(dx, dy) <= min(half_w, half_h)
|
|
43
|
+
if self.shape == "oval":
|
|
44
|
+
# Capsule: a rectangle with semicircular ends along the long axis.
|
|
45
|
+
radius = min(half_w, half_h)
|
|
46
|
+
if half_w >= half_h:
|
|
47
|
+
return math.hypot(max(0.0, dx - (half_w - radius)), dy) <= radius
|
|
48
|
+
return math.hypot(dx, max(0.0, dy - (half_h - radius))) <= radius
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
def on_layer(self, layer: str) -> bool:
|
|
52
|
+
for name in self.layers:
|
|
53
|
+
if name == layer or name == "*.Cu" or (name == "F&B.Cu" and layer in {"F.Cu", "B.Cu"}):
|
|
54
|
+
return True
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Footprint:
|
|
60
|
+
lib_id: str
|
|
61
|
+
reference: str
|
|
62
|
+
value: str
|
|
63
|
+
x: float
|
|
64
|
+
y: float
|
|
65
|
+
angle: float
|
|
66
|
+
layer: str
|
|
67
|
+
pads: list[Pad]
|
|
68
|
+
uuid: str = ""
|
|
69
|
+
path: str = ""
|
|
70
|
+
attrs: list[str] = field(default_factory=list)
|
|
71
|
+
fields: dict[str, str] = field(default_factory=dict)
|
|
72
|
+
texts: list[tuple[str, str, float]] = field(default_factory=list) # (layer, text, height)
|
|
73
|
+
courtyard: tuple[float, float, float, float] | None = None # absolute bbox
|
|
74
|
+
# Edge-mount connectors: (outward direction, point on the line that belongs on the
|
|
75
|
+
# board edge), both in board coordinates. None for ordinary parts.
|
|
76
|
+
edge: tuple[tuple[float, float], tuple[float, float]] | None = None
|
|
77
|
+
sheetname: str = ""
|
|
78
|
+
sheetfile: str = ""
|
|
79
|
+
component_classes: list[str] = field(default_factory=list) # KiCad 9+
|
|
80
|
+
jumper_pad_groups: list[list[str]] = field(default_factory=list) # KiCad 10
|
|
81
|
+
duplicate_pads_are_jumpers: bool = False # KiCad 10
|
|
82
|
+
locked: bool = False
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def dnp(self) -> bool:
|
|
86
|
+
return "dnp" in self.attrs or self.fields.get("dnp", "").lower() in {"yes", "true"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Track:
|
|
91
|
+
x1: float
|
|
92
|
+
y1: float
|
|
93
|
+
x2: float
|
|
94
|
+
y2: float
|
|
95
|
+
width: float
|
|
96
|
+
layer: str
|
|
97
|
+
net: str
|
|
98
|
+
mid: tuple[float, float] | None = None # arcs: a point on the arc
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def length(self) -> float:
|
|
102
|
+
if self.mid is not None:
|
|
103
|
+
arc = _arc_length((self.x1, self.y1), self.mid, (self.x2, self.y2))
|
|
104
|
+
if arc is not None:
|
|
105
|
+
return arc
|
|
106
|
+
return math.hypot(self.x2 - self.x1, self.y2 - self.y1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _arc_length(
|
|
110
|
+
start: tuple[float, float], mid: tuple[float, float], end: tuple[float, float]
|
|
111
|
+
) -> float | None:
|
|
112
|
+
(ax, ay), (bx, by), (cx, cy) = start, mid, end
|
|
113
|
+
d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
|
|
114
|
+
if abs(d) < 1e-12:
|
|
115
|
+
return None
|
|
116
|
+
ux = (
|
|
117
|
+
(ax**2 + ay**2) * (by - cy) + (bx**2 + by**2) * (cy - ay) + (cx**2 + cy**2) * (ay - by)
|
|
118
|
+
) / d
|
|
119
|
+
uy = (
|
|
120
|
+
(ax**2 + ay**2) * (cx - bx) + (bx**2 + by**2) * (ax - cx) + (cx**2 + cy**2) * (bx - ax)
|
|
121
|
+
) / d
|
|
122
|
+
radius = math.hypot(ax - ux, ay - uy)
|
|
123
|
+
angles = [math.atan2(py - uy, px - ux) for px, py in (start, mid, end)]
|
|
124
|
+
sweep = (angles[2] - angles[0]) % (2 * math.pi)
|
|
125
|
+
mid_sweep = (angles[1] - angles[0]) % (2 * math.pi)
|
|
126
|
+
if mid_sweep > sweep: # the arc runs the other way round
|
|
127
|
+
sweep = 2 * math.pi - sweep
|
|
128
|
+
return radius * sweep
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class Via:
|
|
133
|
+
x: float
|
|
134
|
+
y: float
|
|
135
|
+
size: float
|
|
136
|
+
drill: float
|
|
137
|
+
layers: list[str]
|
|
138
|
+
net: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class Board:
|
|
143
|
+
path: Path
|
|
144
|
+
version: str
|
|
145
|
+
thickness: float
|
|
146
|
+
copper_layers: list[str]
|
|
147
|
+
nets: dict[int, str]
|
|
148
|
+
footprints: list[Footprint]
|
|
149
|
+
tracks: list[Track]
|
|
150
|
+
vias: list[Via]
|
|
151
|
+
zones: list[dict]
|
|
152
|
+
outline: tuple[float, float, float, float] | None # min_x, min_y, max_x, max_y
|
|
153
|
+
texts: list[tuple[str, str, float]] # board-level (layer, text, height)
|
|
154
|
+
rules: dict[str, float]
|
|
155
|
+
generator: str = ""
|
|
156
|
+
generator_version: str = ""
|
|
157
|
+
embedded_files: list[str] = field(default_factory=list) # KiCad 9+
|
|
158
|
+
tables: int = 0 # KiCad 9+
|
|
159
|
+
rule_areas: list[dict] = field(default_factory=list) # keepout / placement areas
|
|
160
|
+
variants: list[str] = field(default_factory=list) # KiCad 10
|
|
161
|
+
edge_segments: list[tuple[float, float, float, float]] = field(default_factory=list)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def edge_geometry(self) -> EdgeGeometry | None:
|
|
165
|
+
"""The Edge.Cuts shapes (outline, cut-outs, slots) for inside/distance queries."""
|
|
166
|
+
if self.outline is None:
|
|
167
|
+
return None
|
|
168
|
+
if not self.edge_segments:
|
|
169
|
+
x0, y0, x1, y1 = self.outline
|
|
170
|
+
return EdgeGeometry(
|
|
171
|
+
[(x0, y0, x1, y0), (x1, y0, x1, y1), (x1, y1, x0, y1), (x0, y1, x0, y0)]
|
|
172
|
+
)
|
|
173
|
+
return EdgeGeometry(self.edge_segments)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def kicad_major(self) -> int | None:
|
|
177
|
+
return major_for("board", int(num(self.version, 0)))
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def size_mm(self) -> tuple[float, float] | None:
|
|
181
|
+
if self.outline is None:
|
|
182
|
+
return None
|
|
183
|
+
return (self.outline[2] - self.outline[0], self.outline[3] - self.outline[1])
|
|
184
|
+
|
|
185
|
+
def footprint(self, reference: str) -> Footprint | None:
|
|
186
|
+
for item in self.footprints:
|
|
187
|
+
if item.reference == reference:
|
|
188
|
+
return item
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def pin_function(raw: object, number: str) -> str:
|
|
193
|
+
"""A pad's schematic pin name from KiCad's ``pinfunction`` field.
|
|
194
|
+
|
|
195
|
+
KiCad 10 appends the pin number to the name (``VCC_2`` for pin 2 named ``VCC``);
|
|
196
|
+
KiCad 8 and 9 write the plain name.
|
|
197
|
+
"""
|
|
198
|
+
name = str(raw or "")
|
|
199
|
+
suffix = f"_{number}"
|
|
200
|
+
if number and name.endswith(suffix) and len(name) > len(suffix):
|
|
201
|
+
return name[: -len(suffix)]
|
|
202
|
+
return name
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _rotate(dx: float, dy: float, angle_deg: float) -> tuple[float, float]:
|
|
206
|
+
"""KiCad rotates counter-clockwise on screen; with Y pointing down that is -angle."""
|
|
207
|
+
if not angle_deg:
|
|
208
|
+
return dx, dy
|
|
209
|
+
rad = math.radians(-angle_deg)
|
|
210
|
+
return (dx * math.cos(rad) - dy * math.sin(rad), dx * math.sin(rad) + dy * math.cos(rad))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_EDGE_LIBRARIES = (
|
|
214
|
+
"Connector_USB:",
|
|
215
|
+
"Connector_BarrelJack:",
|
|
216
|
+
"Connector_Audio:",
|
|
217
|
+
"Connector_RJ:",
|
|
218
|
+
"Connector_Coaxial:",
|
|
219
|
+
"Connector_Card:",
|
|
220
|
+
)
|
|
221
|
+
_EDGE_LAYERS = {"Dwgs.User", "Cmts.User", "F.Fab", "B.Fab"}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def edge_marker(node: Node, lib_id: str = "") -> tuple[tuple[float, float], float] | None:
|
|
225
|
+
"""Where an edge-mount connector meets the board edge, in footprint coordinates.
|
|
226
|
+
|
|
227
|
+
Returns ``(direction, offset)``: ``direction`` is the unit vector (axis-aligned)
|
|
228
|
+
pointing out of the board through the connector's opening, and the board edge
|
|
229
|
+
belongs on the line ``direction . p == offset``. KiCad's library footprints mark
|
|
230
|
+
this with a "PCB Edge" note and a line on a drawing layer; without that marker,
|
|
231
|
+
connectors from edge-mount libraries (USB, barrel, audio, RJ, coax, cards) use
|
|
232
|
+
the courtyard side furthest from the pads.
|
|
233
|
+
"""
|
|
234
|
+
lib_id = lib_id or (str(node[1]) if len(node) > 1 else "")
|
|
235
|
+
pads = []
|
|
236
|
+
for pad in find_all(node, "pad"):
|
|
237
|
+
at = find(pad, "at")
|
|
238
|
+
if at is not None and len(at) > 2:
|
|
239
|
+
pads.append((num(at[1]), num(at[2])))
|
|
240
|
+
if not pads:
|
|
241
|
+
return None
|
|
242
|
+
cx = sum(p[0] for p in pads) / len(pads)
|
|
243
|
+
cy = sum(p[1] for p in pads) / len(pads)
|
|
244
|
+
|
|
245
|
+
marker = None
|
|
246
|
+
for child in node:
|
|
247
|
+
if (
|
|
248
|
+
isinstance(child, list)
|
|
249
|
+
and child
|
|
250
|
+
and child[0] in ("fp_text", "property")
|
|
251
|
+
and any(isinstance(a, str) and "pcb edge" in a.lower() for a in child[1:3])
|
|
252
|
+
):
|
|
253
|
+
at = find(child, "at")
|
|
254
|
+
if at is not None and len(at) > 2:
|
|
255
|
+
marker = (num(at[1]), num(at[2]))
|
|
256
|
+
break
|
|
257
|
+
if marker is not None:
|
|
258
|
+
best = None
|
|
259
|
+
for line in find_all(node, "fp_line"):
|
|
260
|
+
if str(value(line, "layer", "")) not in _EDGE_LAYERS:
|
|
261
|
+
continue
|
|
262
|
+
start, end = find(line, "start"), find(line, "end")
|
|
263
|
+
if start is None or end is None:
|
|
264
|
+
continue
|
|
265
|
+
x0, y0, x1, y1 = num(start[1]), num(start[2]), num(end[1]), num(end[2])
|
|
266
|
+
if abs(y0 - y1) < 1e-3 and abs(x1 - x0) >= 2:
|
|
267
|
+
axis, coord = "y", y0
|
|
268
|
+
distance = abs(marker[1] - y0) + max(
|
|
269
|
+
0.0, min(x0, x1) - marker[0], marker[0] - max(x0, x1)
|
|
270
|
+
)
|
|
271
|
+
elif abs(x0 - x1) < 1e-3 and abs(y1 - y0) >= 2:
|
|
272
|
+
axis, coord = "x", x0
|
|
273
|
+
distance = abs(marker[0] - x0) + max(
|
|
274
|
+
0.0, min(y0, y1) - marker[1], marker[1] - max(y0, y1)
|
|
275
|
+
)
|
|
276
|
+
else:
|
|
277
|
+
continue
|
|
278
|
+
if best is None or distance < best[0]:
|
|
279
|
+
best = (distance, axis, coord)
|
|
280
|
+
if best is not None and best[0] < 3.0:
|
|
281
|
+
_, axis, coord = best
|
|
282
|
+
if axis == "y":
|
|
283
|
+
sign = 1.0 if coord >= cy else -1.0
|
|
284
|
+
return (0.0, sign), sign * coord
|
|
285
|
+
sign = 1.0 if coord >= cx else -1.0
|
|
286
|
+
return (sign, 0.0), sign * coord
|
|
287
|
+
if not lib_id.startswith(_EDGE_LIBRARIES) or "Vertical" in lib_id:
|
|
288
|
+
return None
|
|
289
|
+
xs: list[float] = []
|
|
290
|
+
ys: list[float] = []
|
|
291
|
+
for child in node:
|
|
292
|
+
if isinstance(child, list) and child and str(child[0]).startswith("fp_"):
|
|
293
|
+
if str(value(child, "layer", "")).endswith("CrtYd"):
|
|
294
|
+
for key in ("start", "end"):
|
|
295
|
+
point = find(child, key)
|
|
296
|
+
if point is not None:
|
|
297
|
+
xs.append(num(point[1]))
|
|
298
|
+
ys.append(num(point[2]))
|
|
299
|
+
if not xs:
|
|
300
|
+
return None
|
|
301
|
+
sides = [
|
|
302
|
+
((1.0, 0.0), max(xs), max(xs) - cx),
|
|
303
|
+
((-1.0, 0.0), -min(xs), cx - min(xs)),
|
|
304
|
+
((0.0, 1.0), max(ys), max(ys) - cy),
|
|
305
|
+
((0.0, -1.0), -min(ys), cy - min(ys)),
|
|
306
|
+
]
|
|
307
|
+
direction, offset, _ = max(sides, key=lambda s: s[2])
|
|
308
|
+
return direction, offset
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _net_name(node: Node, nets: dict[int, str]) -> str:
|
|
312
|
+
net = find(node, "net")
|
|
313
|
+
if net is None or len(net) < 2:
|
|
314
|
+
return ""
|
|
315
|
+
if len(net) >= 3:
|
|
316
|
+
return str(net[2])
|
|
317
|
+
# KiCad 9 can write (net "name"); older files write (net <code>) on tracks.
|
|
318
|
+
first = net[1]
|
|
319
|
+
if isinstance(first, sexpr.Sym):
|
|
320
|
+
return nets.get(int(num(first, -1)), "")
|
|
321
|
+
return str(first)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _footprint_text(node: Node) -> list[tuple[str, str, float]]:
|
|
325
|
+
texts: list[tuple[str, str, float]] = []
|
|
326
|
+
for child in [
|
|
327
|
+
*find_all(node, "fp_text"),
|
|
328
|
+
*find_all(node, "property"),
|
|
329
|
+
*find_all(node, "gr_text"),
|
|
330
|
+
]:
|
|
331
|
+
layer = value(child, "layer")
|
|
332
|
+
if layer is None:
|
|
333
|
+
continue
|
|
334
|
+
effects = find(child, "effects")
|
|
335
|
+
font = find(effects, "font") if effects else None
|
|
336
|
+
size = find(font, "size") if font else None
|
|
337
|
+
if is_hidden(child):
|
|
338
|
+
continue
|
|
339
|
+
text = (
|
|
340
|
+
str(child[2])
|
|
341
|
+
if child[0] in {"fp_text", "property"} and len(child) > 2
|
|
342
|
+
else str(child[1])
|
|
343
|
+
)
|
|
344
|
+
texts.append((str(layer), text, num(size[2]) if size and len(size) > 2 else 1.0))
|
|
345
|
+
return texts
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _read_footprint(node: Node, nets: dict[int, str]) -> Footprint:
|
|
349
|
+
at = find(node, "at") or [None, 0, 0]
|
|
350
|
+
fx, fy = num(at[1]), num(at[2])
|
|
351
|
+
angle = num(at[3]) if len(at) > 3 else 0.0
|
|
352
|
+
reference = prop(node, "Reference") or ""
|
|
353
|
+
footprint_value = prop(node, "Value") or ""
|
|
354
|
+
for text in find_all(node, "fp_text"): # KiCad 6/7
|
|
355
|
+
if len(text) > 2 and text[1] == "reference":
|
|
356
|
+
reference = reference or str(text[2])
|
|
357
|
+
if len(text) > 2 and text[1] == "value":
|
|
358
|
+
footprint_value = footprint_value or str(text[2])
|
|
359
|
+
pads: list[Pad] = []
|
|
360
|
+
for pad in find_all(node, "pad"):
|
|
361
|
+
pad_at = find(pad, "at") or [None, 0, 0]
|
|
362
|
+
dx, dy = _rotate(num(pad_at[1]), num(pad_at[2]), angle)
|
|
363
|
+
size = find(pad, "size") or [None, 0, 0]
|
|
364
|
+
width, height = num(size[1]), num(size[2])
|
|
365
|
+
pad_angle = num(pad_at[3]) if len(pad_at) > 3 else angle
|
|
366
|
+
if round(pad_angle) % 180 == 90:
|
|
367
|
+
width, height = height, width
|
|
368
|
+
layers_node = find(pad, "layers")
|
|
369
|
+
drill = find(pad, "drill")
|
|
370
|
+
drill_size = 0.0
|
|
371
|
+
if drill is not None:
|
|
372
|
+
numbers = [num(a) for a in drill[1:] if isinstance(a, sexpr.Sym) and a != "oval"]
|
|
373
|
+
drill_size = min(numbers) if numbers else 0.0
|
|
374
|
+
pads.append(
|
|
375
|
+
Pad(
|
|
376
|
+
number=str(pad[1]),
|
|
377
|
+
kind=str(pad[2]) if len(pad) > 2 else "smd",
|
|
378
|
+
shape=str(pad[3]) if len(pad) > 3 else "rect",
|
|
379
|
+
x=fx + dx,
|
|
380
|
+
y=fy + dy,
|
|
381
|
+
width=width,
|
|
382
|
+
height=height,
|
|
383
|
+
layers=[str(a) for a in layers_node[1:]] if layers_node else [],
|
|
384
|
+
net=_net_name(pad, nets),
|
|
385
|
+
drill=drill_size,
|
|
386
|
+
function=pin_function(value(pad, "pinfunction", ""), str(pad[1])),
|
|
387
|
+
castellated=any(
|
|
388
|
+
str(p[1]) == "pad_prop_castellated"
|
|
389
|
+
for p in find_all(pad, "property")
|
|
390
|
+
if len(p) > 1
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
attrs_node = find(node, "attr")
|
|
395
|
+
fields = {str(c[1]): str(c[2]) for c in find_all(node, "property") if len(c) >= 3}
|
|
396
|
+
classes_node = find(node, "component_classes")
|
|
397
|
+
jumpers_node = find(node, "jumper_pad_groups")
|
|
398
|
+
courtyard = _courtyard(node, fx, fy, angle)
|
|
399
|
+
edge = None
|
|
400
|
+
marker = edge_marker(node)
|
|
401
|
+
if marker is not None:
|
|
402
|
+
(ux, uy), offset = marker
|
|
403
|
+
dx, dy = _rotate(ux, uy, angle)
|
|
404
|
+
px, py = _rotate(ux * offset, uy * offset, angle)
|
|
405
|
+
edge = ((round(dx, 6), round(dy, 6)), (fx + px, fy + py))
|
|
406
|
+
return Footprint(
|
|
407
|
+
lib_id=str(node[1]) if len(node) > 1 else "",
|
|
408
|
+
reference=reference,
|
|
409
|
+
value=footprint_value,
|
|
410
|
+
x=fx,
|
|
411
|
+
y=fy,
|
|
412
|
+
angle=angle,
|
|
413
|
+
layer=str(value(node, "layer", "F.Cu")),
|
|
414
|
+
pads=pads,
|
|
415
|
+
uuid=str(value(node, "uuid", value(node, "tstamp", ""))),
|
|
416
|
+
path=str(value(node, "path", "")),
|
|
417
|
+
attrs=[str(a) for a in attrs_node[1:]] if attrs_node else [],
|
|
418
|
+
fields=fields,
|
|
419
|
+
texts=_footprint_text(node),
|
|
420
|
+
courtyard=courtyard,
|
|
421
|
+
edge=edge,
|
|
422
|
+
sheetname=str(value(node, "sheetname", fields.get("Sheetname", ""))),
|
|
423
|
+
sheetfile=str(value(node, "sheetfile", fields.get("Sheetfile", ""))),
|
|
424
|
+
component_classes=[str(c[1]) for c in find_all(classes_node, "class") if len(c) > 1]
|
|
425
|
+
if classes_node
|
|
426
|
+
else [],
|
|
427
|
+
jumper_pad_groups=[
|
|
428
|
+
[str(a) for a in group] for group in jumpers_node[1:] if isinstance(group, list)
|
|
429
|
+
]
|
|
430
|
+
if jumpers_node
|
|
431
|
+
else [],
|
|
432
|
+
duplicate_pads_are_jumpers=flag(node, "duplicate_pad_numbers_are_jumpers"),
|
|
433
|
+
locked=flag(node, "locked"),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _courtyard(node: Node, fx: float, fy: float, angle: float):
|
|
438
|
+
xs: list[float] = []
|
|
439
|
+
ys: list[float] = []
|
|
440
|
+
for child in node:
|
|
441
|
+
if not isinstance(child, list) or not child or not str(child[0]).startswith("fp_"):
|
|
442
|
+
continue
|
|
443
|
+
layer = str(value(child, "layer", ""))
|
|
444
|
+
if not layer.endswith("CrtYd"):
|
|
445
|
+
continue
|
|
446
|
+
for key in ("start", "end", "center", "mid"):
|
|
447
|
+
point = find(child, key)
|
|
448
|
+
if point is not None:
|
|
449
|
+
dx, dy = _rotate(num(point[1]), num(point[2]), angle)
|
|
450
|
+
xs.append(fx + dx)
|
|
451
|
+
ys.append(fy + dy)
|
|
452
|
+
pts = find(child, "pts")
|
|
453
|
+
if pts is not None:
|
|
454
|
+
for xy in find_all(pts, "xy"):
|
|
455
|
+
dx, dy = _rotate(num(xy[1]), num(xy[2]), angle)
|
|
456
|
+
xs.append(fx + dx)
|
|
457
|
+
ys.append(fy + dy)
|
|
458
|
+
if not xs:
|
|
459
|
+
return None
|
|
460
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _outline(root: Node) -> tuple[float, float, float, float] | None:
|
|
464
|
+
xs: list[float] = []
|
|
465
|
+
ys: list[float] = []
|
|
466
|
+
for child in root:
|
|
467
|
+
if not isinstance(child, list) or not child or not str(child[0]).startswith("gr_"):
|
|
468
|
+
continue
|
|
469
|
+
if str(value(child, "layer", "")) != "Edge.Cuts":
|
|
470
|
+
continue
|
|
471
|
+
if child[0] == "gr_circle":
|
|
472
|
+
center, end = find(child, "center"), find(child, "end")
|
|
473
|
+
if center and end:
|
|
474
|
+
radius = math.hypot(num(end[1]) - num(center[1]), num(end[2]) - num(center[2]))
|
|
475
|
+
xs += [num(center[1]) - radius, num(center[1]) + radius]
|
|
476
|
+
ys += [num(center[2]) - radius, num(center[2]) + radius]
|
|
477
|
+
continue
|
|
478
|
+
for key in ("start", "end", "mid"):
|
|
479
|
+
point = find(child, key)
|
|
480
|
+
if point is not None:
|
|
481
|
+
xs.append(num(point[1]))
|
|
482
|
+
ys.append(num(point[2]))
|
|
483
|
+
pts = find(child, "pts")
|
|
484
|
+
if pts is not None:
|
|
485
|
+
for xy in find_all(pts, "xy"):
|
|
486
|
+
xs.append(num(xy[1]))
|
|
487
|
+
ys.append(num(xy[2]))
|
|
488
|
+
if not xs:
|
|
489
|
+
return None
|
|
490
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _edge_segments(root: Node) -> list[tuple[float, float, float, float]]:
|
|
494
|
+
"""Edge.Cuts lines, arcs, circles, rectangles and polygons as straight segments."""
|
|
495
|
+
segments: list[tuple[float, float, float, float]] = []
|
|
496
|
+
|
|
497
|
+
def path(points: list[tuple[float, float]], closed: bool = False) -> None:
|
|
498
|
+
if closed and points and points[0] != points[-1]:
|
|
499
|
+
points = [*points, points[0]]
|
|
500
|
+
segments.extend((a[0], a[1], b[0], b[1]) for a, b in zip(points, points[1:]))
|
|
501
|
+
|
|
502
|
+
def xy(node: Node | None) -> tuple[float, float]:
|
|
503
|
+
return (num(node[1]), num(node[2])) if node is not None and len(node) > 2 else (0.0, 0.0)
|
|
504
|
+
|
|
505
|
+
for child in root:
|
|
506
|
+
if not isinstance(child, list) or not child or not str(child[0]).startswith("gr_"):
|
|
507
|
+
continue
|
|
508
|
+
if str(value(child, "layer", "")) != "Edge.Cuts":
|
|
509
|
+
continue
|
|
510
|
+
kind = str(child[0])
|
|
511
|
+
if kind == "gr_line":
|
|
512
|
+
path([xy(find(child, "start")), xy(find(child, "end"))])
|
|
513
|
+
elif kind == "gr_arc":
|
|
514
|
+
path(
|
|
515
|
+
arc_points(xy(find(child, "start")), xy(find(child, "mid")), xy(find(child, "end")))
|
|
516
|
+
)
|
|
517
|
+
elif kind == "gr_circle":
|
|
518
|
+
center, end = xy(find(child, "center")), xy(find(child, "end"))
|
|
519
|
+
path(circle_points(center, math.hypot(end[0] - center[0], end[1] - center[1])))
|
|
520
|
+
elif kind == "gr_rect":
|
|
521
|
+
(ax, ay), (bx, by) = xy(find(child, "start")), xy(find(child, "end"))
|
|
522
|
+
path([(ax, ay), (bx, ay), (bx, by), (ax, by)], closed=True)
|
|
523
|
+
elif kind == "gr_poly":
|
|
524
|
+
pts = find(child, "pts")
|
|
525
|
+
if pts is not None:
|
|
526
|
+
path([xy(p) for p in find_all(pts, "xy")], closed=True)
|
|
527
|
+
return segments
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _rules(root: Node) -> dict[str, float]:
|
|
531
|
+
rules: dict[str, float] = {}
|
|
532
|
+
setup = find(root, "setup")
|
|
533
|
+
if setup is not None:
|
|
534
|
+
for key in ("pad_to_mask_clearance", "solder_mask_min_width"):
|
|
535
|
+
item = value(setup, key)
|
|
536
|
+
if item is not None:
|
|
537
|
+
rules[key] = num(item)
|
|
538
|
+
return rules
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def read_board(path: str | Path) -> Board:
|
|
542
|
+
path = Path(path)
|
|
543
|
+
root = sexpr.parse(path.read_text(encoding="utf-8"))
|
|
544
|
+
if root[0] != "kicad_pcb":
|
|
545
|
+
raise ValueError(f"{path} is not a KiCad board (found {root[0]!r})")
|
|
546
|
+
nets: dict[int, str] = {}
|
|
547
|
+
for net in find_all(root, "net"):
|
|
548
|
+
if len(net) >= 3:
|
|
549
|
+
nets[int(num(net[1]))] = str(net[2])
|
|
550
|
+
if not nets:
|
|
551
|
+
# KiCad 10 stores net names on the items only; give them stable local codes.
|
|
552
|
+
names: set[str] = set()
|
|
553
|
+
for item in sexpr.walk(root):
|
|
554
|
+
if item and item[0] in {"pad", "segment", "arc", "via", "zone"}:
|
|
555
|
+
net = find(item, "net")
|
|
556
|
+
if net is not None and len(net) >= 2 and not isinstance(net[1], sexpr.Sym):
|
|
557
|
+
names.add(str(net[1]))
|
|
558
|
+
nets = {0: ""}
|
|
559
|
+
for index, name in enumerate(sorted(names - {""}), start=1):
|
|
560
|
+
nets[index] = name
|
|
561
|
+
copper: list[str] = []
|
|
562
|
+
layers = find(root, "layers")
|
|
563
|
+
if layers is not None:
|
|
564
|
+
for layer in layers[1:]:
|
|
565
|
+
if isinstance(layer, list) and len(layer) >= 3 and str(layer[1]).endswith(".Cu"):
|
|
566
|
+
copper.append(str(layer[1]))
|
|
567
|
+
general = find(root, "general")
|
|
568
|
+
tracks: list[Track] = []
|
|
569
|
+
for seg in [*find_all(root, "segment"), *find_all(root, "arc")]:
|
|
570
|
+
start, end = find(seg, "start"), find(seg, "end")
|
|
571
|
+
if start is None or end is None:
|
|
572
|
+
continue
|
|
573
|
+
mid = find(seg, "mid")
|
|
574
|
+
tracks.append(
|
|
575
|
+
Track(
|
|
576
|
+
num(start[1]),
|
|
577
|
+
num(start[2]),
|
|
578
|
+
num(end[1]),
|
|
579
|
+
num(end[2]),
|
|
580
|
+
num(value(seg, "width", 0)),
|
|
581
|
+
str(value(seg, "layer", "")),
|
|
582
|
+
_net_name(seg, nets),
|
|
583
|
+
(num(mid[1]), num(mid[2])) if mid is not None else None,
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
vias: list[Via] = []
|
|
587
|
+
for via in find_all(root, "via"):
|
|
588
|
+
at = find(via, "at") or [None, 0, 0]
|
|
589
|
+
layers_node = find(via, "layers")
|
|
590
|
+
vias.append(
|
|
591
|
+
Via(
|
|
592
|
+
num(at[1]),
|
|
593
|
+
num(at[2]),
|
|
594
|
+
num(value(via, "size", 0)),
|
|
595
|
+
num(value(via, "drill", 0)),
|
|
596
|
+
[str(a) for a in layers_node[1:]] if layers_node else ["F.Cu", "B.Cu"],
|
|
597
|
+
_net_name(via, nets),
|
|
598
|
+
)
|
|
599
|
+
)
|
|
600
|
+
zones = []
|
|
601
|
+
rule_areas = []
|
|
602
|
+
for zone in find_all(root, "zone"):
|
|
603
|
+
layer_names = [str(value(zone, "layer", ""))] if find(zone, "layer") else []
|
|
604
|
+
multi = find(zone, "layers")
|
|
605
|
+
if multi is not None:
|
|
606
|
+
layer_names = [str(a) for a in multi[1:]]
|
|
607
|
+
keepout = find(zone, "keepout")
|
|
608
|
+
if keepout is not None or find(zone, "placement") is not None:
|
|
609
|
+
# A rule area: keepout and/or (KiCad 9+) placement rules, never copper.
|
|
610
|
+
rule_areas.append(
|
|
611
|
+
{
|
|
612
|
+
"name": str(value(zone, "name", "")),
|
|
613
|
+
"layers": layer_names,
|
|
614
|
+
"keepout": {
|
|
615
|
+
str(c[0]): str(c[1])
|
|
616
|
+
for c in (keepout[1:] if keepout is not None else [])
|
|
617
|
+
if isinstance(c, list) and len(c) > 1
|
|
618
|
+
},
|
|
619
|
+
}
|
|
620
|
+
)
|
|
621
|
+
continue
|
|
622
|
+
zones.append(
|
|
623
|
+
{
|
|
624
|
+
"net": str(value(zone, "net_name", "")) or _net_name(zone, nets),
|
|
625
|
+
"name": str(value(zone, "name", "")),
|
|
626
|
+
"layers": layer_names,
|
|
627
|
+
"filled": find(zone, "filled_polygon") is not None,
|
|
628
|
+
}
|
|
629
|
+
)
|
|
630
|
+
texts = []
|
|
631
|
+
for text in find_all(root, "gr_text"):
|
|
632
|
+
effects = find(text, "effects")
|
|
633
|
+
font = find(effects, "font") if effects else None
|
|
634
|
+
size = find(font, "size") if font else None
|
|
635
|
+
texts.append(
|
|
636
|
+
(
|
|
637
|
+
str(value(text, "layer", "")),
|
|
638
|
+
str(text[1]),
|
|
639
|
+
num(size[2]) if size and len(size) > 2 else 1.0,
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
return Board(
|
|
643
|
+
path=path,
|
|
644
|
+
version=str(value(root, "version", "")),
|
|
645
|
+
thickness=num(value(general, "thickness", 1.6), 1.6) if general else 1.6,
|
|
646
|
+
copper_layers=copper or ["F.Cu", "B.Cu"],
|
|
647
|
+
nets=nets,
|
|
648
|
+
footprints=[
|
|
649
|
+
_read_footprint(fp, nets)
|
|
650
|
+
for fp in [*find_all(root, "footprint"), *find_all(root, "module")]
|
|
651
|
+
],
|
|
652
|
+
tracks=tracks,
|
|
653
|
+
vias=vias,
|
|
654
|
+
zones=zones,
|
|
655
|
+
outline=_outline(root),
|
|
656
|
+
edge_segments=_edge_segments(root),
|
|
657
|
+
texts=texts,
|
|
658
|
+
rules=_rules(root),
|
|
659
|
+
generator=str(value(root, "generator", "")),
|
|
660
|
+
generator_version=str(value(root, "generator_version", "")),
|
|
661
|
+
embedded_files=_embedded_files(root),
|
|
662
|
+
tables=len(find_all(root, "table")),
|
|
663
|
+
rule_areas=rule_areas,
|
|
664
|
+
variants=[
|
|
665
|
+
str(value(v, "name", v[1] if len(v) > 1 and not isinstance(v[1], list) else ""))
|
|
666
|
+
for v in find_all(find(root, "variants") or [], "variant")
|
|
667
|
+
],
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _embedded_files(root: Node) -> list[str]:
|
|
672
|
+
"""Names of files embedded in a KiCad 9+ board or schematic (fonts, datasheets, models)."""
|
|
673
|
+
files = find(root, "embedded_files")
|
|
674
|
+
if files is None:
|
|
675
|
+
return []
|
|
676
|
+
return [str(value(f, "name", "")) for f in find_all(files, "file")]
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
# --------------------------------------------------------------------------- connectivity
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
class _UnionFind:
|
|
683
|
+
def __init__(self) -> None:
|
|
684
|
+
self.parent: dict[int, int] = {}
|
|
685
|
+
|
|
686
|
+
def find(self, item: int) -> int:
|
|
687
|
+
self.parent.setdefault(item, item)
|
|
688
|
+
while self.parent[item] != item:
|
|
689
|
+
self.parent[item] = self.parent[self.parent[item]]
|
|
690
|
+
item = self.parent[item]
|
|
691
|
+
return item
|
|
692
|
+
|
|
693
|
+
def union(self, a: int, b: int) -> None:
|
|
694
|
+
ra, rb = self.find(a), self.find(b)
|
|
695
|
+
if ra != rb:
|
|
696
|
+
self.parent[ra] = rb
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
@dataclass
|
|
700
|
+
class NetStatus:
|
|
701
|
+
net: str
|
|
702
|
+
pads: int
|
|
703
|
+
islands: int # connected groups of pads; 1 means fully routed
|
|
704
|
+
|
|
705
|
+
@property
|
|
706
|
+
def unrouted(self) -> int:
|
|
707
|
+
return max(0, self.islands - 1)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def connectivity(board: Board, tolerance: float = 0.005) -> list[NetStatus]:
|
|
711
|
+
"""Approximate routing completeness per net.
|
|
712
|
+
|
|
713
|
+
Pads, track ends and vias of the same net are joined when they touch.
|
|
714
|
+
Zones with fill are treated as connecting every pad of their net on their
|
|
715
|
+
layers. This mirrors KiCad's ratsnest closely for typical boards; DRC from
|
|
716
|
+
``kicad-cli`` remains the authority.
|
|
717
|
+
"""
|
|
718
|
+
results: list[NetStatus] = []
|
|
719
|
+
by_net: dict[str, list[Pad]] = {}
|
|
720
|
+
for footprint in board.footprints:
|
|
721
|
+
for pad in footprint.pads:
|
|
722
|
+
if pad.net and pad.kind != "np_thru_hole":
|
|
723
|
+
by_net.setdefault(pad.net, []).append(pad)
|
|
724
|
+
zone_layers: dict[str, set[str]] = {}
|
|
725
|
+
for zone in board.zones:
|
|
726
|
+
if zone["net"] and zone["filled"]:
|
|
727
|
+
zone_layers.setdefault(zone["net"], set()).update(zone["layers"])
|
|
728
|
+
|
|
729
|
+
for net, pads in sorted(by_net.items()):
|
|
730
|
+
if len(pads) < 2:
|
|
731
|
+
results.append(NetStatus(net, len(pads), len(pads)))
|
|
732
|
+
continue
|
|
733
|
+
uf = _UnionFind()
|
|
734
|
+
tracks = [t for t in board.tracks if t.net == net]
|
|
735
|
+
vias = [v for v in board.vias if v.net == net]
|
|
736
|
+
# node ids: pads 0..P-1, track ends after, vias after
|
|
737
|
+
points: list[tuple[float, float, set[str], int]] = []
|
|
738
|
+
for i in range(len(pads)):
|
|
739
|
+
uf.find(i)
|
|
740
|
+
base = len(pads)
|
|
741
|
+
for j, track in enumerate(tracks):
|
|
742
|
+
a, b = base + 2 * j, base + 2 * j + 1
|
|
743
|
+
uf.union(a, b)
|
|
744
|
+
points.append((track.x1, track.y1, {track.layer}, a))
|
|
745
|
+
points.append((track.x2, track.y2, {track.layer}, b))
|
|
746
|
+
via_base = base + 2 * len(tracks)
|
|
747
|
+
for k, via in enumerate(vias):
|
|
748
|
+
layers = (
|
|
749
|
+
set(board.copper_layers) if via.layers[:2] == ["F.Cu", "B.Cu"] else set(via.layers)
|
|
750
|
+
)
|
|
751
|
+
points.append((via.x, via.y, layers, via_base + k))
|
|
752
|
+
for x, y, layers, node in points:
|
|
753
|
+
for i, pad in enumerate(pads):
|
|
754
|
+
if any(pad.on_layer(layer) for layer in layers) and pad.contains(x, y, tolerance):
|
|
755
|
+
uf.union(node, i)
|
|
756
|
+
for index, (x1, y1, l1, n1) in enumerate(points):
|
|
757
|
+
for x2, y2, l2, n2 in points[index + 1 :]:
|
|
758
|
+
if l1 & l2 and abs(x1 - x2) <= tolerance and abs(y1 - y2) <= tolerance:
|
|
759
|
+
uf.union(n1, n2)
|
|
760
|
+
# Track ends that land on the middle of another track (T-junctions).
|
|
761
|
+
for x, y, layers, node in points:
|
|
762
|
+
for j, track in enumerate(tracks):
|
|
763
|
+
if track.layer in layers and _on_segment(x, y, track, tolerance + track.width / 2):
|
|
764
|
+
uf.union(node, base + 2 * j)
|
|
765
|
+
if net in zone_layers:
|
|
766
|
+
# A filled pour joins every pad, via and track end of its net on its layers.
|
|
767
|
+
# (Islands cut off by other copper are not detected; DRC reports those.)
|
|
768
|
+
zone_node = via_base + len(vias)
|
|
769
|
+
for i, pad in enumerate(pads):
|
|
770
|
+
if any(pad.on_layer(layer) for layer in zone_layers[net]):
|
|
771
|
+
uf.union(zone_node, i)
|
|
772
|
+
for _, _, layers, node in points:
|
|
773
|
+
if layers & zone_layers[net]:
|
|
774
|
+
uf.union(zone_node, node)
|
|
775
|
+
islands = len({uf.find(i) for i in range(len(pads))})
|
|
776
|
+
results.append(NetStatus(net, len(pads), islands))
|
|
777
|
+
return results
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _on_segment(x: float, y: float, track: Track, tolerance: float) -> bool:
|
|
781
|
+
dx, dy = track.x2 - track.x1, track.y2 - track.y1
|
|
782
|
+
length_sq = dx * dx + dy * dy
|
|
783
|
+
if length_sq == 0:
|
|
784
|
+
return math.hypot(x - track.x1, y - track.y1) <= tolerance
|
|
785
|
+
t = max(0.0, min(1.0, ((x - track.x1) * dx + (y - track.y1) * dy) / length_sq))
|
|
786
|
+
px, py = track.x1 + t * dx, track.y1 + t * dy
|
|
787
|
+
return math.hypot(x - px, y - py) <= tolerance
|