traceforge-pcb 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Board edge geometry: the Edge.Cuts shapes as line segments, for inside and distance queries.
|
|
2
|
+
|
|
3
|
+
Boards are rarely plain rectangles: L-shapes, circles, slots and cut-outs all end up as lines,
|
|
4
|
+
arcs, circles and polygons on Edge.Cuts. Every loop, outer or inner, is flattened to segments, so
|
|
5
|
+
"inside the board" is the even-odd rule over all of them and "room" is the signed distance to the
|
|
6
|
+
nearest edge (positive inside). A plain rectangle takes a fast path, so the router and the checks
|
|
7
|
+
cost nothing extra on the common board.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
Point = tuple[float, float]
|
|
16
|
+
Segment = tuple[float, float, float, float]
|
|
17
|
+
|
|
18
|
+
_SAGITTA = 0.02 # mm: how far a flattened arc may stray from the true curve
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def arc_points(start: Point, mid: Point, end: Point) -> list[Point]:
|
|
22
|
+
"""Points along the arc through three points (both ends included)."""
|
|
23
|
+
(ax, ay), (bx, by), (cx, cy) = start, mid, end
|
|
24
|
+
d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
|
|
25
|
+
if abs(d) < 1e-12:
|
|
26
|
+
return [start, end]
|
|
27
|
+
ux = (
|
|
28
|
+
(ax**2 + ay**2) * (by - cy) + (bx**2 + by**2) * (cy - ay) + (cx**2 + cy**2) * (ay - by)
|
|
29
|
+
) / d
|
|
30
|
+
uy = (
|
|
31
|
+
(ax**2 + ay**2) * (cx - bx) + (bx**2 + by**2) * (ax - cx) + (cx**2 + cy**2) * (bx - ax)
|
|
32
|
+
) / d
|
|
33
|
+
radius = math.hypot(ax - ux, ay - uy)
|
|
34
|
+
a0, a1, a2 = (math.atan2(py - uy, px - ux) for px, py in (start, mid, end))
|
|
35
|
+
sweep = (a2 - a0) % (2 * math.pi)
|
|
36
|
+
if (a1 - a0) % (2 * math.pi) > sweep: # the arc runs clockwise
|
|
37
|
+
sweep -= 2 * math.pi
|
|
38
|
+
step = 2 * math.acos(max(0.0, 1 - _SAGITTA / max(radius, _SAGITTA)))
|
|
39
|
+
count = max(2, int(math.ceil(abs(sweep) / max(step, 1e-3))))
|
|
40
|
+
pts = [
|
|
41
|
+
(
|
|
42
|
+
ux + radius * math.cos(a0 + sweep * i / count),
|
|
43
|
+
uy + radius * math.sin(a0 + sweep * i / count),
|
|
44
|
+
)
|
|
45
|
+
for i in range(count + 1)
|
|
46
|
+
]
|
|
47
|
+
pts[0], pts[-1] = start, end
|
|
48
|
+
return pts
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def circle_points(center: Point, radius: float) -> list[Point]:
|
|
52
|
+
cx, cy = center
|
|
53
|
+
step = 2 * math.acos(max(0.0, 1 - _SAGITTA / max(radius, _SAGITTA)))
|
|
54
|
+
count = max(8, int(math.ceil(2 * math.pi / max(step, 1e-3))))
|
|
55
|
+
pts = [
|
|
56
|
+
(
|
|
57
|
+
cx + radius * math.cos(2 * math.pi * i / count),
|
|
58
|
+
cy + radius * math.sin(2 * math.pi * i / count),
|
|
59
|
+
)
|
|
60
|
+
for i in range(count)
|
|
61
|
+
]
|
|
62
|
+
return [*pts, pts[0]]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _seg_dist(px: float, py: float, s: Segment) -> float:
|
|
66
|
+
ax, ay, bx, by = s
|
|
67
|
+
dx, dy = bx - ax, by - ay
|
|
68
|
+
n = dx * dx + dy * dy
|
|
69
|
+
t = 0.0 if n == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / n))
|
|
70
|
+
return math.hypot(px - ax - t * dx, py - ay - t * dy)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _cross(ox: float, oy: float, ax: float, ay: float, bx: float, by: float) -> float:
|
|
74
|
+
return (ax - ox) * (by - oy) - (ay - oy) * (bx - ox)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _seg_seg_dist(a: Segment, b: Segment) -> float:
|
|
78
|
+
ax, ay, bx, by = a
|
|
79
|
+
cx, cy, dx, dy = b
|
|
80
|
+
d1 = _cross(ax, ay, bx, by, cx, cy)
|
|
81
|
+
d2 = _cross(ax, ay, bx, by, dx, dy)
|
|
82
|
+
d3 = _cross(cx, cy, dx, dy, ax, ay)
|
|
83
|
+
d4 = _cross(cx, cy, dx, dy, bx, by)
|
|
84
|
+
if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)) and 0 not in (d1, d2, d3, d4):
|
|
85
|
+
return 0.0
|
|
86
|
+
return min(
|
|
87
|
+
_seg_dist(ax, ay, b), _seg_dist(bx, by, b), _seg_dist(cx, cy, a), _seg_dist(dx, dy, a)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class EdgeGeometry:
|
|
93
|
+
segments: list[Segment]
|
|
94
|
+
bbox: tuple[float, float, float, float] = field(init=False)
|
|
95
|
+
is_rect: bool = field(init=False)
|
|
96
|
+
|
|
97
|
+
def __post_init__(self) -> None:
|
|
98
|
+
xs = [v for s in self.segments for v in (s[0], s[2])]
|
|
99
|
+
ys = [v for s in self.segments for v in (s[1], s[3])]
|
|
100
|
+
self.bbox = (min(xs), min(ys), max(xs), max(ys)) if xs else (0.0, 0.0, 0.0, 0.0)
|
|
101
|
+
x0, y0, x1, y1 = self.bbox
|
|
102
|
+
eps = 1e-6
|
|
103
|
+
self.is_rect = all(
|
|
104
|
+
(abs(s[0] - s[2]) < eps and (abs(s[0] - x0) < eps or abs(s[0] - x1) < eps))
|
|
105
|
+
or (abs(s[1] - s[3]) < eps and (abs(s[1] - y0) < eps or abs(s[1] - y1) < eps))
|
|
106
|
+
for s in self.segments
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# -- point queries
|
|
110
|
+
def inside(self, x: float, y: float) -> bool:
|
|
111
|
+
if self.is_rect:
|
|
112
|
+
x0, y0, x1, y1 = self.bbox
|
|
113
|
+
return x0 <= x <= x1 and y0 <= y <= y1
|
|
114
|
+
hit = False
|
|
115
|
+
for ax, ay, bx, by in self.segments:
|
|
116
|
+
if (ay > y) != (by > y) and x < ax + (y - ay) * (bx - ax) / (by - ay):
|
|
117
|
+
hit = not hit
|
|
118
|
+
return hit
|
|
119
|
+
|
|
120
|
+
def distance(self, x: float, y: float) -> float:
|
|
121
|
+
"""Distance to the nearest edge segment."""
|
|
122
|
+
return min((_seg_dist(x, y, s) for s in self.segments), default=math.inf)
|
|
123
|
+
|
|
124
|
+
def room(self, x: float, y: float) -> float:
|
|
125
|
+
"""Signed clearance to the edge: positive inside the board, negative outside."""
|
|
126
|
+
if self.is_rect:
|
|
127
|
+
x0, y0, x1, y1 = self.bbox
|
|
128
|
+
return min(x - x0, x1 - x, y - y0, y1 - y)
|
|
129
|
+
d = self.distance(x, y)
|
|
130
|
+
return d if self.inside(x, y) else -d
|
|
131
|
+
|
|
132
|
+
def segment_room(self, ax: float, ay: float, bx: float, by: float) -> float:
|
|
133
|
+
"""Smallest clearance along a straight run (negative if it leaves the board)."""
|
|
134
|
+
if self.is_rect:
|
|
135
|
+
return min(self.room(ax, ay), self.room(bx, by))
|
|
136
|
+
room = min(self.room(ax, ay), self.room(bx, by))
|
|
137
|
+
probe = (ax, ay, bx, by)
|
|
138
|
+
lo = min(ax, bx) - max(room, 0.0)
|
|
139
|
+
for s in self.segments:
|
|
140
|
+
if max(s[0], s[2]) < lo or min(s[0], s[2]) > max(ax, bx) + max(room, 0.0):
|
|
141
|
+
continue
|
|
142
|
+
d = _seg_seg_dist(probe, s)
|
|
143
|
+
if d < room:
|
|
144
|
+
room = d if d > 0 else -1e-3
|
|
145
|
+
return room
|
|
146
|
+
|
|
147
|
+
# -- grid queries
|
|
148
|
+
def blocked_cells(
|
|
149
|
+
self, x0: float, y0: float, step: float, w: int, h: int, margin: float
|
|
150
|
+
) -> bytearray:
|
|
151
|
+
"""Grid cells (index ``gy * w + gx``) outside the board or closer than ``margin`` to an edge."""
|
|
152
|
+
out = bytearray(w * h)
|
|
153
|
+
if self.is_rect:
|
|
154
|
+
bx0, by0, bx1, by1 = self.bbox
|
|
155
|
+
for gy in range(h):
|
|
156
|
+
y = y0 + gy * step
|
|
157
|
+
for gx in range(w):
|
|
158
|
+
x = x0 + gx * step
|
|
159
|
+
if min(x - bx0, bx1 - x, y - by0, by1 - y) < margin:
|
|
160
|
+
out[gy * w + gx] = 1
|
|
161
|
+
return out
|
|
162
|
+
for gy in range(h):
|
|
163
|
+
y = y0 + gy * step
|
|
164
|
+
xs = sorted(
|
|
165
|
+
ax + (y - ay) * (bx - ax) / (by - ay)
|
|
166
|
+
for ax, ay, bx, by in self.segments
|
|
167
|
+
if (ay > y) != (by > y)
|
|
168
|
+
)
|
|
169
|
+
gx = 0
|
|
170
|
+
row = gy * w
|
|
171
|
+
for lo, hi in zip(xs[0::2], xs[1::2]):
|
|
172
|
+
first = max(0, int(math.ceil((lo - x0) / step)))
|
|
173
|
+
last = min(w - 1, int(math.floor((hi - x0) / step)))
|
|
174
|
+
for cell in range(gx, min(first, w)):
|
|
175
|
+
out[row + cell] = 1
|
|
176
|
+
gx = max(gx, last + 1)
|
|
177
|
+
for cell in range(gx, w):
|
|
178
|
+
out[row + cell] = 1
|
|
179
|
+
for ax, ay, bx, by in self.segments:
|
|
180
|
+
gx0 = max(0, int((min(ax, bx) - margin - x0) / step) - 1)
|
|
181
|
+
gx1 = min(w - 1, int((max(ax, bx) + margin - x0) / step) + 1)
|
|
182
|
+
gy0 = max(0, int((min(ay, by) - margin - y0) / step) - 1)
|
|
183
|
+
gy1 = min(h - 1, int((max(ay, by) + margin - y0) / step) + 1)
|
|
184
|
+
seg = (ax, ay, bx, by)
|
|
185
|
+
for gy in range(gy0, gy1 + 1):
|
|
186
|
+
y = y0 + gy * step
|
|
187
|
+
row = gy * w
|
|
188
|
+
for gx in range(gx0, gx1 + 1):
|
|
189
|
+
if not out[row + gx] and _seg_dist(x0 + gx * step, y, seg) < margin:
|
|
190
|
+
out[row + gx] = 1
|
|
191
|
+
return out
|
traceforge/kicad/edit.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Structured edits to KiCad files.
|
|
2
|
+
|
|
3
|
+
The agent can always edit S-expressions as text, but these helpers make the
|
|
4
|
+
common operations exact: they keep UUIDs unique, nets consistent and rotate
|
|
5
|
+
pads along with their footprint.
|
|
6
|
+
|
|
7
|
+
Edits keep the file's own format: new items use ``uuid`` or ``tstamp`` and net
|
|
8
|
+
codes or (KiCad 10) net names, whichever the file uses. :func:`convert_file`
|
|
9
|
+
rewrites a whole file for another KiCad release.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from traceforge import sexpr
|
|
18
|
+
from traceforge.kicad import formats
|
|
19
|
+
from traceforge.sexpr import Node, Sym, find, find_all, fmt, num, prop, set_prop, value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EditError(ValueError):
|
|
23
|
+
"""The requested edit does not apply to this file."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load(path: Path) -> Node:
|
|
27
|
+
return sexpr.parse(Path(path).read_text(encoding="utf-8"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def save(path: Path, root: Node) -> None:
|
|
31
|
+
Path(path).write_text(sexpr.dumps(root), encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _new_uuid() -> str:
|
|
35
|
+
return str(uuid.uuid4())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _stamp_key(root: Node) -> str:
|
|
39
|
+
# KiCad 7 boards use (tstamp ...); KiCad 8+ use (uuid ...).
|
|
40
|
+
return formats.stamp_key(root)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _footprint_ref(node: Node) -> str:
|
|
44
|
+
reference = prop(node, "Reference")
|
|
45
|
+
if reference:
|
|
46
|
+
return reference
|
|
47
|
+
for text in find_all(node, "fp_text"):
|
|
48
|
+
if len(text) > 2 and text[1] == "reference":
|
|
49
|
+
return str(text[2])
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def footprints(root: Node) -> dict[str, Node]:
|
|
54
|
+
return {
|
|
55
|
+
_footprint_ref(fp): fp for fp in [*find_all(root, "footprint"), *find_all(root, "module")]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def net_codes(root: Node) -> dict[str, int]:
|
|
60
|
+
"""Net name -> code. KiCad 10 boards have no codes; their nets get local numbers."""
|
|
61
|
+
codes = {str(n[2]): int(num(n[1])) for n in find_all(root, "net") if len(n) >= 3}
|
|
62
|
+
if codes:
|
|
63
|
+
return codes
|
|
64
|
+
return {name: i for i, name in enumerate(formats.board_net_names(root), start=1)}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _net_node(root: Node, net: str) -> Node:
|
|
68
|
+
try:
|
|
69
|
+
return formats.board_net_node(root, net)
|
|
70
|
+
except KeyError:
|
|
71
|
+
raise EditError(f"net {net!r} does not exist on the board") from None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _item_net(root: Node, item: Node, codes: dict[str, int]) -> str | None:
|
|
75
|
+
net = find(item, "net")
|
|
76
|
+
if net is None or len(net) < 2:
|
|
77
|
+
return None
|
|
78
|
+
if len(net) >= 3 or not isinstance(net[1], Sym):
|
|
79
|
+
return str(net[-1])
|
|
80
|
+
by_code = {code: name for name, code in codes.items()}
|
|
81
|
+
return by_code.get(int(num(net[1], -1)))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def convert_file(path: Path, kicad_version: int | str, out: Path | None = None) -> dict:
|
|
85
|
+
"""Rewrite a schematic, board, footprint or symbol library for another KiCad release."""
|
|
86
|
+
root = load(path)
|
|
87
|
+
before = formats.root_info(root)
|
|
88
|
+
target = formats.resolve_target(kicad_version)
|
|
89
|
+
formats.convert(root, target)
|
|
90
|
+
save(out or path, root)
|
|
91
|
+
return {"from": before.to_dict(), "to": formats.root_info(root).to_dict()}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ----------------------------------------------------------------------- schematic
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sch_set_field(path: Path, reference: str, field_name: str, new_value: str) -> int:
|
|
98
|
+
"""Set a field on every unit of a schematic symbol. Returns units changed."""
|
|
99
|
+
root = load(path)
|
|
100
|
+
changed = 0
|
|
101
|
+
for symbol in find_all(root, "symbol"):
|
|
102
|
+
if value(symbol, "lib_id") is None:
|
|
103
|
+
continue
|
|
104
|
+
ref = prop(symbol, "Reference")
|
|
105
|
+
if ref != reference:
|
|
106
|
+
instances = find(symbol, "instances")
|
|
107
|
+
refs = (
|
|
108
|
+
[str(value(p, "reference")) for p in sexpr.walk(instances, "path")]
|
|
109
|
+
if instances
|
|
110
|
+
else []
|
|
111
|
+
)
|
|
112
|
+
if reference not in refs:
|
|
113
|
+
continue
|
|
114
|
+
set_prop(symbol, field_name, new_value)
|
|
115
|
+
if field_name == "Reference":
|
|
116
|
+
instances = find(symbol, "instances")
|
|
117
|
+
if instances is not None:
|
|
118
|
+
for path_node in sexpr.walk(instances, "path"):
|
|
119
|
+
ref_node = find(path_node, "reference")
|
|
120
|
+
if ref_node is not None:
|
|
121
|
+
ref_node[1] = new_value
|
|
122
|
+
changed += 1
|
|
123
|
+
if not changed:
|
|
124
|
+
raise EditError(f"no symbol with reference {reference!r} in {path.name}")
|
|
125
|
+
save(path, root)
|
|
126
|
+
return changed
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --------------------------------------------------------------------------- board
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def pcb_place(
|
|
133
|
+
path: Path, reference: str, x: float, y: float, rotation: float | None = None
|
|
134
|
+
) -> dict:
|
|
135
|
+
"""Move (and optionally rotate) a footprint. Pads and texts rotate with it."""
|
|
136
|
+
root = load(path)
|
|
137
|
+
footprint = footprints(root).get(reference)
|
|
138
|
+
if footprint is None:
|
|
139
|
+
raise EditError(f"no footprint {reference!r} on the board")
|
|
140
|
+
at = find(footprint, "at")
|
|
141
|
+
if at is None:
|
|
142
|
+
at = [Sym("at"), Sym("0"), Sym("0")]
|
|
143
|
+
footprint.insert(2, at)
|
|
144
|
+
old_angle = num(at[3]) if len(at) > 3 else 0.0
|
|
145
|
+
new_angle = old_angle if rotation is None else rotation % 360
|
|
146
|
+
delta = (new_angle - old_angle) % 360
|
|
147
|
+
at[1:] = [fmt(x), fmt(y)] + ([fmt(new_angle)] if new_angle else [])
|
|
148
|
+
if delta:
|
|
149
|
+
for child in footprint:
|
|
150
|
+
if isinstance(child, list) and child and child[0] in {"pad", "fp_text", "property"}:
|
|
151
|
+
child_at = find(child, "at")
|
|
152
|
+
if child_at is None:
|
|
153
|
+
continue
|
|
154
|
+
base = num(child_at[3]) if len(child_at) > 3 else 0.0
|
|
155
|
+
del child_at[3:]
|
|
156
|
+
angle = (base + delta) % 360
|
|
157
|
+
if angle:
|
|
158
|
+
child_at.append(fmt(angle))
|
|
159
|
+
save(path, root)
|
|
160
|
+
return {"reference": reference, "x": x, "y": y, "rotation": new_angle}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def pcb_add_track(
|
|
164
|
+
path: Path,
|
|
165
|
+
net: str,
|
|
166
|
+
points: list[tuple[float, float]],
|
|
167
|
+
layer: str = "F.Cu",
|
|
168
|
+
width: float = 0.25,
|
|
169
|
+
) -> int:
|
|
170
|
+
if len(points) < 2:
|
|
171
|
+
raise EditError("a track needs at least two points")
|
|
172
|
+
root = load(path)
|
|
173
|
+
codes = net_codes(root)
|
|
174
|
+
if net not in codes:
|
|
175
|
+
raise EditError(f"net {net!r} does not exist on the board (nets: {sorted(codes)[:40]})")
|
|
176
|
+
stamp = _stamp_key(root)
|
|
177
|
+
added = 0
|
|
178
|
+
for (x1, y1), (x2, y2) in zip(points, points[1:]):
|
|
179
|
+
if (x1, y1) == (x2, y2):
|
|
180
|
+
continue
|
|
181
|
+
root.append(
|
|
182
|
+
[
|
|
183
|
+
Sym("segment"),
|
|
184
|
+
[Sym("start"), fmt(x1), fmt(y1)],
|
|
185
|
+
[Sym("end"), fmt(x2), fmt(y2)],
|
|
186
|
+
[Sym("width"), fmt(width)],
|
|
187
|
+
[Sym("layer"), layer],
|
|
188
|
+
_net_node(root, net),
|
|
189
|
+
[Sym(stamp), _new_uuid()],
|
|
190
|
+
]
|
|
191
|
+
)
|
|
192
|
+
added += 1
|
|
193
|
+
save(path, root)
|
|
194
|
+
return added
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def pcb_add_via(
|
|
198
|
+
path: Path, net: str, x: float, y: float, size: float = 0.6, drill: float = 0.3
|
|
199
|
+
) -> None:
|
|
200
|
+
root = load(path)
|
|
201
|
+
codes = net_codes(root)
|
|
202
|
+
if net not in codes:
|
|
203
|
+
raise EditError(f"net {net!r} does not exist on the board")
|
|
204
|
+
root.append(
|
|
205
|
+
[
|
|
206
|
+
Sym("via"),
|
|
207
|
+
[Sym("at"), fmt(x), fmt(y)],
|
|
208
|
+
[Sym("size"), fmt(size)],
|
|
209
|
+
[Sym("drill"), fmt(drill)],
|
|
210
|
+
[Sym("layers"), "F.Cu", "B.Cu"],
|
|
211
|
+
_net_node(root, net),
|
|
212
|
+
[Sym(_stamp_key(root)), _new_uuid()],
|
|
213
|
+
]
|
|
214
|
+
)
|
|
215
|
+
save(path, root)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def pcb_remove_routing(path: Path, net: str | None = None) -> int:
|
|
219
|
+
"""Delete tracks and vias (for one net, or all). Returns the number removed."""
|
|
220
|
+
root = load(path)
|
|
221
|
+
codes = net_codes(root)
|
|
222
|
+
if net and net not in codes:
|
|
223
|
+
raise EditError(f"net {net!r} does not exist on the board")
|
|
224
|
+
keep: Node = []
|
|
225
|
+
removed = 0
|
|
226
|
+
for child in root:
|
|
227
|
+
if isinstance(child, list) and child and child[0] in {"segment", "via", "arc"}:
|
|
228
|
+
if net is None or _item_net(root, child, codes) == net:
|
|
229
|
+
removed += 1
|
|
230
|
+
continue
|
|
231
|
+
keep.append(child)
|
|
232
|
+
save(path, keep)
|
|
233
|
+
return removed
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def pcb_set_outline(path: Path, x: float, y: float, width: float, height: float) -> None:
|
|
237
|
+
"""Replace the Edge.Cuts outline with a rectangle."""
|
|
238
|
+
root = load(path)
|
|
239
|
+
kept = [
|
|
240
|
+
c
|
|
241
|
+
for c in root
|
|
242
|
+
if not (
|
|
243
|
+
isinstance(c, list)
|
|
244
|
+
and c
|
|
245
|
+
and str(c[0]).startswith("gr_")
|
|
246
|
+
and value(c, "layer") == "Edge.Cuts"
|
|
247
|
+
)
|
|
248
|
+
]
|
|
249
|
+
kept.append(
|
|
250
|
+
[
|
|
251
|
+
Sym("gr_rect"),
|
|
252
|
+
[Sym("start"), fmt(x), fmt(y)],
|
|
253
|
+
[Sym("end"), fmt(x + width), fmt(y + height)],
|
|
254
|
+
[Sym("stroke"), [Sym("width"), Sym("0.1")], [Sym("type"), Sym("default")]],
|
|
255
|
+
[Sym("fill"), Sym("none")],
|
|
256
|
+
[Sym("layer"), "Edge.Cuts"],
|
|
257
|
+
[Sym(_stamp_key(root)), _new_uuid()],
|
|
258
|
+
]
|
|
259
|
+
)
|
|
260
|
+
save(path, kept)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def pcb_add_zone(
|
|
264
|
+
path: Path,
|
|
265
|
+
net: str,
|
|
266
|
+
layer: str,
|
|
267
|
+
polygon: list[tuple[float, float]] | None = None,
|
|
268
|
+
clearance: float = 0.3,
|
|
269
|
+
min_thickness: float = 0.25,
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Add a copper pour. Without a polygon it covers the board outline."""
|
|
272
|
+
from traceforge.kicad.pcb import read_board
|
|
273
|
+
|
|
274
|
+
root = load(path)
|
|
275
|
+
codes = net_codes(root)
|
|
276
|
+
if net not in codes:
|
|
277
|
+
raise EditError(f"net {net!r} does not exist on the board")
|
|
278
|
+
if polygon is None:
|
|
279
|
+
outline = read_board(path).outline
|
|
280
|
+
if outline is None:
|
|
281
|
+
raise EditError("the board has no Edge.Cuts outline; pass a polygon")
|
|
282
|
+
x0, y0, x1, y1 = outline
|
|
283
|
+
polygon = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
|
|
284
|
+
net_node = _net_node(root, net)
|
|
285
|
+
root.append(
|
|
286
|
+
[
|
|
287
|
+
Sym("zone"),
|
|
288
|
+
net_node,
|
|
289
|
+
*([[Sym("net_name"), net]] if isinstance(net_node[1], Sym) else []),
|
|
290
|
+
[Sym("layer"), layer],
|
|
291
|
+
[Sym(_stamp_key(root)), _new_uuid()],
|
|
292
|
+
[Sym("hatch"), Sym("edge"), Sym("0.5")],
|
|
293
|
+
[Sym("connect_pads"), [Sym("clearance"), fmt(clearance)]],
|
|
294
|
+
[Sym("min_thickness"), fmt(min_thickness)],
|
|
295
|
+
[Sym("filled_areas_thickness"), Sym("no")],
|
|
296
|
+
[
|
|
297
|
+
Sym("fill"),
|
|
298
|
+
Sym("yes"),
|
|
299
|
+
[Sym("thermal_gap"), Sym("0.5")],
|
|
300
|
+
[Sym("thermal_bridge_width"), Sym("0.5")],
|
|
301
|
+
],
|
|
302
|
+
[Sym("polygon"), [Sym("pts"), *[[Sym("xy"), fmt(px), fmt(py)] for px, py in polygon]]],
|
|
303
|
+
]
|
|
304
|
+
)
|
|
305
|
+
save(path, root)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def fill_zones(path: Path) -> bool:
|
|
309
|
+
"""Fill zones with KiCad's own filler (needs a Python that can import pcbnew)."""
|
|
310
|
+
import os
|
|
311
|
+
import subprocess
|
|
312
|
+
|
|
313
|
+
from traceforge.kicad.cli import find_kicad_python
|
|
314
|
+
|
|
315
|
+
python = find_kicad_python()
|
|
316
|
+
if python is None:
|
|
317
|
+
return False
|
|
318
|
+
script = (
|
|
319
|
+
"import sys, pcbnew\n"
|
|
320
|
+
"b = pcbnew.LoadBoard(sys.argv[1])\n"
|
|
321
|
+
"pcbnew.ZONE_FILLER(b).Fill(b.Zones())\n"
|
|
322
|
+
"pcbnew.SaveBoard(sys.argv[1], b)\n"
|
|
323
|
+
)
|
|
324
|
+
completed = subprocess.run( # noqa: S603
|
|
325
|
+
[python, "-c", script, str(Path(path).resolve())],
|
|
326
|
+
capture_output=True,
|
|
327
|
+
text=True,
|
|
328
|
+
encoding="utf-8",
|
|
329
|
+
errors="replace",
|
|
330
|
+
timeout=600,
|
|
331
|
+
env={**os.environ, "QT_QPA_PLATFORM": "offscreen"},
|
|
332
|
+
check=False,
|
|
333
|
+
)
|
|
334
|
+
return completed.returncode == 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def pcb_add_routing(
|
|
338
|
+
path: Path,
|
|
339
|
+
tracks: list[tuple[str, str, float, list[tuple[float, float]]]],
|
|
340
|
+
vias: list[tuple[str, float, float]],
|
|
341
|
+
via_size: float = 0.6,
|
|
342
|
+
via_drill: float = 0.3,
|
|
343
|
+
) -> int:
|
|
344
|
+
"""Add many tracks ``(net, layer, width, points)`` and vias ``(net, x, y)`` in one write."""
|
|
345
|
+
root = load(path)
|
|
346
|
+
codes = net_codes(root)
|
|
347
|
+
stamp = _stamp_key(root)
|
|
348
|
+
for net in {t[0] for t in tracks} | {v[0] for v in vias}:
|
|
349
|
+
if net not in codes:
|
|
350
|
+
raise EditError(f"net {net!r} does not exist on the board")
|
|
351
|
+
added = 0
|
|
352
|
+
for net, layer, width, points in tracks:
|
|
353
|
+
for (x1, y1), (x2, y2) in zip(points, points[1:]):
|
|
354
|
+
if (x1, y1) == (x2, y2):
|
|
355
|
+
continue
|
|
356
|
+
root.append(
|
|
357
|
+
[
|
|
358
|
+
Sym("segment"),
|
|
359
|
+
[Sym("start"), fmt(x1), fmt(y1)],
|
|
360
|
+
[Sym("end"), fmt(x2), fmt(y2)],
|
|
361
|
+
[Sym("width"), fmt(width)],
|
|
362
|
+
[Sym("layer"), layer],
|
|
363
|
+
[Sym("net"), Sym(str(codes[net]))],
|
|
364
|
+
[Sym(stamp), _new_uuid()],
|
|
365
|
+
]
|
|
366
|
+
)
|
|
367
|
+
added += 1
|
|
368
|
+
for net, x, y in vias:
|
|
369
|
+
root.append(
|
|
370
|
+
[
|
|
371
|
+
Sym("via"),
|
|
372
|
+
[Sym("at"), fmt(x), fmt(y)],
|
|
373
|
+
[Sym("size"), fmt(via_size)],
|
|
374
|
+
[Sym("drill"), fmt(via_drill)],
|
|
375
|
+
[Sym("layers"), "F.Cu", "B.Cu"],
|
|
376
|
+
[Sym("net"), Sym(str(codes[net]))],
|
|
377
|
+
[Sym(stamp), _new_uuid()],
|
|
378
|
+
]
|
|
379
|
+
)
|
|
380
|
+
save(path, root)
|
|
381
|
+
return added
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def pcb_set_pad_zone_connect(path: Path, pads: list[tuple[str, str]], mode: int = 2) -> int:
|
|
385
|
+
"""Override how zones connect to the given ``(reference, pad number)`` pads.
|
|
386
|
+
|
|
387
|
+
``mode`` follows KiCad: 0 none, 1 thermal relief, 2 solid. Returns the pads changed.
|
|
388
|
+
"""
|
|
389
|
+
wanted = {(str(r), str(n)) for r, n in pads}
|
|
390
|
+
if not wanted:
|
|
391
|
+
return 0
|
|
392
|
+
root = load(path)
|
|
393
|
+
changed = 0
|
|
394
|
+
for ref, fp in footprints(root).items():
|
|
395
|
+
for pad in find_all(fp, "pad"):
|
|
396
|
+
if (ref, str(pad[1])) not in wanted:
|
|
397
|
+
continue
|
|
398
|
+
existing = find(pad, "zone_connect")
|
|
399
|
+
if existing is not None:
|
|
400
|
+
existing[1] = Sym(str(mode))
|
|
401
|
+
else:
|
|
402
|
+
pad.append([Sym("zone_connect"), Sym(str(mode))])
|
|
403
|
+
changed += 1
|
|
404
|
+
save(path, root)
|
|
405
|
+
return changed
|