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.
@@ -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"]
@@ -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
@@ -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
+ )