diff --git a/pcbsight/pcbsight/__init__.py b/pcbsight/pcbsight/__init__.py index eb1c19f..37d6c98 100644 --- a/pcbsight/pcbsight/__init__.py +++ b/pcbsight/pcbsight/__init__.py @@ -5,9 +5,16 @@ is IPC-2221 arithmetic, and pair skew is a subtraction. So compute them. from pcbsight import parse_board, analyze + +Beyond review there is an EXPERIMENTAL create side (WIP, no tests yet): +`Design` is an intent API (part/net/place/pour), `ARouter` is a +clearance-correct A* autorouter, and `library` holds real footprints. It +emits a board that the review side then validates. + + from pcbsight import Design, ARouter # experimental """ -__version__ = "0.6.0" +__version__ = "0.7.0" from .board import Arc, Board, Pad, Track, Via, Zone, parse_board from .sexpr import dumps as sexpr_dumps @@ -18,10 +25,20 @@ from .errors import BadArgumentError, BadBoardError, PCBSightError from .report import analyze, inspect +# --- experimental create side (WIP) --------------------------------------- +from . import library +from .aroute import ARouter +from .design import Component, Design, Pin +from .library import get as get_footprint +from .router import Router + __all__ = [ "parse_board", "Board", "Track", "Via", "Pad", "Arc", "Zone", "load_sexpr", "save_sexpr", "sexpr_dumps", "analyze", "inspect", "connectivity", "clearance", "current_capacity", "diff_pairs", "microstrip_z0", "PCBSightError", "BadBoardError", "BadArgumentError", + # experimental create side + "Design", "Component", "Pin", "ARouter", "Router", + "library", "get_footprint", ] diff --git a/pcbsight/pcbsight/aroute.py b/pcbsight/pcbsight/aroute.py new file mode 100644 index 0000000..bf0fef2 --- /dev/null +++ b/pcbsight/pcbsight/aroute.py @@ -0,0 +1,340 @@ +"""A clearance-correct autorouter — "a router an AI can trust". + +The toy maze router laid copper its own clearance halo allowed but that +pcbsight then flagged, because it never reserved the width of the trace it +was *currently* laying. This one does: when routing a net of width w, every +obstacle (foreign pad, foreign trace, via) is inflated by + + obstacle_half + clearance + w/2 + +so the routed centre-line is at least (clearance + w/2 + obstacle_half) +from any foreign copper, and therefore the finished edge-to-edge gap is +>= clearance BY CONSTRUCTION. No finding can appear. + +A* over (layer, x, y) on a fine grid, two copper layers joined by vias, +multi-terminal nets grown terminal by terminal, and a rip-up-and-retry +outer loop so a net that gets boxed in is retried after the others move. +""" + +from __future__ import annotations + +import heapq +import math + + +class ARouter: + def __init__(self, x0, y0, w, h, grid=0.15, clearance=0.2, + via_cost=30.0, via_size=0.6, hweight=1.8, budget=120000): + self.x0, self.y0 = x0, y0 + self.grid = grid + self.clear = clearance + self.via_cost = via_cost + self.via_size = via_size + # weighted A*: focus the search toward the goal so it terminates in + # far fewer nodes on big open boards (and actually reaches the + # second-layer detour instead of sweeping F.Cu until budget runs + # out). hweight>1 trades slightly longer traces for a huge speedup. + self.hweight = hweight + self.budget = budget + self.nx = int(math.ceil(w / grid)) + 1 + self.ny = int(math.ceil(h / grid)) + 1 + self.pads = [] # (cx, cy, half, net, layers:tuple[int]) + self.obst = [] # laid copper: ('seg', a, b, half, net, layer) + self.vias = [] # (cx, cy, half, net) + self.out_segs = [] # (x1,y1,x2,y2,width,layer,net) world + self.out_vias = [] # (x,y,size,net) world + + # --- coordinate helpers -------------------------------------------- + def _c(self, x, y): + return (int(round((x - self.x0) / self.grid)), + int(round((y - self.y0) / self.grid))) + + def _w(self, ix, iy): + return (self.x0 + ix * self.grid, self.y0 + iy * self.grid) + + def _in(self, ix, iy): + return 0 <= ix < self.nx and 0 <= iy < self.ny + + def add_pad(self, x, y, w, h, net, through): + cx, cy = self._c(x, y) + layers = (0, 1) if through else (0,) + # store the pad as a RECTANGLE (half-dims in cells); a circle of + # its long side would over-block the gaps between fine-pitch pins + self.pads.append((cx, cy, (w / 2) / self.grid, (h / 2) / self.grid, + net, layers)) + + # --- rect / segment stamps into an arbitrary mask ------------------ + def _stamp_rect(self, buf, cx, cy, hw, hh, reach): + """Mark every cell within `reach` mm of the rectangle centred at + (cx,cy) with half-extents (hw,hh) cells. buf is a flat bytearray.""" + rc = reach / self.grid + rx = int(math.ceil(hw + rc)) + ry = int(math.ceil(hh + rc)) + for dy in range(-ry, ry + 1): + yy = cy + dy + if not (0 <= yy < self.ny): + continue + ddy = max(abs(dy) - hh, 0.0) + row = yy * self.nx + for dx in range(-rx, rx + 1): + xx = cx + dx + if not (0 <= xx < self.nx): + continue + ddx = max(abs(dx) - hw, 0.0) + if ddx * ddx + ddy * ddy <= rc * rc: + buf[row + xx] = 1 + + def _stamp_seg(self, buf, ax, ay, bx, by, reach): + r = int(math.ceil(reach / self.grid)) + lo_x, hi_x = min(ax, bx) - r, max(ax, bx) + r + lo_y, hi_y = min(ay, by) - r, max(ay, by) + r + for yy in range(max(0, lo_y), min(self.ny, hi_y + 1)): + row = yy * self.nx + for xx in range(max(0, lo_x), min(self.nx, hi_x + 1)): + if _pt_seg_cells(xx, yy, ax, ay, bx, by) <= r: + buf[row + xx] = 1 + + # --- per-net blocked map (the clearance-correct part) -------------- + def _blocked(self, net, width): + """Returns (blk, vblk): + blk[L] - cells a TRACE centre of this net may not occupy on layer L + (foreign copper inflated by clearance + trace_half). + vblk - cells a VIA of this net may not be centred on (either + layer), foreign copper inflated by clearance + VIA_half. + The via mask is the fix for vias sliding into the clearance zone: a + via is ~0.6 mm, far wider than the trace, so it needs its own, larger + halo — checking it against the trace map let vias sit too close.""" + half = width / 2.0 + vhalf = self.via_size / 2.0 + blk = [bytearray(self.nx * self.ny) for _ in range(2)] + vblk = bytearray(self.nx * self.ny) + + for (cx, cy, hw, hh, pnet, players) in self.pads: + if pnet == net: + continue + for L in players: + self._stamp_rect(blk[L], cx, cy, hw, hh, self.clear + half) + self._stamp_rect(vblk, cx, cy, hw, hh, self.clear + vhalf) + for (cx, cy, vh, vnet) in self.vias: + if vnet == net: + continue + self._stamp_rect(blk[0], cx, cy, 0, 0, vh + self.clear + half) + self._stamp_rect(blk[1], cx, cy, 0, 0, vh + self.clear + half) + self._stamp_rect(vblk, cx, cy, 0, 0, vh + self.clear + vhalf) + for kind, a, b, oh, onet, oL in self.obst: + if onet == net: + continue + (ax, ay), (bx, by) = a, b + self._stamp_seg(blk[oL], ax, ay, bx, by, oh + self.clear + half) + self._stamp_seg(vblk, ax, ay, bx, by, oh + self.clear + vhalf) + return blk, vblk + + # --- A* between a set of source cells and a set of target cells ---- + def _astar(self, sources, targets, blk, vblk, anchors=None): + tgt = set(targets) + pq = [] + g = {} + prev = {} + # heuristic = Manhattan distance to the NEAREST remaining terminal + # (anchor points), not the centroid: a net spread across the whole + # board (GND, 3V3) would otherwise be pulled toward the middle and + # never reach its far terminals within budget. + if anchors is None: + cx = sum(t[1] for t in tgt) / len(tgt) + cy = sum(t[2] for t in tgt) / len(tgt) + anchors = ((cx, cy),) + axs = [a[0] for a in anchors] + ays = [a[1] for a in anchors] + na = len(axs) + + def hmin(x, y): + best = 1e18 + for i in range(na): + dxi = axs[i] - x + if dxi < 0: + dxi = -dxi + dyi = ays[i] - y + if dyi < 0: + dyi = -dyi + d = dxi + dyi + if d < best: + best = d + return best + + W = self.hweight + for s in sources: + g[s] = 0.0 + h = hmin(s[1], s[2]) + heapq.heappush(pq, (W * h, h, s)) + budget = self.budget + while pq: + budget -= 1 + if budget <= 0: + return None + _f, _hh, cur = heapq.heappop(pq) + if cur in tgt: + return self._trace(prev, cur, sources) + cg = g[cur] + L, x, y = cur + for nL, nx_, ny_, step in ((L, x + 1, y, 1.0), (L, x - 1, y, 1.0), + (L, x, y + 1, 1.0), (L, x, y - 1, 1.0), + (1 - L, x, y, self.via_cost)): + if not self._in(nx_, ny_): + continue + nxt = (nL, nx_, ny_) + cell = ny_ * self.nx + nx_ + if nL != L: # via move: ALWAYS respect the + if vblk[cell]: # via halo, even onto a target + continue # (else two nets' vias can stack) + elif nxt not in tgt and blk[nL][cell]: # ortho: trace halo + continue + ng = cg + step + if ng < g.get(nxt, 1e18): + g[nxt] = ng + prev[nxt] = cur + h = hmin(nx_, ny_) + heapq.heappush(pq, (ng + W * h, h, nxt)) + return None + + def _h(self, node, tx, ty): + return abs(node[1] - tx) + abs(node[2] - ty) + + def _trace(self, prev, end, sources): + path = [end] + while path[-1] not in sources: + p = prev.get(path[-1]) + if p is None: + break + path.append(p) + path.reverse() + return path + + # --- route one net (all its terminals) ----------------------------- + def route_net(self, net, terminals, width, partial=False): + """terminals: list of (x, y, through). Returns True if all joined + (partial=True: lay what is reachable, always return True).""" + if len(terminals) < 2: + return True + blk, vblk = self._blocked(net, width) + cells = [] + for (x, y, th) in terminals: + cx, cy = self._c(x, y) + layers = (0, 1) if th else (0,) + cells.append({(L, cx, cy) for L in layers}) + routed = set(cells[0]) + remaining = list(cells[1:]) + laid = [] + while remaining: + # ONE multi-target A* from the whole tree to the union of all + # remaining terminals; connect whichever is reached first. This is + # O(k) A* calls instead of O(k^2) — essential for big nets like a + # 45-pad GND, where the per-terminal scan was pathologically slow. + all_targets = set() + cell2term = {} + anchors = [] + for i, t in enumerate(remaining): + for c in t: + all_targets.add(c) + cell2term[c] = i + ac = next(iter(t)) # one anchor per terminal + anchors.append((ac[1], ac[2])) + path = self._astar(routed, all_targets, blk, vblk, anchors) + if path is None or cell2term.get(path[-1]) is None: + # no remaining terminal reachable from the tree so far + if partial: # lay what we have, drop the rest + break + return False + laid.append(path) + routed |= set(path) + remaining.pop(cell2term[path[-1]]) + for path in laid: + self._lay(net, path, width) + # partial (GND): always keep the copper we managed to lay; the poured + # plane + these vias carry the rest. A hard net must connect fully. + return True if partial else (len(remaining) == 0) + + def _lay(self, net, path, width): + half = width / 2.0 + i = 0 + n = len(path) + while i < n - 1: + L, x, y = path[i] + L2, x2, y2 = path[i + 1] + if L2 != L: # via + self.vias.append((x, y, self.via_size / 2, net)) + wx, wy = self._w(x, y) + self.out_vias.append((wx, wy, self.via_size, net)) + i += 1 + continue + dx, dy = x2 - x, y2 - y # straight run + j = i + 1 + while j < n - 1: + Lj, xj, yj = path[j] + Lk, xk, yk = path[j + 1] + if Lk != Lj or (xk - xj, yk - yj) != (dx, dy): + break + j += 1 + ex, ey = path[j][1], path[j][2] + self.obst.append(('seg', (x, y), (ex, ey), half, net, L)) + ax, ay = self._w(x, y) + bx, by = self._w(ex, ey) + self.out_segs.append((ax, ay, bx, by, width, + "F.Cu" if L == 0 else "B.Cu", net)) + i = j + + def _reset_copper(self): + self.obst = [] + self.vias = [] + self.out_segs = [] + self.out_vias = [] + + # --- rip-up-and-retry over a list of nets -------------------------- + def route_all(self, nets, rounds=4, defer=()): + """nets: list of (net_id, terminals, width). Returns failed ids. + + Rip-up-and-retry: each round rips up ALL routing and re-routes, + giving the nets that failed last round FIRST pick of the board + (routed on the empty board, the others weave around them). Keeps + the best round. This breaks the congestion deadlocks that a single + greedy pass gets stuck in. + + `defer` = net ids to route LAST (e.g. a big GND that is also poured): + a huge many-terminal net would otherwise be routed first and hog the + tight areas the fine-pitch signals need to escape.""" + defer = set(defer) + best_segs = best_vias = best_failed = None + for rnd in range(rounds): + self._reset_copper() + if rnd == 0 or best_failed is None: + # deferred nets last; else hardest first (many-terminal, span) + key = lambda nt: (nt[0] in defer, -len(nt[1]), -_span(nt[1])) + else: + fset = set(best_failed) + key = lambda nt: (nt[0] in defer, nt[0] not in fset, + -len(nt[1]), -_span(nt[1])) + failed = [] + for (nid, terms, w) in sorted(nets, key=key): + if not self.route_net(nid, terms, w, partial=(nid in defer)): + failed.append(nid) + if best_failed is None or len(failed) < len(best_failed): + best_failed, best_segs = failed, list(self.out_segs) + best_vias = list(self.out_vias) + if not failed: + break + self.out_segs, self.out_vias = best_segs, best_vias + return best_failed + + +def _pt_seg_cells(px, py, ax, ay, bx, by) -> float: + dx, dy = bx - ax, by - ay + L2 = dx * dx + dy * dy + if L2 == 0: + return math.hypot(px - ax, py - ay) + t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L2)) + return math.hypot(px - (ax + t * dx), py - (ay + t * dy)) + + +def _span(terminals): + xs = [t[0] for t in terminals] + ys = [t[1] for t in terminals] + return (max(xs) - min(xs)) + (max(ys) - min(ys)) diff --git a/pcbsight/pcbsight/checks.py b/pcbsight/pcbsight/checks.py index 2608c96..81ca9ce 100644 --- a/pcbsight/pcbsight/checks.py +++ b/pcbsight/pcbsight/checks.py @@ -17,7 +17,7 @@ import math -from .board import Board, Pad, Track, Zone, pad_on_layer +from .board import Board, Pad, Track, pad_on_layer CLEARANCE_DEFAULT = 0.2 # mm — a common fab minimum; overridable DT_DEFAULT = 10.0 # deg C rise for the IPC current table @@ -52,6 +52,35 @@ def _pad_radius(p: Pad) -> float: return max(p.size) / 2.0 +def _seg_pad_dist(a1, a2, pad: Pad) -> float: + """Distance from a track segment to a pad's RECTANGLE (respecting the + pad's rotation). A circle of the long side over-states an elongated + pad and would flag a trace escaping cleanly past its narrow edge — + which is exactly how fine-pitch pins are routed.""" + cx, cy = pad.at + hw, hh = pad.size[0] / 2.0, pad.size[1] / 2.0 + ang = math.radians(pad.rot) + ca, sa = math.cos(-ang), math.sin(-ang) + + def local(p): + dx, dy = p[0] - cx, p[1] - cy + return (dx * ca - dy * sa, dx * sa + dy * ca) + + l1, l2 = local(a1), local(a2) + + def inside(p): + return -hw <= p[0] <= hw and -hh <= p[1] <= hh + + if inside(l1) or inside(l2): + return 0.0 + corners = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)] + best = float("inf") + for i in range(4): + best = min(best, _seg_seg_dist(l1, l2, corners[i], + corners[(i + 1) % 4])) + return best + + # --------------------------------------------------------------------------- # polygon geometry — for copper pours (zones) # --------------------------------------------------------------------------- @@ -336,8 +365,7 @@ def clearance(board: Board, min_clearance: float = CLEARANCE_DEFAULT p = pads[(j - nt) if a_track else (i - nt)] if p.net == t.net or not pad_on_layer(p, t.layer): continue - d = _seg_seg_dist(p.at, p.at, t.start, t.end) \ - - t.width / 2 - _pad_radius(p) + d = _seg_pad_dist(t.start, t.end, p) - t.width / 2 if d < min_clearance: findings.append({ "kind": "track-pad", diff --git a/pcbsight/pcbsight/design.py b/pcbsight/pcbsight/design.py new file mode 100644 index 0000000..da86112 --- /dev/null +++ b/pcbsight/pcbsight/design.py @@ -0,0 +1,392 @@ +"""Author a board from electrical INTENT, not from copper coordinates. + + d = Design("mando") + u = d.part("IC_SOP8", "U1", value="TP4056", lcsc="C16581") + c = d.part("C_0402", "C1", value="10uF", lcsc="C15525") + d.place(u, 10, 10); d.place(c, 14, 10) + d.net("VBAT", u["3"], c["1"]) + d.net("GND", u["2"], c["2"]) + d.autoroute() + d.save("mando.kicad_pcb") # -> pcbsight inspect validates it + +The point: you declare which PINS connect (a net), and the tool owns the +error-prone geometry — footprint placement, net numbering, routing. That +is what makes the result correct by construction instead of the hand-laid +copper an AI gets wrong. pcbsight is then the acceptance test. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +from . import library as L +from .router import Router + + +@dataclass +class Pin: + comp: "Component" + name: str + + def world(self) -> tuple[float, float]: + return self.comp.pad_world(self.name) + + @property + def through(self) -> bool: + return self.comp.footprint_pad(self.name).through + + +@dataclass +class Component: + ref: str + fp: L.Footprint + value: str = "" + lcsc: str = "" + at: tuple[float, float] = (0.0, 0.0) + rot: float = 0.0 + + def __getitem__(self, pin_name: str) -> Pin: + if pin_name not in self.fp.pin_names(): + raise KeyError( + f"{self.ref} ({self.fp.name}) has no pin '{pin_name}'; " + f"pins are {self.fp.pin_names()}") + return Pin(self, pin_name) + + def footprint_pad(self, name: str) -> L.PadDef: + return next(p for p in self.fp.pads if p.name == name) + + def pad_world(self, name: str) -> tuple[float, float]: + p = self.footprint_pad(name) + a = math.radians(self.rot) + lx, ly = p.at + return (self.at[0] + lx * math.cos(a) + ly * math.sin(a), + self.at[1] - lx * math.sin(a) + ly * math.cos(a)) + + +class Design: + def __init__(self, name: str = "board"): + self.name = name + self.components: list[Component] = [] + self.nets: dict[str, list[Pin]] = {} + self.net_width: dict[str, float] = {} + self._pours: list[tuple[str, str, float]] = [] + self._manual: dict[str, list] = {} # net -> [(layer,width,pts)] + self._manual_nets: set[str] = set() + self._manual_vias: list[tuple] = [] + self._escape_segs: list[tuple] = [] # (net, width, [p0,p1]) world + self._segments: list[str] = [] + self._vias: list[str] = [] + self._routed = False + + # --- authoring ------------------------------------------------------ + def part(self, footprint: str, ref: str, value: str = "", + lcsc: str = "") -> Component: + if any(c.ref == ref for c in self.components): + raise ValueError(f"duplicate reference {ref}") + c = Component(ref=ref, fp=L.get(footprint), value=value, lcsc=lcsc) + self.components.append(c) + return c + + def place(self, comp: Component, x: float, y: float, + rot: float = 0.0) -> Component: + comp.at = (float(x), float(y)) + comp.rot = float(rot) + return comp + + def net(self, name: str, *pins: Pin, width: float | None = None) -> None: + self.nets.setdefault(name, []) + for p in pins: + self.nets[name].append(p) + if width is not None: + self.net_width[name] = width + + def pour(self, name: str, layer: str = "F.Cu", inset: float = 1.0 + ) -> None: + """Flood a net as a copper plane over the whole board. This is how + a ground net closes on a real board — every pad of the net sits on + the fill — so it is not routed pad-to-pad; pcbsight reads the pour + and counts those pads as connected.""" + self.nets.setdefault(name, []) + self._pours.append((name, layer, inset)) + + def _poured(self) -> set[str]: + return {n for n, _l, _i in self._pours} + + def route(self, name: str, waypoints, layer: str = "F.Cu", + width: float = 0.25) -> None: + """Lay a net's copper by hand, as a polyline of world points. + This is how a small board is really routed — place, then draw each + trace deliberately — and it closes exactly the fine-pitch escapes + the maze router cannot. pcbsight then proves it is clean. A net can + take several route() calls (branches); the autorouter skips any net + routed this way.""" + pts = [(float(x), float(y)) for x, y in waypoints] + self._manual.setdefault(name, []) + self._manual[name].append((layer, width, pts)) + self._manual_nets.add(name) + + def via(self, name: str, x: float, y: float, size: float = 0.6, + drill: float = 0.3) -> None: + """Drop a via on a net (to hop layers in a hand route).""" + self._manual_vias.append((name, float(x), float(y), size, drill)) + + # --- geometry ------------------------------------------------------- + def _extent(self, margin: float) -> tuple[float, float, float, float]: + xs, ys = [], [] + for c in self.components: + hw, hh = c.fp.courtyard[0] / 2, c.fp.courtyard[1] / 2 + xs += [c.at[0] - hw, c.at[0] + hw] + ys += [c.at[1] - hh, c.at[1] + hh] + if not xs: + return (0.0, 0.0, 20.0, 20.0) + return (min(xs) - margin, min(ys) - margin, + max(xs) + margin, max(ys) + margin) + + def net_ids(self) -> dict[str, int]: + return {name: i + 1 for i, name in enumerate(sorted(self.nets))} + + # --- routing -------------------------------------------------------- + def _escape(self, pin, length: float): + """A pad's escape point: `length` mm straight out from the pad, + PERPENDICULAR to the pad row (so it leaves between the neighbours, + not through them), on the side away from the footprint centre. + Routing between escape points instead of pads keeps the maze + router off the fine-pitch pad rows.""" + c = pin.comp + px, py = pin.world() + others = [c.pad_world(pd.name) for pd in c.fp.pads + if pd.name != pin.name] + if others: + nb = min(others, key=lambda o: (o[0] - px) ** 2 + (o[1] - py) ** 2) + rx, ry = px - nb[0], py - nb[1] # neighbour -> pad + L = math.hypot(rx, ry) or 1.0 + nx, ny = -ry / L, rx / L # perpendicular to row + if (px - c.at[0]) * nx + (py - c.at[1]) * ny < 0: + nx, ny = -nx, -ny # point away from centre + else: + dx, dy = px - c.at[0], py - c.at[1] + if abs(dx) >= abs(dy): + nx, ny = (1.0 if dx >= 0 else -1.0), 0.0 + else: + nx, ny = 0.0, (1.0 if dy >= 0 else -1.0) + return (px, py), (px + nx * length, py + ny * length) + + def autoroute(self, default_width: float = 0.25, margin: float = 2.5, + grid: float = 0.2, clear: float = 0.25, + escape_len: float = 1.0) -> list[str]: + """Route every declared net with the shipped maze router. Returns + the list of nets it could NOT fully route (empty == success). + + grid: cell size — finer escapes fine-pitch parts (slower). + clear: routing clearance — keep it >= the clearance you inspect + with, or the router lays copper pcbsight then flags.""" + x1, y1, x2, y2 = self._extent(margin) + w, h = x2 - x1, y2 - y1 + self._off = (x1, y1) # shift into the router's box + r = Router(w, h, margin=margin, grid=grid, clear=clear) + ids = self.net_ids() + + def sh(p): + return (p[0] - x1, p[1] - y1) + + # block every pad as an obstacle owned by its net + for c in self.components: + for pad in c.fp.pads: + wx, wy = c.pad_world(pad.name) + netname = self._net_of(c, pad.name) + nid = ids.get(netname, 0) + bx, by = sh((wx, wy)) + r.block_pad(bx, by, pad.size[0], pad.size[1], nid, + through=pad.through) + r.block_edge() + + poured = self._poured() + failed = [] + for name in sorted(self.nets): + if name in poured or name in self._manual_nets: + continue # plane / hand-routed already + pins = self.nets[name] + if len(pins) < 2: + continue + width = self.net_width.get(name, default_width) + pads = [] + for p in pins: + if escape_len > 1e-6: + (px, py), (ex, ey) = self._escape(p, escape_len) + self._escape_segs.append((name, width, + [(px, py), (ex, ey)])) + r.block_pad(*sh((ex, ey)), 0.3, 0.3, ids[name]) + pads.append((*sh((ex, ey)), p.through)) + else: + wx, wy = p.world() + pads.append((*sh((wx, wy)), p.through)) + ok = r.route(ids[name], pads, width) + if not ok: + failed.append(name) + # shift routed geometry back to world coordinates on emit + self._segments = r.segments + self._vias = r.vias + self._routed = True + self._failed = failed + return failed + + def route_pro(self, grid: float = 0.15, clear: float = 0.2, + margin: float = 2.5) -> list[str]: + """Route every net with the clearance-correct A* autorouter + (pcbsight.aroute). Clean by construction — a routed board has no + clearance findings. GND is routed as a normal net here (no plane), + so nothing is left to a pour. Returns the nets it could not + complete (empty == fully routed).""" + from .aroute import ARouter + x1, y1, x2, y2 = self._extent(margin) + r = ARouter(x1, y1, x2 - x1, y2 - y1, grid=grid, clearance=clear) + ids = self.net_ids() + id2name = {v: k for k, v in ids.items()} + for c in self.components: + for pad in c.fp.pads: + nid = ids.get(self._net_of(c, pad.name), 0) + wx, wy = c.pad_world(pad.name) + r.add_pad(wx, wy, pad.size[0], pad.size[1], nid, pad.through) + nets = [] + for name in sorted(self.nets): + pins = self.nets[name] + if len(pins) < 2 or name in self._poured(): + continue + terms = [(*p.world(), p.through) for p in pins] + # signals default to a thin 0.15 mm so they escape fine pitch + # (USB-C 0.5 mm, TSSOP 0.65 mm); power nets set their own width + nets.append((ids[name], terms, self.net_width.get(name, 0.15))) + failed = r.route_all(nets) + for (ax, ay, bx, by, width, layer, net) in r.out_segs: + nm = id2name[net] + self._manual.setdefault(nm, []).append( + (layer, width, [(ax, ay), (bx, by)])) + for (vx, vy, size, net) in r.out_vias: + self._manual_vias.append((id2name[net], vx, vy, size, size / 2)) + self._routed = True + self._failed = [id2name[f] for f in failed] + return self._failed + + def _net_of(self, comp: Component, pin_name: str) -> str: + for name, pins in self.nets.items(): + if any(p.comp is comp and p.name == pin_name for p in pins): + return name + return "" + + # --- emit ----------------------------------------------------------- + def to_kicad(self, margin: float = 2.5) -> str: + ids = self.net_ids() + x1, y1, x2, y2 = self._extent(margin) + ox, oy = getattr(self, "_off", (x1, y1)) + lines = ['(kicad_pcb (version 20221018) (generator pcbsight)'] + lines.append(' (net 0 "")') + for name, i in ids.items(): + lines.append(f' (net {i} "{name}")') + + # board outline + lines.append(f' (gr_rect (start {x1:.3f} {y1:.3f}) ' + f'(end {x2:.3f} {y2:.3f}) (layer "Edge.Cuts"))') + + # footprints, pads carry their net + for c in self.components: + lines.append(f' (footprint "{c.fp.name}" (at {c.at[0]:.3f} ' + f'{c.at[1]:.3f} {c.rot:.3f}) (layer "F.Cu")') + lines.append(f' (property "Reference" "{c.ref}" (at 0 0))') + if c.value: + lines.append(f' (property "Value" "{c.value}" (at 0 0))') + for pad in c.fp.pads: + netname = self._net_of(c, pad.name) + nid = ids.get(netname, 0) + ptype = "thru_hole" if pad.through else "smd" + lay = " ".join(f'"{L_}"' for L_ in pad.layers) + lines.append( + f' (pad "{pad.name}" {ptype} {pad.shape} ' + f'(at {pad.at[0]:.3f} {pad.at[1]:.3f}) ' + f'(size {pad.size[0]:.3f} {pad.size[1]:.3f}) ' + f'(layers {lay}) (net {nid} "{netname}"))') + lines.append(' )') + + # copper planes (pours): a rectangle inset from the outline + for name, layer, inset in self._pours: + nid = ids[name] + zx1, zy1 = x1 + inset, y1 + inset + zx2, zy2 = x2 - inset, y2 - inset + lines.append( + f' (zone (net {nid}) (net_name "{name}") (layer "{layer}")') + lines.append(f' (filled_polygon (layer "{layer}")') + lines.append(f' (pts (xy {zx1:.3f} {zy1:.3f}) ' + f'(xy {zx2:.3f} {zy1:.3f}) (xy {zx2:.3f} {zy2:.3f}) ' + f'(xy {zx1:.3f} {zy2:.3f}))))') + + # escape stubs off each pad (world coordinates) + for name, width, pts in self._escape_segs: + nid = ids[name] + lines.append( + f' (segment (start {pts[0][0]:.3f} {pts[0][1]:.3f}) ' + f'(end {pts[1][0]:.3f} {pts[1][1]:.3f}) (width {width}) ' + f'(layer "F.Cu") (net {nid}))') + + # hand-routed copper (already in world coordinates) + for name, routes in self._manual.items(): + nid = ids[name] + for layer, width, pts in routes: + for i in range(len(pts) - 1): + a, b = pts[i], pts[i + 1] + lines.append( + f' (segment (start {a[0]:.3f} {a[1]:.3f}) ' + f'(end {b[0]:.3f} {b[1]:.3f}) (width {width}) ' + f'(layer "{layer}") (net {nid}))') + for name, x, y, size, drill in self._manual_vias: + nid = ids[name] + lines.append(f' (via (at {x:.3f} {y:.3f}) (size {size}) ' + f'(drill {drill}) (layers "F.Cu" "B.Cu") ' + f'(net {nid}))') + + # routed copper — the router worked in a shifted box; put it back + for s in self._segments: + lines.append(self._unshift(s, ox, oy)) + for v in self._vias: + lines.append(self._unshift(v, ox, oy)) + + lines.append(')') + return "\n".join(lines) + "\n" + + @staticmethod + def _unshift(line: str, ox: float, oy: float) -> str: + """The router emits (start X Y)/(end X Y)/(at X Y) in its own box; + add the offset back so the emitted board is in world space.""" + import re + + def repl(m): + tag, a, b = m.group(1), float(m.group(2)), float(m.group(3)) + return f'({tag} {a + ox:.3f} {b + oy:.3f}' + return re.sub(r'\((start|end|at) ' + r'(-?\d+\.?\d*) (-?\d+\.?\d*)', repl, line) + + def save(self, path: str, margin: float = 2.5) -> None: + if not self._routed: + self.autoroute(margin=margin) + from pathlib import Path + Path(path).write_text(self.to_kicad(margin=margin), encoding="utf-8") + + # --- fabrication outputs ------------------------------------------- + def bom(self) -> list[dict]: + groups: dict[tuple, list[str]] = {} + meta: dict[tuple, tuple] = {} + for c in self.components: + key = (c.value, c.fp.name, c.lcsc) + groups.setdefault(key, []).append(c.ref) + meta[key] = (c.value, c.fp.name, c.lcsc) + rows = [] + for key, refs in groups.items(): + value, fp, lcsc = meta[key] + rows.append({"Comment": value or fp, "Designator": ",".join( + sorted(refs)), "Footprint": fp, "LCSC": lcsc}) + return sorted(rows, key=lambda r: r["Designator"]) + + def cpl(self) -> list[dict]: + return [{"Designator": c.ref, "Mid X": round(c.at[0], 3), + "Mid Y": round(c.at[1], 3), "Layer": "Top", + "Rotation": round(c.rot, 1)} + for c in sorted(self.components, key=lambda c: c.ref)] diff --git a/pcbsight/pcbsight/library.py b/pcbsight/pcbsight/library.py new file mode 100644 index 0000000..f6b63bb --- /dev/null +++ b/pcbsight/pcbsight/library.py @@ -0,0 +1,258 @@ +"""A small library of real component footprints, so a design is built +from parts that exist and can be soldered — not from invented pads. + +A footprint is a land pattern in LOCAL millimetres (origin at the part +centre); the design composes it into world coordinates through the +part's placement, exactly the way pcbsight reads a .kicad_pcb back. + +HONESTY: these land patterns are nominal (close to IPC / the part +datasheet, good enough to route and to look right), NOT signed-off fab +data. Before a real order, the pad geometry of each footprint should be +checked against the manufacturer's recommended pattern — ideally by +importing the KiCad standard footprint for that exact part. They are +enough to prove the pipeline and to place/route the board. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class PadDef: + name: str # "1", "2", "VBUS", "GND", ... + at: tuple[float, float] # LOCAL mm, part centre = origin + size: tuple[float, float] + shape: str = "roundrect" # rect | roundrect | circle | oval + through: bool = False + layers: tuple[str, ...] = ("F.Cu",) + + +@dataclass +class Footprint: + name: str # "R_0402", "IC_SOP8", ... + pads: list[PadDef] + courtyard: tuple[float, float] = (0.0, 0.0) # w, h for the body box + description: str = "" + + def pin_names(self) -> list[str]: + return [p.name for p in self.pads] + + +def _smd(name, x, y, w, h, shape="roundrect") -> PadDef: + return PadDef(name=name, at=(x, y), size=(w, h), shape=shape) + + +# --------------------------------------------------------------------------- +# passives — two-terminal chip parts +# --------------------------------------------------------------------------- + +def _chip(name, pad_w, pad_h, dx, court) -> Footprint: + return Footprint(name, [_smd("1", -dx, 0, pad_w, pad_h), + _smd("2", +dx, 0, pad_w, pad_h)], + courtyard=court, description="2-terminal chip") + + +R_0402 = _chip("R_0402", 0.6, 0.5, 0.51, (1.5, 0.8)) +C_0402 = _chip("C_0402", 0.6, 0.5, 0.51, (1.5, 0.8)) +R_0603 = _chip("R_0603", 0.9, 0.8, 0.85, (2.0, 1.2)) +C_0603 = _chip("C_0603", 0.9, 0.8, 0.85, (2.0, 1.2)) + +# an LED is a chip part with a polarity: pad 1 = anode, pad 2 = cathode +LED_0603 = Footprint("LED_0603", + [_smd("A", -0.85, 0, 0.9, 0.8), + _smd("K", +0.85, 0, 0.9, 0.8)], + courtyard=(2.0, 1.2), description="LED, A=anode K=cathode") + + +# --------------------------------------------------------------------------- +# SOT-23-5 — for a small LDO (XC6206 / TLV70033 class) +# 1,2,3 on the bottom edge, 4,5 on the top edge, 0.95 mm pitch +# --------------------------------------------------------------------------- + +SOT23_5 = Footprint("SOT23_5", [ + _smd("1", -0.95, 1.1, 0.6, 1.0), + _smd("2", 0.0, 1.1, 0.6, 1.0), + _smd("3", 0.95, 1.1, 0.6, 1.0), + _smd("4", 0.95, -1.1, 0.6, 1.0), + _smd("5", -0.95, -1.1, 0.6, 1.0), +], courtyard=(3.0, 3.0), description="SOT-23-5") + + +# --------------------------------------------------------------------------- +# SOP-8 / SOIC-8 — for the TP4056 charger +# pitch 1.27, row spacing 5.4 (pad centres), pads 0.6 x 1.55 +# --------------------------------------------------------------------------- + +def _dual_row(n_side, pitch, row_dx, pad_w, pad_h, court) -> Footprint: + pads = [] + y0 = (n_side - 1) * pitch / 2 + for i in range(n_side): # left column, top->bottom + pads.append(_smd(str(i + 1), -row_dx, y0 - i * pitch, pad_w, pad_h)) + for i in range(n_side): # right column, bottom->top + pads.append(_smd(str(n_side + i + 1), row_dx, + -y0 + i * pitch, pad_w, pad_h)) + return Footprint("", pads, courtyard=court) + + +def _sop8() -> Footprint: + # pads run OUTWARD (wide in x), narrow in y (< pitch) so they don't + # overlap and leave clean escape corridors between pins + fp = _dual_row(4, 1.27, 2.7, 1.5, 0.55, (7.0, 5.5)) + fp.name = "IC_SOP8" + fp.description = "SOP-8 / SOIC-8" + return fp + + +IC_SOP8 = _sop8() + + +# --------------------------------------------------------------------------- +# USB-C receptacle, power-only 6-pin (charge input) +# VBUS x2, GND x2, CC1, CC2 + four board-mount/shield tabs (net SHIELD) +# --------------------------------------------------------------------------- + +# 0.5 mm pitch signal row (pads 0.3 wide -> 0.2 mm gaps), shield tabs +# outside. Order across the row: GND VBUS CC1 CC2 VBUS GND. +USB_C_6 = Footprint("USB_C_6", [ + _smd("GND", -1.25, 0.0, 0.3, 1.3), + _smd("VBUS", -0.75, 0.0, 0.3, 1.3), + _smd("CC1", -0.25, 0.0, 0.3, 1.3), + _smd("CC2", 0.25, 0.0, 0.3, 1.3), + _smd("VBUS2", 0.75, 0.0, 0.3, 1.3), + _smd("GND2", 1.25, 0.0, 0.3, 1.3), + # mechanical shield tabs, tied to GND in the design + _smd("S1", -3.2, 0.9, 1.0, 1.8, "rect"), + _smd("S2", 3.2, 0.9, 1.0, 1.8, "rect"), + _smd("S3", -3.2, -0.9, 1.0, 1.8, "rect"), + _smd("S4", 3.2, -0.9, 1.0, 1.8, "rect"), +], courtyard=(7.0, 4.5), description="USB-C 6P power/charge receptacle") + + +# --------------------------------------------------------------------------- +# generic 2-pad block — LiPo battery pads, antenna solder point, etc. +# --------------------------------------------------------------------------- + +BAT_2 = Footprint("BAT_2", [ + _smd("+", -1.6, 0, 1.6, 2.2, "rect"), + _smd("-", 1.6, 0, 1.6, 2.2, "rect"), +], courtyard=(5.0, 3.0), description="2-pad battery / wire land") + +# antenna solder pad + a keepout note (RF handled as a reference block) +ANT_1 = Footprint("ANT_1", [_smd("RF", 0, 0, 1.5, 1.5, "rect")], + courtyard=(2.5, 2.5), description="helical antenna pad") + +# single flat SMD test / programming pad (SWD) +TESTPAD = Footprint("TESTPAD", [_smd("1", 0, 0, 1.5, 1.5, "rect")], + courtyard=(2.0, 2.0), description="single SMD test/programming pad") + + +# --------------------------------------------------------------------------- +# a row of SMD test / programming pads (1xN, 2.0 mm pitch) +# --------------------------------------------------------------------------- + +def test_row(names, pitch=2.0) -> Footprint: + n = len(names) + x0 = -(n - 1) * pitch / 2 + pads = [_smd(nm, x0 + i * pitch, 0, 1.2, 2.0, "rect") + for i, nm in enumerate(names)] + return Footprint("TEST_%d" % n, pads, courtyard=(n * pitch, 2.6), + description="SMD test/programming pads") + + +# --------------------------------------------------------------------------- +# MCU — TSSOP-20 (STM32G030F6P6 class), 0.65 mm pitch +# --------------------------------------------------------------------------- + +def _tssop20() -> Footprint: + fp = _dual_row(10, 0.65, 3.0, 1.2, 0.38, (7.6, 7.5)) + fp.name = "MCU_TSSOP20" + fp.description = "TSSOP-20, 0.65 mm (STM32G030F6 class)" + return fp + + +MCU_TSSOP20 = _tssop20() + + +# --------------------------------------------------------------------------- +# through-hole headers / connectors — the coarse, easy-to-route stuff +# --------------------------------------------------------------------------- + +def _th(name, x, y, dia=1.6) -> PadDef: + return PadDef(name=name, at=(x, y), size=(dia, dia), shape="circle", + through=True, layers=("*.Cu",)) + + +# CC1101 radio module: an 8-pin header (2 x 4, 2.54 mm). The RF, matching +# and antenna live ON the module, so the board only wires these pins. +MOD_CC1101 = Footprint("MOD_CC1101", [ + _th("GND", -3.81, -1.27), _th("VCC", -1.27, -1.27), + _th("GDO0", 1.27, -1.27), _th("CSN", 3.81, -1.27), + _th("SCK", -3.81, 1.27), _th("MOSI", -1.27, 1.27), + _th("MISO", 1.27, 1.27), _th("GDO2", 3.81, 1.27), +], courtyard=(10.0, 6.0), description="CC1101 433 MHz module, 2x4 header") + +# JST-PH 2.0 battery connector — the LiPo unplugs here (swappable) +BATCONN = Footprint("BATCONN", [ + _th("+", -1.0, 0), _th("-", 1.0, 0), +], courtyard=(6.0, 4.5), description="JST-PH 2.0 battery (unpluggable)") + +# tactile push button, SMD, two terminals +SW_TACT = Footprint("SW_TACT", [ + _smd("1", -2.2, 0, 1.2, 1.5, "rect"), + _smd("2", 2.2, 0, 1.2, 1.5, "rect"), +], courtyard=(5.0, 3.2), description="SMD tactile button") + + +# 0402 inductor (same land as the 0402 chips) — RF matching network +L_0402 = _chip("L_0402", 0.6, 0.5, 0.51, (1.5, 0.8)) + +# CC1101 bare chip, QFN-20 4x4 + exposed pad "21". Geometry approximate (the +# KiCad flow routes on the REAL footprint); only the pad NAMES matter here. +def _qfn20(): + pads, p, d = [], 0.5, 2.15 + for i in range(5): # 1-5 left + pads.append(_smd(str(1 + i), -d, 1.0 - i*p, 0.25, 0.30, "rect")) + for i in range(5): # 6-10 bottom + pads.append(_smd(str(6 + i), -1.0 + i*p, -d, 0.30, 0.25, "rect")) + for i in range(5): # 11-15 right + pads.append(_smd(str(11 + i), d, -1.0 + i*p, 0.25, 0.30, "rect")) + for i in range(5): # 16-20 top + pads.append(_smd(str(16 + i), 1.0 - i*p, d, 0.30, 0.25, "rect")) + pads.append(_smd("21", 0, 0, 2.5, 2.5, "rect")) # exposed pad = GND + return Footprint("QFN20_CC", pads, courtyard=(4.6, 4.6), + description="CC1101 QFN-20 4x4 (EP=21)") +QFN20_CC = _qfn20() + +# 26 MHz crystal, 3225 4-pad (terminals 1 & 3, case-GND 2 & 4) +XTAL_3225 = Footprint("XTAL_3225", [ + _smd("1", -1.1, 0.85, 1.2, 1.0, "rect"), _smd("2", 1.1, 0.85, 1.2, 1.0, "rect"), + _smd("3", 1.1, -0.85, 1.2, 1.0, "rect"), _smd("4", -1.1, -0.85, 1.2, 1.0, "rect"), +], courtyard=(3.8, 3.0), description="26 MHz 3225 4-pad crystal") + +# M2 mounting hole (NPTH). Modelled as a 2.2 mm through "pad" so the router +# keeps clear of the screw barrel; the screw-head/boss keep-out is handled by +# the courtyard (no components sit on it). +MHOLE = Footprint("MHOLE", [_th("1", 0, 0, dia=2.2)], + courtyard=(5.0, 5.0), description="M2 mounting hole (NPTH)") + +# Schottky small-signal diode, SOD-323 (BAT54 class). Pad 1 = cathode (band). +D_SOD323 = Footprint("D_SOD323", [ + _smd("K", -0.85, 0, 0.5, 0.55), + _smd("A", 0.85, 0, 0.5, 0.55), +], courtyard=(2.4, 1.1), description="Schottky diode SOD-323, K=cathode A=anode") + +# a convenient registry by name +REGISTRY: dict[str, Footprint] = { + fp.name: fp for fp in (R_0402, C_0402, R_0603, C_0603, LED_0603, + SOT23_5, IC_SOP8, USB_C_6, BAT_2, ANT_1, + MCU_TSSOP20, MOD_CC1101, BATCONN, SW_TACT, TESTPAD, + MHOLE, L_0402, QFN20_CC, XTAL_3225, D_SOD323) +} + + +def get(name: str) -> Footprint: + if name not in REGISTRY: + raise KeyError(f"no footprint '{name}' in the library " + f"(have: {', '.join(sorted(REGISTRY))})") + return REGISTRY[name] diff --git a/pcbsight/pcbsight/router.py b/pcbsight/pcbsight/router.py new file mode 100644 index 0000000..cdf6c4c --- /dev/null +++ b/pcbsight/pcbsight/router.py @@ -0,0 +1,187 @@ +"""A tiny 2-layer grid autorouter for the examples. + +Not production EDA — just enough to route a demo board to zero findings +so the "after" side of a before/after is real. Lee/BFS maze routing on a +0.5 mm grid, two copper layers, vias to change layer, and a clearance +halo around every net so nothing it lays down shorts to anything else. + +Emits KiCad `segment` and `via` lines. Deterministic. +""" + +from __future__ import annotations + +import heapq +import math + +GRID = 0.4 # mm per cell +CLEAR = 0.3 # clearance halo, mm +VIA_COST = 12 # discourage layer changes + + +class Router: + def __init__(self, w: float, h: float, margin: float = 1.5, + grid: float = GRID, clear: float = CLEAR, + via_cost: float = VIA_COST): + # a finer grid escapes fine-pitch parts; clear must be >= the DRC + # minimum you will check against, or the router lays copper that + # its own clearance halo allows but pcbsight then flags + self.grid = grid + self.clear = clear + self.via_cost = via_cost + self.nx = int(w / grid) + 1 + self.ny = int(h / grid) + 1 + self.margin = margin + self.w, self.h = w, h + # occ[layer][y][x] = net id occupying the cell, or 0 = free + self.occ = [[[0] * self.nx for _ in range(self.ny)] + for _ in range(2)] + self.segments: list[str] = [] + self.vias: list[str] = [] + + def _cell(self, x, y): + return (int(round(x / self.grid)), int(round(y / self.grid))) + + def _in(self, cx, cy): + return 0 <= cx < self.nx and 0 <= cy < self.ny + + def _stamp(self, cx, cy, layer, net, radius): + r = int(math.ceil((radius + self.clear) / self.grid)) + for dy in range(-r, r + 1): + for dx in range(-r, r + 1): + x, y = cx + dx, cy + dy + if self._in(x, y): + self.occ[layer][y][x] = net + + def block_pad(self, x, y, w, h, net, through=False): + cx, cy = self._cell(x, y) + r = max(w, h) / 2 + layers = (0, 1) if through else (0,) + for L in layers: + self._stamp(cx, cy, L, net, r) + + def block_edge(self): + # a border ring is blocked to all nets (id -1) so routes stay in + for L in (0, 1): + m = int(self.margin / self.grid) + for y in range(self.ny): + for x in range(self.nx): + if (x < m or x >= self.nx - m or y < m + or y >= self.ny - m): + self.occ[L][y][x] = -1 + + def route(self, net: int, pads: list[tuple[float, float, bool]], + width: float) -> bool: + """Connect all pads of one net. pads: (x, y, through). Returns + True if every pad joined the net.""" + if len(pads) < 2: + return True + # seed the net with its first pad's cell on its layer(s) + placed = set() + first = pads[0] + c = self._cell(first[0], first[1]) + placed.add((0, c[0], c[1])) + if first[2]: + placed.add((1, c[0], c[1])) + for pad in pads[1:]: + tc = self._cell(pad[0], pad[1]) + targets = {(0, tc[0], tc[1])} + if pad[2]: + targets.add((1, tc[0], tc[1])) + path = self._maze(net, placed, targets) + if path is None: + return False + self._lay(net, path, width) + for (L, x, y) in path: + placed.add((L, x, y)) + return True + + def _maze(self, net, sources, targets): + # Dijkstra over (layer, x, y); free cell = 0 or this net; target + # cells always enterable + dist = {} + pq = [] + for s in sources: + dist[s] = 0 + heapq.heappush(pq, (0, s)) + while pq: + d, (L, x, y) = heapq.heappop(pq) + if d > dist.get((L, x, y), 1e18): + continue + if (L, x, y) in targets: + return self._trace(dist, (L, x, y), sources) + # in-plane neighbours + for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): + nx_, ny_ = x + dx, y + dy + if not self._in(nx_, ny_): + continue + occ = self.occ[L][ny_][nx_] + if occ not in (0, net) and (L, nx_, ny_) not in targets: + continue + nd = d + 1 + if nd < dist.get((L, nx_, ny_), 1e18): + dist[(L, nx_, ny_)] = nd + dist[("p", L, nx_, ny_)] = (L, x, y) + heapq.heappush(pq, (nd, (L, nx_, ny_))) + # layer change (via) + oL = 1 - L + occ = self.occ[oL][y][x] + if occ in (0, net) or (oL, x, y) in targets: + nd = d + self.via_cost + if nd < dist.get((oL, x, y), 1e18): + dist[(oL, x, y)] = nd + dist[("p", oL, x, y)] = (L, x, y) + heapq.heappush(pq, (nd, (oL, x, y))) + return None + + def _trace(self, dist, end, sources): + path = [end] + cur = end + while cur not in sources: + L, x, y = cur + prev = dist.get(("p", L, x, y)) + if prev is None: + break + path.append(prev) + cur = prev + path.reverse() + return path + + def _lay(self, net, path, width): + # mark occupancy and emit geometry, collapsing straight runs + for (L, x, y) in path: + self._stamp(x, y, L, net, width / 2) + # split into per-layer polylines, insert vias at layer changes + i = 0 + while i < len(path) - 1: + (L, x, y) = path[i] + (L2, x2, y2) = path[i + 1] + if L2 != L: # via + self.vias.append( + f' (via (at {x * self.grid:.3f} {y * self.grid:.3f}) ' + f'(size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") ' + f'(net {net}))') + i += 1 + continue + # extend a straight run on this layer + j = i + 1 + dx = x2 - x + dy = y2 - y + while j + 1 < len(path): + (Ln, xn, yn) = path[j] + (Ln2, xn2, yn2) = path[j + 1] + if Ln2 != Ln: + break + if (xn2 - xn, yn2 - yn) != (dx, dy): + break + j += 1 + (Le, xe, ye) = path[j] + layer = "F.Cu" if L == 0 else "B.Cu" + self.segments.append( + f' (segment (start {x * self.grid:.3f} {y * self.grid:.3f}) ' + f'(end {xe * self.grid:.3f} {ye * self.grid:.3f}) ' + f'(width {width}) ' + f'(layer "{layer}") (net {net}))') + i = j + + def emit(self) -> list[str]: + return self.segments + self.vias diff --git a/pcbsight/pyproject.toml b/pcbsight/pyproject.toml index bbe586e..60d995f 100644 --- a/pcbsight/pyproject.toml +++ b/pcbsight/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pcbsight" -version = "0.6.0" +version = "0.7.0" description = "PCB review built exclusively for AI agents: a .kicad_pcb in, exact connectivity/clearance/current findings out." readme = "README.md" license = { text = "MIT" } diff --git a/pcbsight/tests/test_extreme.py b/pcbsight/tests/test_extreme.py index 73b7087..9c586e1 100644 --- a/pcbsight/tests/test_extreme.py +++ b/pcbsight/tests/test_extreme.py @@ -15,7 +15,7 @@ import pytest from pcbsight import analyze, parse_board -from pcbsight.board import Board, pad_on_layer +from pcbsight.board import Board from pcbsight.checks import _seg_seg_dist, clearance, connectivity from pcbsight.errors import BadBoardError