gitcad-ecad 0.7.7__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.
- gitcad/ecad/__init__.py +34 -0
- gitcad/ecad/annotate.py +63 -0
- gitcad/ecad/autoroute.py +210 -0
- gitcad/ecad/board.py +356 -0
- gitcad/ecad/bom.py +96 -0
- gitcad/ecad/component.py +71 -0
- gitcad/ecad/connectivity.py +82 -0
- gitcad/ecad/drc.py +404 -0
- gitcad/ecad/envelope.py +127 -0
- gitcad/ecad/excellon.py +67 -0
- gitcad/ecad/fab.py +82 -0
- gitcad/ecad/fpgen.py +114 -0
- gitcad/ecad/gencad.py +176 -0
- gitcad/ecad/gerber.py +177 -0
- gitcad/ecad/ipc2581.py +278 -0
- gitcad/ecad/ipcd356.py +50 -0
- gitcad/ecad/kicadout.py +40 -0
- gitcad/ecad/netderive.py +336 -0
- gitcad/ecad/odb.py +283 -0
- gitcad/ecad/route.py +99 -0
- gitcad/ecad/schematic.py +249 -0
- gitcad/ecad/schpdf.py +179 -0
- gitcad/ecad/schsvg.py +372 -0
- gitcad/ecad/sheetedit.py +298 -0
- gitcad/ecad/spice.py +163 -0
- gitcad/ecad/stats.py +99 -0
- gitcad/ecad/strokefont.py +5 -0
- gitcad/ecad/sync.py +113 -0
- gitcad/ecad/teardrop.py +76 -0
- gitcad/importers/altium.py +227 -0
- gitcad/importers/eagle.py +75 -0
- gitcad/importers/kicad.py +236 -0
- gitcad/importers/kicad_sch.py +354 -0
- gitcad/importers/sexp.py +71 -0
- gitcad_ecad-0.7.7.dist-info/METADATA +40 -0
- gitcad_ecad-0.7.7.dist-info/RECORD +37 -0
- gitcad_ecad-0.7.7.dist-info/WHEEL +4 -0
gitcad/ecad/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""gitcad-ecad — the electrical domain.
|
|
2
|
+
|
|
3
|
+
v0.1 scope: a text-first board model plus the fabrication outputs that let a
|
|
4
|
+
user actually order a PCB — Gerber X2 per layer, Excellon drill, pick-and-place.
|
|
5
|
+
Schematic capture, ERC, and the full DRC engine follow (see
|
|
6
|
+
docs/research/feature-map.md Part B).
|
|
7
|
+
|
|
8
|
+
Like the mechanical document, the board is canonical text (ADR-0004): the
|
|
9
|
+
model diffs and merges in git, and every generated output is deterministic —
|
|
10
|
+
the same board text produces byte-identical Gerbers on any machine.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from gitcad.ecad.board import Board, Component, Footprint, MountingHole, Pad, Track, Via, Zone
|
|
14
|
+
from gitcad.ecad.fab import export_fab
|
|
15
|
+
from gitcad.ecad.bom import bom, bom_csv, mpn_component
|
|
16
|
+
from gitcad.ecad.component import footprint_from_part, footprint_to_part
|
|
17
|
+
from gitcad.ecad.connectivity import check_connectivity
|
|
18
|
+
from gitcad.ecad.drc import Rule, RulePack, default_rules, run_drc
|
|
19
|
+
from gitcad.ecad.route import pad_position, route
|
|
20
|
+
from gitcad.ecad.schematic import (Pin, SchComponent, Schematic, board_parity,
|
|
21
|
+
merge_schematics)
|
|
22
|
+
from gitcad.ecad.envelope import check_envelopes, net_voltage, power_budget
|
|
23
|
+
from gitcad.ecad.netderive import derive_nets, sheet_parity
|
|
24
|
+
from gitcad.ecad.spice import sim_check, to_spice
|
|
25
|
+
from gitcad.ecad.schsvg import schematic_to_svg
|
|
26
|
+
|
|
27
|
+
__all__ = ["Board", "Component", "Footprint", "MountingHole", "Pad", "Track", "Via", "Zone",
|
|
28
|
+
"export_fab", "Schematic", "SchComponent", "Pin", "board_parity",
|
|
29
|
+
"Rule", "RulePack", "default_rules", "run_drc", "check_connectivity",
|
|
30
|
+
"pad_position", "route", "footprint_to_part", "footprint_from_part",
|
|
31
|
+
"mpn_component", "bom", "bom_csv", "schematic_to_svg",
|
|
32
|
+
"merge_schematics", "derive_nets", "sheet_parity",
|
|
33
|
+
"check_envelopes", "net_voltage", "power_budget",
|
|
34
|
+
"to_spice", "sim_check"]
|
gitcad/ecad/annotate.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""schematic_annotate — deterministic reference numbering (KiCad-map P4).
|
|
2
|
+
|
|
3
|
+
Components with placeholder refs (``R?``, ``U?`` — the KiCad convention)
|
|
4
|
+
get the lowest free number for their prefix, in a deterministic order:
|
|
5
|
+
sheet position (top-to-bottom, then left-to-right) when placements exist,
|
|
6
|
+
declaration order otherwise. Existing numbered refs are never touched —
|
|
7
|
+
annotation fills gaps, it does not reshuffle a reviewed design. Net pin
|
|
8
|
+
references follow the renames atomically.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from gitcad.ecad.schematic import Schematic
|
|
16
|
+
from gitcad.errors import GitcadError
|
|
17
|
+
|
|
18
|
+
_REF = re.compile(r"^([A-Za-z_]+)(\?|\d+)$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def annotate(sch: Schematic) -> dict[str, str]:
|
|
22
|
+
"""Assign numbers to ``?`` refs in place; returns {old_label: new_ref}
|
|
23
|
+
keyed by a positional label (``R?@2`` for the third placeholder R).
|
|
24
|
+
|
|
25
|
+
Nets may not reference placeholders — ``R?.1`` is ambiguous when two
|
|
26
|
+
unannotated Rs exist, so we refuse rather than guess: annotate first,
|
|
27
|
+
then connect (or connect by final refs)."""
|
|
28
|
+
for net, prs in sch.nets.items():
|
|
29
|
+
for pr in prs:
|
|
30
|
+
if "?" in pr.split(".", 1)[0]:
|
|
31
|
+
raise GitcadError(
|
|
32
|
+
f"net {net!r} references placeholder ref {pr!r} — "
|
|
33
|
+
"annotate before connecting, or connect by the final ref")
|
|
34
|
+
used: dict[str, set[int]] = {}
|
|
35
|
+
placeholders = []
|
|
36
|
+
decl_counter: dict[str, int] = {}
|
|
37
|
+
for i, comp in enumerate(sch.components):
|
|
38
|
+
m = _REF.match(comp.ref)
|
|
39
|
+
if not m:
|
|
40
|
+
raise GitcadError(f"unparseable ref {comp.ref!r} (want PREFIX+number or PREFIX?)")
|
|
41
|
+
prefix, num = m.groups()
|
|
42
|
+
if num == "?":
|
|
43
|
+
at = comp.attrs.get("at")
|
|
44
|
+
sort_key = (at[1], at[0], i) if at else (float("inf"), float("inf"), i)
|
|
45
|
+
k = decl_counter.get(prefix, 0) # label by DECLARATION order
|
|
46
|
+
decl_counter[prefix] = k + 1
|
|
47
|
+
placeholders.append((prefix, sort_key, f"{prefix}?@{k}", comp))
|
|
48
|
+
else:
|
|
49
|
+
used.setdefault(prefix, set()).add(int(num))
|
|
50
|
+
|
|
51
|
+
renames: dict[str, str] = {}
|
|
52
|
+
counters: dict[str, int] = {}
|
|
53
|
+
# numbering order is READING order (top-to-bottom, left-to-right)
|
|
54
|
+
for prefix, _key, label, comp in sorted(placeholders, key=lambda p: (p[0], p[1])):
|
|
55
|
+
n = counters.get(prefix, 1)
|
|
56
|
+
taken = used.setdefault(prefix, set())
|
|
57
|
+
while n in taken:
|
|
58
|
+
n += 1
|
|
59
|
+
taken.add(n)
|
|
60
|
+
counters[prefix] = n + 1
|
|
61
|
+
renames[label] = f"{prefix}{n}"
|
|
62
|
+
comp.ref = f"{prefix}{n}"
|
|
63
|
+
return renames
|
gitcad/ecad/autoroute.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Autorouting assist v1 — a grid maze router for agents.
|
|
2
|
+
|
|
3
|
+
Not push-and-shove: a deterministic BFS over a clearance-aware obstacle
|
|
4
|
+
grid (Lee router), one net at a time, through-vias for layer changes.
|
|
5
|
+
The result is ordinary Tracks/Vias appended to the board — the same
|
|
6
|
+
check chain that gates hand routing (DRC, connectivity) gates this.
|
|
7
|
+
Honest refusal: no path means ``autoroute-no-path``, never a
|
|
8
|
+
rules-violating trace.
|
|
9
|
+
|
|
10
|
+
Scope v1: routes between the net's pad centers on the outer layers (or
|
|
11
|
+
any copper layer list you pass), rectilinear steps on a fixed grid.
|
|
12
|
+
Pour-covered boards refuse fast — a pour IS the route.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from gitcad.ecad.board import Board, Track, Via
|
|
18
|
+
from gitcad.errors import GitcadError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def autoroute(board: Board, net: str, *, grid: float = 0.25,
|
|
22
|
+
width: float = 0.25, clearance: float = 0.2,
|
|
23
|
+
layers: tuple[str, ...] | None = None,
|
|
24
|
+
via_drill: float = 0.4, via_diameter: float = 0.8) -> dict:
|
|
25
|
+
"""Route every pad of ``net`` into one connected tree. Returns
|
|
26
|
+
{"tracks": n, "vias": n}. Raises GitcadError when no path exists."""
|
|
27
|
+
copper = board.copper_layers()
|
|
28
|
+
layer_list = list(layers) if layers else ["top", "bottom"]
|
|
29
|
+
for ly in layer_list:
|
|
30
|
+
if ly not in copper:
|
|
31
|
+
raise GitcadError(f"unknown layer {ly!r}")
|
|
32
|
+
|
|
33
|
+
minx, miny, maxx, maxy = board.bbox()
|
|
34
|
+
nx = int((maxx - minx) / grid) + 1
|
|
35
|
+
ny = int((maxy - miny) / grid) + 1
|
|
36
|
+
if nx * ny > 1_500_000:
|
|
37
|
+
raise GitcadError(
|
|
38
|
+
f"autoroute grid {nx}x{ny} too large — coarsen `grid`")
|
|
39
|
+
|
|
40
|
+
def cell(x: float, y: float) -> tuple[int, int]:
|
|
41
|
+
return (round((x - minx) / grid), round((y - miny) / grid))
|
|
42
|
+
|
|
43
|
+
def pos(c: tuple[int, int]) -> tuple[float, float]:
|
|
44
|
+
return (minx + c[0] * grid, miny + c[1] * grid)
|
|
45
|
+
|
|
46
|
+
# -- obstacle rasterization (other-net copper, inflated) -------------------
|
|
47
|
+
# tracks need clearance + track half-width; via barrels are wider, so
|
|
48
|
+
# via placement checks a second grid with the bigger margin
|
|
49
|
+
margin = clearance + width / 2
|
|
50
|
+
margin_via = clearance + via_diameter / 2
|
|
51
|
+
blocked: list[set] = [set() for _ in layer_list]
|
|
52
|
+
blocked_via: list[set] = [set() for _ in layer_list]
|
|
53
|
+
li = {name: i for i, name in enumerate(layer_list)}
|
|
54
|
+
|
|
55
|
+
def block_disc(x: float, y: float, r: float, idxs) -> None:
|
|
56
|
+
for grid_set, mg in ((blocked, margin), (blocked_via, margin_via)):
|
|
57
|
+
rr = r + mg
|
|
58
|
+
c0x, c0y = cell(x - rr, y - rr)
|
|
59
|
+
c1x, c1y = cell(x + rr, y + rr)
|
|
60
|
+
for cx in range(max(0, c0x), min(nx, c1x + 1)):
|
|
61
|
+
for cy in range(max(0, c0y), min(ny, c1y + 1)):
|
|
62
|
+
px, py = pos((cx, cy))
|
|
63
|
+
if (px - x) ** 2 + (py - y) ** 2 <= rr * rr:
|
|
64
|
+
for i in idxs:
|
|
65
|
+
grid_set[i].add((cx, cy))
|
|
66
|
+
|
|
67
|
+
def block_seg(x1, y1, x2, y2, half_w, idx) -> None:
|
|
68
|
+
steps = max(1, int(((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5 / grid) * 2)
|
|
69
|
+
for s in range(steps + 1):
|
|
70
|
+
t = s / steps
|
|
71
|
+
block_disc(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t, half_w, [idx])
|
|
72
|
+
|
|
73
|
+
all_idx = list(range(len(layer_list)))
|
|
74
|
+
for comp in board.components:
|
|
75
|
+
for pad, bx, by, rot in comp.placed_pads():
|
|
76
|
+
if comp.nets.get(pad.name, "") == net:
|
|
77
|
+
continue
|
|
78
|
+
w, h = (pad.h, pad.w) if round(rot) % 180 == 90 else (pad.w, pad.h)
|
|
79
|
+
r = max(w, h) / 2
|
|
80
|
+
if pad.drill is not None:
|
|
81
|
+
block_disc(bx, by, r, all_idx)
|
|
82
|
+
elif comp.side in li:
|
|
83
|
+
block_disc(bx, by, r, [li[comp.side]])
|
|
84
|
+
for v in board.vias:
|
|
85
|
+
if v.net == net:
|
|
86
|
+
continue
|
|
87
|
+
sp = set(v.span(copper))
|
|
88
|
+
idxs = [li[ly] for ly in layer_list if ly in sp] or all_idx
|
|
89
|
+
block_disc(v.x, v.y, v.diameter / 2, idxs)
|
|
90
|
+
for t in board.tracks:
|
|
91
|
+
if t.net == net or t.layer not in li:
|
|
92
|
+
continue
|
|
93
|
+
block_seg(t.x1, t.y1, t.x2, t.y2, t.width / 2, li[t.layer])
|
|
94
|
+
for z in board.zones:
|
|
95
|
+
if z.kind == "keepout":
|
|
96
|
+
xs = [p[0] for p in z.polygon]
|
|
97
|
+
ys = [p[1] for p in z.polygon]
|
|
98
|
+
for ly in ([z.layer] if z.layer in li else []):
|
|
99
|
+
c0, c1 = cell(min(xs), min(ys)), cell(max(xs), max(ys))
|
|
100
|
+
for cx in range(max(0, c0[0]), min(nx, c1[0] + 1)):
|
|
101
|
+
for cy in range(max(0, c0[1]), min(ny, c1[1] + 1)):
|
|
102
|
+
blocked[li[ly]].add((cx, cy))
|
|
103
|
+
elif z.kind == "copper" and z.net != net and z.layer in li:
|
|
104
|
+
xs = [p[0] for p in z.polygon]
|
|
105
|
+
ys = [p[1] for p in z.polygon]
|
|
106
|
+
c0, c1 = cell(min(xs), min(ys)), cell(max(xs), max(ys))
|
|
107
|
+
for cx in range(max(0, c0[0]), min(nx, c1[0] + 1)):
|
|
108
|
+
for cy in range(max(0, c0[1]), min(ny, c1[1] + 1)):
|
|
109
|
+
blocked[li[z.layer]].add((cx, cy))
|
|
110
|
+
|
|
111
|
+
# -- terminals -------------------------------------------------------------
|
|
112
|
+
terminals: list[tuple[tuple[int, int], int]] = [] # (cell, layer idx)
|
|
113
|
+
for comp in board.components:
|
|
114
|
+
for pad, bx, by, _rot in comp.placed_pads():
|
|
115
|
+
if comp.nets.get(pad.name, "") != net:
|
|
116
|
+
continue
|
|
117
|
+
if pad.drill is not None:
|
|
118
|
+
terminals.append((cell(bx, by), 0))
|
|
119
|
+
elif comp.side in li:
|
|
120
|
+
terminals.append((cell(bx, by), li[comp.side]))
|
|
121
|
+
if len(terminals) < 2:
|
|
122
|
+
raise GitcadError(f"net {net!r} has fewer than 2 routable pads")
|
|
123
|
+
|
|
124
|
+
# -- sequential Lee routing ------------------------------------------------
|
|
125
|
+
tree: set[tuple[int, int, int]] = set()
|
|
126
|
+
first_cell, first_li = terminals[0]
|
|
127
|
+
tree.add((first_cell[0], first_cell[1], first_li))
|
|
128
|
+
tracks_added = vias_added = 0
|
|
129
|
+
|
|
130
|
+
for term_cell, term_li in terminals[1:]:
|
|
131
|
+
start = (term_cell[0], term_cell[1], term_li)
|
|
132
|
+
if start in tree:
|
|
133
|
+
continue
|
|
134
|
+
import heapq
|
|
135
|
+
|
|
136
|
+
prev: dict = {start: None}
|
|
137
|
+
dist: dict = {start: 0}
|
|
138
|
+
heap: list[tuple[int, tuple]] = [(0, start)]
|
|
139
|
+
via_cost = max(1, int(3.0 / grid)) # a via "costs" ~3 mm
|
|
140
|
+
goal = None
|
|
141
|
+
while heap:
|
|
142
|
+
d, cur = heapq.heappop(heap)
|
|
143
|
+
if d > dist.get(cur, 1 << 30):
|
|
144
|
+
continue
|
|
145
|
+
if cur in tree:
|
|
146
|
+
goal = cur
|
|
147
|
+
break
|
|
148
|
+
cx, cy, cl = cur
|
|
149
|
+
steps = [((cx + 1, cy, cl), 1), ((cx - 1, cy, cl), 1),
|
|
150
|
+
((cx, cy + 1, cl), 1), ((cx, cy - 1, cl), 1)]
|
|
151
|
+
for other in range(len(layer_list)): # through-via move
|
|
152
|
+
if other != cl:
|
|
153
|
+
steps.append(((cx, cy, other), via_cost))
|
|
154
|
+
for nxt, cost in steps:
|
|
155
|
+
tx, ty, tl = nxt
|
|
156
|
+
if not (0 <= tx < nx and 0 <= ty < ny):
|
|
157
|
+
continue
|
|
158
|
+
if tl != cl: # via: all layers clear
|
|
159
|
+
if any((tx, ty) in blocked_via[i]
|
|
160
|
+
for i in range(len(layer_list))):
|
|
161
|
+
continue
|
|
162
|
+
elif (tx, ty) in blocked[tl]:
|
|
163
|
+
continue
|
|
164
|
+
nd = d + cost
|
|
165
|
+
if nd < dist.get(nxt, 1 << 30):
|
|
166
|
+
dist[nxt] = nd
|
|
167
|
+
prev[nxt] = cur
|
|
168
|
+
heapq.heappush(heap, (nd, nxt))
|
|
169
|
+
if goal is None:
|
|
170
|
+
raise GitcadError(f"autoroute-no-path:{net}")
|
|
171
|
+
|
|
172
|
+
# walk back, merging straight runs into tracks
|
|
173
|
+
path = []
|
|
174
|
+
n_ = goal
|
|
175
|
+
while n_ is not None:
|
|
176
|
+
path.append(n_)
|
|
177
|
+
n_ = prev[n_]
|
|
178
|
+
run_start = path[0]
|
|
179
|
+
for a, b in zip(path, path[1:]):
|
|
180
|
+
if a[2] != b[2]: # layer change: via at a
|
|
181
|
+
if run_start[:2] != a[:2]:
|
|
182
|
+
_emit(board, pos(run_start[:2]), pos(a[:2]),
|
|
183
|
+
layer_list[a[2]], width, net)
|
|
184
|
+
tracks_added += 1
|
|
185
|
+
board.vias.append(Via(*pos(a[:2]), drill=via_drill,
|
|
186
|
+
diameter=via_diameter, net=net))
|
|
187
|
+
vias_added += 1
|
|
188
|
+
run_start = b
|
|
189
|
+
elif _turns(run_start, a, b):
|
|
190
|
+
_emit(board, pos(run_start[:2]), pos(a[:2]),
|
|
191
|
+
layer_list[a[2]], width, net)
|
|
192
|
+
tracks_added += 1
|
|
193
|
+
run_start = a
|
|
194
|
+
last = path[-1]
|
|
195
|
+
if run_start[:2] != last[:2]:
|
|
196
|
+
_emit(board, pos(run_start[:2]), pos(last[:2]),
|
|
197
|
+
layer_list[last[2]], width, net)
|
|
198
|
+
tracks_added += 1
|
|
199
|
+
tree.update(path)
|
|
200
|
+
|
|
201
|
+
return {"tracks": tracks_added, "vias": vias_added}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _turns(run_start, a, b) -> bool:
|
|
205
|
+
return ((b[0] - run_start[0]) * (a[1] - run_start[1])
|
|
206
|
+
!= (b[1] - run_start[1]) * (a[0] - run_start[0]))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _emit(board: Board, p1, p2, layer: str, width: float, net: str) -> None:
|
|
210
|
+
board.tracks.append(Track(p1[0], p1[1], p2[0], p2[1], width, layer, net))
|
gitcad/ecad/board.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""The board model — text-first, deterministic, minimal but fab-complete.
|
|
2
|
+
|
|
3
|
+
Coordinates are mm, origin bottom-left, y up (same convention as the drawing
|
|
4
|
+
sheet). Rotation is degrees CCW. Sides are "top"/"bottom".
|
|
5
|
+
|
|
6
|
+
Pads live on footprints; a Component instance places a footprint at (x, y, rot,
|
|
7
|
+
side) — pad positions in board space are computed, never stored, so moving a
|
|
8
|
+
component is a one-line diff (ADR-0004: derived data is never source).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import asdict, dataclass, field
|
|
17
|
+
|
|
18
|
+
from gitcad.canonical import canonical_json
|
|
19
|
+
from gitcad.errors import GitcadError, ValidationReport
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Pad:
|
|
24
|
+
name: str
|
|
25
|
+
x: float # relative to footprint origin
|
|
26
|
+
y: float
|
|
27
|
+
w: float
|
|
28
|
+
h: float
|
|
29
|
+
shape: str = "rect" # rect | circle | obround
|
|
30
|
+
drill: float | None = None # None = SMD; a value = plated through hole
|
|
31
|
+
net: str = "" # assigned per-instance via Component.nets
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Footprint:
|
|
36
|
+
name: str
|
|
37
|
+
pads: list[Pad] = field(default_factory=list)
|
|
38
|
+
courtyard: tuple[float, float] | None = None # (w, h) centered on origin
|
|
39
|
+
height: float | None = None # body height mm (IDF-style envelope; None = unknown)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Component:
|
|
44
|
+
ref: str # designator, e.g. "R1"
|
|
45
|
+
footprint: Footprint
|
|
46
|
+
value: str = ""
|
|
47
|
+
x: float = 0.0
|
|
48
|
+
y: float = 0.0
|
|
49
|
+
rot: float = 0.0
|
|
50
|
+
side: str = "top"
|
|
51
|
+
nets: dict[str, str] = field(default_factory=dict) # pad name -> net name
|
|
52
|
+
|
|
53
|
+
def placed_pads(self) -> list[tuple[Pad, float, float, float]]:
|
|
54
|
+
"""Yield (pad, board_x, board_y, rotation) for each pad, with the
|
|
55
|
+
component's placement applied. Bottom-side placement mirrors X."""
|
|
56
|
+
out = []
|
|
57
|
+
cos, sin = math.cos(math.radians(self.rot)), math.sin(math.radians(self.rot))
|
|
58
|
+
for pad in self.footprint.pads:
|
|
59
|
+
px, py = pad.x, pad.y
|
|
60
|
+
if self.side == "bottom":
|
|
61
|
+
px = -px
|
|
62
|
+
bx = self.x + px * cos - py * sin
|
|
63
|
+
by = self.y + px * sin + py * cos
|
|
64
|
+
out.append((pad, bx, by, self.rot))
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Track:
|
|
70
|
+
x1: float
|
|
71
|
+
y1: float
|
|
72
|
+
x2: float
|
|
73
|
+
y2: float
|
|
74
|
+
width: float
|
|
75
|
+
layer: str = "top" # top | bottom
|
|
76
|
+
net: str = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Via:
|
|
81
|
+
"""A plated barrel connecting copper layers. ``layer_from``/``layer_to``
|
|
82
|
+
are the outermost copper layers the barrel touches, ordered outside-in
|
|
83
|
+
(top before in1 before ... before bottom). The defaults make a classic
|
|
84
|
+
through via; anything narrower is a blind via (touches exactly one outer
|
|
85
|
+
surface) or a buried via (touches neither)."""
|
|
86
|
+
x: float
|
|
87
|
+
y: float
|
|
88
|
+
drill: float = 0.4
|
|
89
|
+
diameter: float = 0.8
|
|
90
|
+
net: str = ""
|
|
91
|
+
layer_from: str = "top"
|
|
92
|
+
layer_to: str = "bottom"
|
|
93
|
+
|
|
94
|
+
def span(self, copper_layers: list[str]) -> list[str]:
|
|
95
|
+
"""Copper layers this barrel touches, outside-in — or [] when the
|
|
96
|
+
span doesn't resolve in this stack (validate() reports which)."""
|
|
97
|
+
try:
|
|
98
|
+
i = copper_layers.index(self.layer_from)
|
|
99
|
+
j = copper_layers.index(self.layer_to)
|
|
100
|
+
except ValueError:
|
|
101
|
+
return []
|
|
102
|
+
return copper_layers[i:j + 1] if i < j else []
|
|
103
|
+
|
|
104
|
+
def kind(self, copper_layers: list[str]) -> str:
|
|
105
|
+
"""through | blind | buried | invalid — derived, never stored."""
|
|
106
|
+
s = self.span(copper_layers)
|
|
107
|
+
if not s:
|
|
108
|
+
return "invalid"
|
|
109
|
+
outer = (s[0] == copper_layers[0], s[-1] == copper_layers[-1])
|
|
110
|
+
if all(outer):
|
|
111
|
+
return "through"
|
|
112
|
+
return "blind" if any(outer) else "buried"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Zone:
|
|
117
|
+
"""A zone: closed polygon on one layer. ``kind`` (KiCad-map P2):
|
|
118
|
+
|
|
119
|
+
- ``copper`` (default): a pour tied to a net — the real Altair board is
|
|
120
|
+
routed almost entirely with these.
|
|
121
|
+
- ``keepout``: a rule area FORBIDDING copper — tracks, vias, and copper
|
|
122
|
+
zones intersecting it are DRC violations; it never emits to Gerber
|
|
123
|
+
and never conducts. ``net`` is meaningless for keepouts ("")."""
|
|
124
|
+
net: str
|
|
125
|
+
layer: str # top | bottom
|
|
126
|
+
polygon: list[tuple[float, float]] # closed (first != last is fine)
|
|
127
|
+
kind: str = "copper" # copper | keepout
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ISO 273 clearance drills -> thread (medium + close fits). Exact matches
|
|
131
|
+
# only: an unrecognized drill honestly yields no thread spec, and the
|
|
132
|
+
# fastener generator will report it rather than guess (dogfood finding
|
|
133
|
+
# 2026-07-23: the real Altair board's 2.7mm holes are M2.5 clearance).
|
|
134
|
+
_CLEARANCE_THREADS = {
|
|
135
|
+
2.2: "M2", 2.4: "M2.2", 2.7: "M2.5", 3.2: "M3", 3.4: "M3",
|
|
136
|
+
4.3: "M4", 4.5: "M4", 5.3: "M5", 5.5: "M5", 6.4: "M6", 6.6: "M6",
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _thread_from_clearance(drill: float) -> str | None:
|
|
141
|
+
return _CLEARANCE_THREADS.get(round(drill, 1))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class MountingHole:
|
|
146
|
+
"""A non-plated mounting hole — and, equally, a published mech interface:
|
|
147
|
+
every mounting hole becomes a `mech.bolt` port in the board's part.json
|
|
148
|
+
(ADR-0008), which is what the enclosure mates against."""
|
|
149
|
+
name: str # port name, e.g. "mnt_1"
|
|
150
|
+
x: float
|
|
151
|
+
y: float
|
|
152
|
+
drill: float # NPTH diameter
|
|
153
|
+
thread: str | None = None # clearance for e.g. "M3" — informational spec
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class Board:
|
|
158
|
+
"""A complete board: 2 copper layers by default, up to 16 (``layers``).
|
|
159
|
+
Copper layer names: ``top``, ``in1``..``in{n-2}``, ``bottom``. Vias
|
|
160
|
+
default to through-hole; a narrower ``layer_from``/``layer_to`` span
|
|
161
|
+
makes them blind or buried (every consumer — Gerber, drill, DRC,
|
|
162
|
+
connectivity, IPC-2581 — honors the span)."""
|
|
163
|
+
|
|
164
|
+
name: str
|
|
165
|
+
outline: list[tuple[float, float]] # closed polygon (first != last is fine)
|
|
166
|
+
components: list[Component] = field(default_factory=list)
|
|
167
|
+
tracks: list[Track] = field(default_factory=list)
|
|
168
|
+
vias: list[Via] = field(default_factory=list)
|
|
169
|
+
zones: list[Zone] = field(default_factory=list)
|
|
170
|
+
mounting_holes: list[MountingHole] = field(default_factory=list)
|
|
171
|
+
thickness: float = 1.6 # mm — board stack height
|
|
172
|
+
mask_expansion: float = 0.05 # mm per side
|
|
173
|
+
layers: int = 2 # copper layer count (2..16)
|
|
174
|
+
# Net classes: named net groups binding DRC constraints (KiCad-map P1).
|
|
175
|
+
# {"power": {"nets": ["VCC", "GND", "+*"], "clearance": 0.3,
|
|
176
|
+
# "track_width_min": 0.5}} — nets may be fnmatch globs; DRC
|
|
177
|
+
# expands each class into net-scoped rules that OVERRIDE the pack's
|
|
178
|
+
# defaults for matching nets.
|
|
179
|
+
net_classes: dict[str, dict] = field(default_factory=dict)
|
|
180
|
+
|
|
181
|
+
SCHEMA = "gitcad/board@1"
|
|
182
|
+
|
|
183
|
+
# -- canonical text (the git-diffable source) -----------------------------
|
|
184
|
+
|
|
185
|
+
def dumps(self) -> str:
|
|
186
|
+
doc = {"schema": self.SCHEMA, "board": asdict(self)}
|
|
187
|
+
return canonical_json(doc, indent=2) + "\n"
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def loads(cls, text: str) -> "Board":
|
|
191
|
+
doc = json.loads(text)
|
|
192
|
+
if doc.get("schema") != cls.SCHEMA:
|
|
193
|
+
raise GitcadError(f"unsupported board schema {doc.get('schema')!r}")
|
|
194
|
+
b = doc["board"]
|
|
195
|
+
return cls(
|
|
196
|
+
name=b["name"],
|
|
197
|
+
outline=[tuple(p) for p in b["outline"]],
|
|
198
|
+
components=[
|
|
199
|
+
Component(
|
|
200
|
+
ref=c["ref"],
|
|
201
|
+
footprint=Footprint(
|
|
202
|
+
name=c["footprint"]["name"],
|
|
203
|
+
pads=[Pad(**p) for p in c["footprint"]["pads"]],
|
|
204
|
+
courtyard=tuple(c["footprint"]["courtyard"]) if c["footprint"]["courtyard"] else None,
|
|
205
|
+
height=c["footprint"].get("height"),
|
|
206
|
+
),
|
|
207
|
+
value=c["value"], x=c["x"], y=c["y"], rot=c["rot"], side=c["side"],
|
|
208
|
+
nets=dict(c["nets"]),
|
|
209
|
+
)
|
|
210
|
+
for c in b["components"]
|
|
211
|
+
],
|
|
212
|
+
tracks=[Track(**t) for t in b["tracks"]],
|
|
213
|
+
vias=[Via(**v) for v in b["vias"]],
|
|
214
|
+
zones=[Zone(net=z["net"], layer=z["layer"],
|
|
215
|
+
polygon=[tuple(p) for p in z["polygon"]],
|
|
216
|
+
kind=z.get("kind", "copper"))
|
|
217
|
+
for z in b.get("zones", [])],
|
|
218
|
+
mounting_holes=[MountingHole(**m) for m in b.get("mounting_holes", [])],
|
|
219
|
+
thickness=b.get("thickness", 1.6),
|
|
220
|
+
mask_expansion=b.get("mask_expansion", 0.05),
|
|
221
|
+
layers=int(b.get("layers", 2)),
|
|
222
|
+
net_classes={k: dict(v) for k, v in b.get("net_classes", {}).items()},
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def copper_layers(self) -> list[str]:
|
|
226
|
+
"""Layer names outside-in: top, in1..in{n-2}, bottom."""
|
|
227
|
+
return (["top"] + [f"in{i}" for i in range(1, self.layers - 1)]
|
|
228
|
+
+ ["bottom"])
|
|
229
|
+
|
|
230
|
+
# -- checks (the agent verification loop) ---------------------------------
|
|
231
|
+
|
|
232
|
+
def bbox(self) -> tuple[float, float, float, float]:
|
|
233
|
+
xs = [x for x, _ in self.outline]
|
|
234
|
+
ys = [y for _, y in self.outline]
|
|
235
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
236
|
+
|
|
237
|
+
def validate(self) -> ValidationReport:
|
|
238
|
+
"""Fab-readiness checks, machine-readable. Not a full DRC yet — the
|
|
239
|
+
checks that make a fab reject the files outright."""
|
|
240
|
+
violations: list[str] = []
|
|
241
|
+
# Filesystem-safe name: fab filenames derive from it, and the board
|
|
242
|
+
# text can arrive via MCP from untrusted sources (path traversal was
|
|
243
|
+
# flagged in the 2026-07-22 review).
|
|
244
|
+
if not re.fullmatch(r"[A-Za-z0-9._-]+", self.name) or ".." in self.name:
|
|
245
|
+
violations.append("board-name-not-filesystem-safe")
|
|
246
|
+
if len(self.outline) < 3:
|
|
247
|
+
violations.append("outline-degenerate")
|
|
248
|
+
if not (2 <= self.layers <= 16):
|
|
249
|
+
violations.append(f"layers-out-of-range:{self.layers}")
|
|
250
|
+
valid_layers = set(self.copper_layers())
|
|
251
|
+
minx, miny, maxx, maxy = self.bbox()
|
|
252
|
+
refs = [c.ref for c in self.components]
|
|
253
|
+
if len(refs) != len(set(refs)):
|
|
254
|
+
violations.append("components-duplicate-refs")
|
|
255
|
+
for comp in self.components:
|
|
256
|
+
# v0.1 writers can only render right-angle rotations; anything else
|
|
257
|
+
# would emit wrong copper silently — reject at the fab gate.
|
|
258
|
+
if round(comp.rot) % 90 != 0:
|
|
259
|
+
violations.append(f"rotation-not-multiple-of-90:{comp.ref}")
|
|
260
|
+
for pad, bx, by, _ in comp.placed_pads():
|
|
261
|
+
if not (minx <= bx <= maxx and miny <= by <= maxy):
|
|
262
|
+
violations.append(f"pad-outside-outline:{comp.ref}.{pad.name}")
|
|
263
|
+
if pad.drill is not None and pad.drill >= min(pad.w, pad.h):
|
|
264
|
+
violations.append(f"drill-exceeds-pad:{comp.ref}.{pad.name}")
|
|
265
|
+
copper_names = self.copper_layers()
|
|
266
|
+
for i, via in enumerate(self.vias):
|
|
267
|
+
if via.drill >= via.diameter:
|
|
268
|
+
violations.append(f"via-drill-exceeds-diameter:{i}")
|
|
269
|
+
if via.layer_from not in valid_layers or via.layer_to not in valid_layers:
|
|
270
|
+
violations.append(f"via-bad-layer:{i}")
|
|
271
|
+
elif not via.span(copper_names):
|
|
272
|
+
violations.append(f"via-span-inverted:{i}")
|
|
273
|
+
for i, t in enumerate(self.tracks):
|
|
274
|
+
if t.width <= 0:
|
|
275
|
+
violations.append(f"track-zero-width:{i}")
|
|
276
|
+
if (t.x1, t.y1) == (t.x2, t.y2):
|
|
277
|
+
# a zero-length track slipped through in the dogfood build
|
|
278
|
+
violations.append(f"track-degenerate:{i}")
|
|
279
|
+
if t.layer not in valid_layers:
|
|
280
|
+
violations.append(f"track-bad-layer:{i}")
|
|
281
|
+
for i, z in enumerate(self.zones):
|
|
282
|
+
if len(z.polygon) < 3:
|
|
283
|
+
violations.append(f"zone-degenerate:{i}")
|
|
284
|
+
if z.layer not in valid_layers:
|
|
285
|
+
violations.append(f"zone-bad-layer:{i}")
|
|
286
|
+
if z.kind not in ("copper", "keepout"):
|
|
287
|
+
violations.append(f"zone-bad-kind:{i}")
|
|
288
|
+
if z.kind == "keepout" and z.net:
|
|
289
|
+
violations.append(f"keepout-with-net:{i}")
|
|
290
|
+
for cname, spec in sorted(self.net_classes.items()):
|
|
291
|
+
nets = spec.get("nets")
|
|
292
|
+
if not nets or not all(isinstance(n, str) and n for n in nets):
|
|
293
|
+
violations.append(f"netclass-empty-nets:{cname}")
|
|
294
|
+
for key in spec:
|
|
295
|
+
if key == "nets":
|
|
296
|
+
continue
|
|
297
|
+
if key not in ("clearance", "track_width_min"):
|
|
298
|
+
violations.append(f"netclass-unknown-param:{cname}:{key}")
|
|
299
|
+
elif not isinstance(spec[key], (int, float)) or spec[key] <= 0:
|
|
300
|
+
violations.append(f"netclass-bad-value:{cname}:{key}")
|
|
301
|
+
hole_names = [m.name for m in self.mounting_holes]
|
|
302
|
+
if len(hole_names) != len(set(hole_names)):
|
|
303
|
+
violations.append("mounting-holes-duplicate-names")
|
|
304
|
+
for m in self.mounting_holes:
|
|
305
|
+
if not (minx <= m.x <= maxx and miny <= m.y <= maxy):
|
|
306
|
+
violations.append(f"mounting-hole-outside-outline:{m.name}")
|
|
307
|
+
if m.drill <= 0:
|
|
308
|
+
violations.append(f"mounting-hole-zero-drill:{m.name}")
|
|
309
|
+
return ValidationReport(
|
|
310
|
+
ok=not violations,
|
|
311
|
+
checks={"components": len(self.components), "tracks": len(self.tracks),
|
|
312
|
+
"vias": len(self.vias), "zones": len(self.zones),
|
|
313
|
+
"mounting_holes": len(self.mounting_holes)},
|
|
314
|
+
violations=violations,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# -- the derived part interface (ADR-0008: domain wiring) -----------------
|
|
318
|
+
|
|
319
|
+
def to_part(self, part_id: str, version: str = "0.1.0",
|
|
320
|
+
*, schematics: list[str] | None = None):
|
|
321
|
+
"""Derive this board's PCBA part from its actual geometry — the
|
|
322
|
+
Fusion-360 duality: from the OUTSIDE this is a mechanical part
|
|
323
|
+
(envelope from outline bbox × thickness, one frame + ``mech.bolt``
|
|
324
|
+
port per mounting hole, a 3D body via the bridge); ENTERING it is
|
|
325
|
+
the electrical workflow (the referenced board + schematics, checked
|
|
326
|
+
by the pcba suite). Nothing hand-authored — change the board, and
|
|
327
|
+
the interface (interface-semver, ADR-0009) follows.
|
|
328
|
+
|
|
329
|
+
``schematics`` lists the schematic files (ADR-0017 names) that are
|
|
330
|
+
this PCBA's electrical source; pass them and the part is a full
|
|
331
|
+
PCBA, omit them and it is a bare board part."""
|
|
332
|
+
from gitcad.part import Frame, Interface, PartManifest, Port
|
|
333
|
+
|
|
334
|
+
minx, miny, maxx, maxy = self.bbox()
|
|
335
|
+
frames = {"origin": Frame()}
|
|
336
|
+
ports = {}
|
|
337
|
+
for m in self.mounting_holes:
|
|
338
|
+
frames[m.name] = Frame(origin=(m.x, m.y, 0.0))
|
|
339
|
+
spec: dict = {"drill": m.drill}
|
|
340
|
+
thread = m.thread or _thread_from_clearance(m.drill)
|
|
341
|
+
if thread:
|
|
342
|
+
spec["thread"] = thread
|
|
343
|
+
ports[m.name] = Port(m.name, "mech.bolt", m.name, spec)
|
|
344
|
+
body: dict = {"kind": "pcba", "board": f"{self.name}.board"}
|
|
345
|
+
if schematics:
|
|
346
|
+
body["schematics"] = sorted(schematics)
|
|
347
|
+
return PartManifest(
|
|
348
|
+
id=part_id, name=self.name, domain="ecad", version=version,
|
|
349
|
+
interface=Interface(
|
|
350
|
+
envelope={"origin": [minx, miny, 0.0],
|
|
351
|
+
"dx": maxx - minx, "dy": maxy - miny, "dz": self.thickness},
|
|
352
|
+
frames=frames, ports=ports,
|
|
353
|
+
properties={"layers": 2, "components": len(self.components)},
|
|
354
|
+
),
|
|
355
|
+
body=body,
|
|
356
|
+
)
|