#!/usr/bin/env python3
"""
fab.py - a tiny PCB description library for single-sided milled boards.

Everything is in millimetres, origin bottom-left, y up. A Board collects
copper shapes tagged with a net name, plus drills and a silk layer, and can:

  - rasterise to the PNGs the CBA mods workflow wants (traces / outline / drill)
  - run a design-rule check that asks the only question a mill cares about:
    does the end mill fit between every pair of things that are not the
    same net, and is every trace at least one mill wide
  - write a KiCad-format netlist so the same design can be opened in an EDA tool

No vias, no second layer, no soldermask. Crossings are made with 0 ohm
resistors, which the design file has to place on purpose.
"""
import math, json
import numpy as np
from PIL import Image, ImageDraw
from scipy import ndimage

# ---------------------------------------------------------------- rules
class Rules:
    """Shop rules. These are the numbers to change if the mill changes."""
    def __init__(self, mill_d=0.4, min_trace=0.4, min_clear=0.4, edge_clear=0.8, dpi=1000):
        self.mill_d, self.min_trace, self.min_clear = mill_d, min_trace, min_clear
        self.edge_clear, self.dpi = edge_clear, dpi
    @property
    def px_per_mm(self): return self.dpi / 25.4


# ---------------------------------------------------------------- shapes
class Shape:
    def __init__(self, net, kind, **kw):
        self.net, self.kind, self.p = net, kind, kw
    def draw(self, d, s, oy):  # s = px per mm, oy = image height in px
        X = lambda x: x * s
        Y = lambda y: oy - y * s
        p = self.p
        if self.kind == "rect":
            x, y, w, h = p["x"], p["y"], p["w"], p["h"]
            d.rectangle([X(x - w / 2), Y(y + h / 2), X(x + w / 2), Y(y - h / 2)], fill=255)
        elif self.kind == "circle":
            x, y, r = p["x"], p["y"], p["d"] / 2
            d.ellipse([X(x - r), Y(y + r), X(x + r), Y(y - r)], fill=255)
        elif self.kind == "trace":
            pts = p["pts"]; w = p["w"]
            d.line([(X(a), Y(b)) for a, b in pts], fill=255, width=max(1, int(round(w * s))),
                   joint="curve")
            for a, b in pts:  # round the ends so corners do not get cut square
                r = w / 2
                d.ellipse([X(a - r), Y(b + r), X(a + r), Y(b - r)], fill=255)


def rect(net, x, y, w, h):   return Shape(net, "rect", x=x, y=y, w=w, h=h)
def circle(net, x, y, d):    return Shape(net, "circle", x=x, y=y, d=d)
def trace(net, pts, w=0.5):  return Shape(net, "trace", pts=list(pts), w=w)


# ---------------------------------------------------------------- board
class Board:
    def __init__(self, w, h, rules=None, name="board"):
        self.w, self.h, self.rules, self.name = w, h, rules or Rules(), name
        self.copper, self.drills, self.silk, self.parts = [], [], [], []
        self.pour_net, self._pour = None, None

    def add(self, *shapes):
        self.copper.extend(shapes); self._pour = None; return shapes[-1]
    def drill(self, x, y, d): self.drills.append((x, y, d))
    def text(self, x, y, s, size=1.6): self.silk.append((x, y, s, size))

    # ---- placement -----------------------------------------------------
    def place(self, ref, value, footprint, x, y, rot=0, nets=None, label=True):
        """Drop a footprint at (x,y). nets maps pin name -> net name."""
        nets = nets or {}
        pins = {}
        c, s = math.cos(math.radians(rot)), math.sin(math.radians(rot))
        for pin in footprint["pins"]:
            px, py = pin["x"] * c - pin["y"] * s, pin["x"] * s + pin["y"] * c
            ax, ay = x + px, y + py
            net = nets.get(pin["name"], f"{ref}-{pin['name']}")
            pw, ph = (pin["w"], pin["h"]) if rot % 180 == 0 else (pin["h"], pin["w"])
            if pin.get("drill"):
                self.add(circle(net, ax, ay, max(pw, ph)))
                self.drill(ax, ay, pin["drill"])
            else:
                self.add(rect(net, ax, ay, pw, ph))
            pins[pin["name"]] = (ax, ay, net)
        self.parts.append(dict(ref=ref, value=value, fp=footprint["name"], x=x, y=y, rot=rot,
                               pins={k: v[2] for k, v in pins.items()}))
        if label:
            self.text(x, y + footprint.get("label_dy", 0), ref)
        return pins

    # ---- raster --------------------------------------------------------
    def _blank(self):
        s = self.rules.px_per_mm
        return Image.new("L", (int(round(self.w * s)), int(round(self.h * s))), 0), s

    def raster_copper(self, only_net=None, with_pour=True):
        img, s = self._blank(); d = ImageDraw.Draw(img)
        for sh in self.copper:
            if only_net is None or sh.net == only_net:
                sh.draw(d, s, img.size[1])
        if with_pour and self.pour_net and only_net in (None, self.pour_net):
            m = self.pour_mask()
            if m is not None:
                a = np.array(img); a[m] = 255; img = Image.fromarray(a)
        return img

    # ---- ground pour ---------------------------------------------------
    def set_pour(self, net): self.pour_net = net; self._pour = None

    def pour_mask(self):
        """Everything inside the board that is not within clearance of another
        net, opened so no sliver narrower than the mill survives, and kept only
        where it actually touches the poured net."""
        if self._pour is not None: return self._pour
        if not self.pour_net: return None
        R, s = self.rules, self.rules.px_per_mm
        img, _ = self._blank(); d = ImageDraw.Draw(img)
        for sh in self.copper:
            if sh.net != self.pour_net: sh.draw(d, s, img.size[1])
        other = np.array(img) > 127
        gnd = np.array(self.raster_copper(self.pour_net, with_pour=False)) > 127
        free = (ndimage.distance_transform_edt(~other) / s) >= (R.min_clear + 0.1)
        e = int(round(R.edge_clear * s))
        free[:e] = free[-e:] = False; free[:, :e] = free[:, -e:] = False
        w = max(1, int(round(R.min_trace / 2 * s)))
        free = ndimage.binary_opening(free, iterations=w)
        lab, _ = ndimage.label(free | gnd)
        keep = set(np.unique(lab[gnd & (lab > 0)]).tolist())
        self._pour = np.isin(lab, list(keep)) & (free | gnd)
        return self._pour

    def raster_outline(self):
        img, s = self._blank(); d = ImageDraw.Draw(img)
        d.rectangle([0, 0, img.size[0] - 1, img.size[1] - 1], fill=255)
        return img

    def raster_drill(self):
        img, s = self._blank(); d = ImageDraw.Draw(img)
        for x, y, dia in self.drills:
            r = dia / 2 * s
            d.ellipse([x * s - r, img.size[1] - y * s - r, x * s + r, img.size[1] - y * s + r], fill=255)
        return img

    # ---- design rule check --------------------------------------------
    def drc(self, verbose=True):
        """Mill-centric DRC. Two questions, both answered on the raster:
        1. between any two different nets, is there room for the end mill?
        2. is every piece of copper at least one mill wide, so it survives?
        """
        R, s = self.rules, self.rules.px_per_mm
        nets = sorted({sh.net for sh in self.copper})
        masks = {n: np.array(self.raster_copper(n)) > 127 for n in nets}
        if self.pour_net and self.pour_net not in masks:
            masks[self.pour_net] = np.array(self.raster_copper(self.pour_net)) > 127
            nets = sorted(masks)
        allc = np.zeros_like(next(iter(masks.values())))
        for m in masks.values(): allc |= m

        # label every copper pixel with the net that owns it, so a violation can
        # name both sides of the gap instead of just one
        label = np.full(allc.shape, -1, dtype=np.int32)
        for i, n in enumerate(nets): label[masks[n]] = i

        worst, viol, seen = 1e9, [], set()
        for i, n in enumerate(nets):
            others = allc & ~masks[n]
            if not others.any() or not masks[n].any(): continue
            dist, idx = ndimage.distance_transform_edt(~others, return_indices=True)
            dist = dist / s
            d_in = np.where(masks[n], dist, np.inf)
            flat = int(np.argmin(d_in)); py, px = np.unravel_index(flat, d_in.shape)
            gap = float(d_in[py, px])
            if gap < worst: worst = gap
            if gap < R.min_clear:
                oy, ox = int(idx[0][py, px]), int(idx[1][py, px])
                other = nets[int(label[oy, ox])]
                key = tuple(sorted((n, other)))
                if key in seen: continue
                seen.add(key)
                viol.append(dict(a=n, b=other, gap_mm=round(gap, 3),
                                 at=(round(px / s, 1), round((allc.shape[0] - py) / s, 1))))

        # width: erode by half a mill; anything that vanishes was too thin
        k = max(1, int(round(R.min_trace / 2 * s)))
        eroded = ndimage.binary_erosion(allc, ndimage.generate_binary_structure(2, 1), iterations=k)
        thin = allc & ~ndimage.binary_dilation(eroded, iterations=k)
        thin_frac = thin.sum() / max(1, allc.sum())

        # board edge
        edge_px = int(round(R.edge_clear * s))
        border = np.zeros_like(allc); border[:edge_px] = border[-edge_px:] = True
        border[:, :edge_px] = border[:, -edge_px:] = True
        edge_hits = int((allc & border).sum())

        ok = not viol and edge_hits == 0
        rep = dict(nets=len(nets), min_clearance_mm=round(float(worst), 3),
                   required_clearance_mm=R.min_clear, violations=viol,
                   thin_copper_fraction=round(float(thin_frac), 4),
                   copper_within_edge_keepout_px=edge_hits, pass_=bool(ok))
        if verbose:
            print(f"DRC  {self.name}  {self.w}x{self.h} mm, {len(nets)} nets, "
                  f"{len(self.copper)} copper shapes, {len(self.drills)} drills")
            print(f"  smallest gap between different nets : {worst:.3f} mm "
                  f"(need {R.min_clear})  {'PASS' if worst >= R.min_clear else 'FAIL'}")
            print(f"  copper inside {R.edge_clear} mm edge keepout  : {edge_hits} px  "
                  f"{'PASS' if edge_hits == 0 else 'FAIL'}")
            for v in viol: print(f"   ! {v['a']} / {v['b']}  {v['gap_mm']:.3f} mm at {v['at']}")
            print(f"  -> {'PASS' if ok else 'FAIL'}")
        return rep

    # ---- exports -------------------------------------------------------
    def export_png(self, stem):
        for suffix, img in (("traces", self.raster_copper()), ("outline", self.raster_outline()),
                            ("drill", self.raster_drill())):
            img.save(f"{stem}_{suffix}.png", dpi=(self.rules.dpi, self.rules.dpi))

    def export_netlist(self, path):
        """KiCad-style netlist: enough to import into an EDA tool and check."""
        nets = {}
        for p in self.parts:
            for pin, net in p["pins"].items(): nets.setdefault(net, []).append((p["ref"], pin))
        with open(path, "w") as f:
            f.write("(export (version D)\n (components\n")
            for p in self.parts:
                f.write(f'  (comp (ref {p["ref"]}) (value "{p["value"]}") '
                        f'(footprint {p["fp"]}))\n')
            f.write(" )\n (nets\n")
            for i, (n, pins) in enumerate(sorted(nets.items()), 1):
                f.write(f'  (net (code {i}) (name "{n}")\n')
                for ref, pin in pins: f.write(f'   (node (ref {ref}) (pin {pin}))\n')
                f.write("  )\n")
            f.write(" )\n)\n")
        return nets

    def export_bom(self, path):
        from collections import defaultdict
        g = defaultdict(list)
        for p in self.parts: g[(p["value"], p["fp"])].append(p["ref"])
        with open(path, "w") as f:
            f.write("qty,value,footprint,refs\n")
            for (val, fp), refs in sorted(g.items(), key=lambda kv: kv[0][0]):
                f.write(f'{len(refs)},"{val}",{fp},"{" ".join(sorted(refs))}"\n')
        return g


# ---------------------------------------------------------------- router
import heapq

class Router:
    """A* maze router for one copper layer. Works on a coarse grid, keeps the
    mill's clearance from every other net, and refuses rather than shorting."""

    def __init__(self, board, grid=0.3, cross_cost=2500):
        self.b, self.g = board, grid
        self.allow_cross, self.cross_cost, self.links = False, cross_cost, 0
        self.nx, self.ny = int(board.w / grid) + 1, int(board.h / grid) + 1

    def _raster(self, nets):
        s = self.b.rules.px_per_mm
        img, _ = self.b._blank(); d = ImageDraw.Draw(img)
        for sh in self.b.copper:
            if sh.net in nets: sh.draw(d, s, img.size[1])
        return np.array(img) > 127

    def _to_grid(self, full):
        """Any raster pixel inside a grid cell marks that cell."""
        s = self.b.rules.px_per_mm
        k = max(1, int(round(self.g * s)))
        big = ndimage.maximum_filter(full, size=k)
        H = full.shape[0]
        ys = np.clip(H - (np.arange(self.ny) * self.g * s).astype(int), 0, H - 1)
        xs = np.clip((np.arange(self.nx) * self.g * s).astype(int), 0, full.shape[1] - 1)
        return big[ys][:, xs]

    def _mask_of(self, nets): return self._to_grid(self._raster(nets))

    def blocked_for(self, net, width):
        """A cell is blocked if a trace centred there would come closer than
        the mill's clearance to copper belonging to any other net."""
        s = self.b.rules.px_per_mm
        allnets = {sh.net for sh in self.b.copper}
        other = self._raster(allnets - {net})
        dist = ndimage.distance_transform_edt(~other) / s
        blk = self._to_grid(dist < (self.b.rules.min_clear + width / 2 + 0.25))
        e = int(round(self.b.rules.edge_clear / self.g)) + 1
        blk[:e] = blk[-e:] = True; blk[:, :e] = blk[:, -e:] = True
        return blk

    def _astar(self, blk, starts, goals):
        """Flat-array Dijkstra/A*. Blocked cells cost extra instead of being
        forbidden, so the router can cross when the layer leaves no choice."""
        ny, nx = self.ny, self.nx
        N = ny * nx
        INF = np.int32(1 << 29)
        dist = np.full(N, INF, dtype=np.int32)
        prev = np.full(N, -1, dtype=np.int32)
        blkf = blk.reshape(-1)
        goalf = np.zeros(N, dtype=bool)
        gxs = np.array([g[0] for g in goals], dtype=np.int64)
        gys = np.array([g[1] for g in goals], dtype=np.int64)
        goalf[gys * nx + gxs] = True
        # admissible heuristic: Manhattan distance to the nearest goal
        hx = ndimage.distance_transform_cdt(~goalf.reshape(ny, nx),
                                            metric="taxicab").reshape(-1).astype(np.int32) * 10
        pq = []
        for (x, y) in starts:
            i = y * nx + x
            if dist[i] > 0: dist[i] = 0; heapq.heappush(pq, (int(hx[i]), i))
        pen = np.where(blkf & ~goalf, self.cross_cost if self.allow_cross else INF, 0).astype(np.int32)
        while pq:
            f, i = heapq.heappop(pq)
            if f - int(hx[i]) > int(dist[i]): continue
            if goalf[i]:
                path, j = [], i
                while j >= 0: path.append((int(j % nx), int(j // nx))); j = int(prev[j])
                return path[::-1]
            y, x = divmod(i, nx)
            c = int(dist[i])
            for j in ((i - 1) if x > 0 else -1, (i + 1) if x < nx - 1 else -1,
                      (i - nx) if y > 0 else -1, (i + nx) if y < ny - 1 else -1):
                if j < 0: continue
                p = int(pen[j])
                if p >= INF: continue
                nc = c + 10 + p
                if nc < int(dist[j]):
                    dist[j] = nc; prev[j] = i
                    heapq.heappush(pq, (nc + int(hx[j]), j))
        return None

    def cells_of(self, net):
        m = self._mask_of({net})
        ys, xs = np.where(m)
        return list(zip(xs.tolist(), ys.tolist()))

    def route_net(self, net, width=0.5, verbose=True):
        """Connect every blob of this net's copper together, one A* run each."""
        seeds = self._seed_groups(net)
        if len(seeds) <= 1: return True
        done = seeds[0]
        for nxt in seeds[1:]:
            blk = self.blocked_for(net, width)
            self.allow_cross = False
            path = self._astar(blk, done, nxt)
            if path is None:
                self.allow_cross = True
                path = self._astar(blk, done, nxt)
                self.allow_cross = False
            if path is None:
                if verbose: print(f"   router: FAILED to route {net}")
                return False
            if not self._lay(net, path, blk, width):
                if verbose: print(f"   router: {net} needs a link it cannot place")
                return False
            done = done + nxt + [tuple(p) for p in path]
        return True

    def _lay(self, net, path, blk, width):
        """Draw the path, cutting it wherever it had to cross another net and
        dropping a wire link across the cut."""
        pad_blk = self.blocked_for(net, 1.8)
        bad = [bool(blk[y, x]) for x, y in path]
        seg, i, n = [], 0, len(path)
        while i < n:
            if not bad[i]:
                seg.append(path[i]); i += 1; continue
            j = i
            while j < n and bad[j]: j += 1
            # back off to the nearest cell on each side that can host a pad
            a = len(seg) - 1
            while a > 0 and pad_blk[seg[a][1], seg[a][0]]: a -= 1
            k = j
            while k < n - 1 and pad_blk[path[k][1], path[k][0]]: k += 1
            if a <= 0 or k >= n - 1:        # nowhere safe to land a pad here
                a = len(seg) - 1; k = min(n - 1, j)
            a = max(0, min(a, len(seg) - 1))
            if a >= 1: self._emit(net, seg[:a + 1], width)
            p0 = seg[a] if seg else path[max(0, i - 1)]
            p1 = path[min(k, n - 1)]
            self.b.add(circle(net, p0[0] * self.g, p0[1] * self.g, 1.3))
            self.b.add(circle(net, p1[0] * self.g, p1[1] * self.g, 1.3))
            self.links += 1
            span = ((p1[0] - p0[0]) ** 2 + (p1[1] - p0[1]) ** 2) ** .5 * self.g
            self.b.parts.append(dict(ref=f"JL{self.links}", value=f"wire link {span:.0f}mm",
                                     fp="WIRE-LINK", x=p0[0] * self.g, y=p0[1] * self.g,
                                     rot=0, pins={"1": net, "2": net}))
            seg = [p1]; i = min(k, n - 1) + 1
        if len(seg) > 1: self._emit(net, seg, width)
        return True

    def _emit(self, net, cells, width):
        pts = [(x * self.g, y * self.g) for x, y in cells]
        if len(pts) > 1: self.b.add(trace(net, self._simplify(pts), width))

    def _seed_groups(self, net):
        """Connected blobs of this net's existing copper, as lists of cells."""
        m = self._mask_of({net})
        lab, n = ndimage.label(m, structure=np.ones((3, 3)))
        out = []
        for i in range(1, n + 1):
            ys, xs = np.where(lab == i)
            out.append(list(zip(xs.tolist(), ys.tolist())))
        out.sort(key=len, reverse=True)
        return out

    @staticmethod
    def _simplify(pts):
        if len(pts) < 3: return pts
        out = [pts[0]]
        for i in range(1, len(pts) - 1):
            ax, ay = pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]
            bx, by = pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]
            if (ax, ay) != (bx, by): out.append(pts[i])
        out.append(pts[-1])
        return out
