main
noah amfm-receiver-molecule: KiCad project, fab files, 3D and README (6/7) 53707c4 1d ago
"""Copper-geometry helpers: clearance-checked via fanout, GND stitching, silkscreen reference placement."""
import math
import pcbnew


def mm(v):
    return pcbnew.FromMM(v)


def tomm(v):
    return pcbnew.ToMM(v)


def rect_dist(px, py, r):
    """Distance from point to axis-aligned rect (0 if inside)."""
    x0, y0, x1, y1 = r
    dx = max(x0 - px, 0.0, px - x1)
    dy = max(y0 - py, 0.0, py - y1)
    return math.hypot(dx, dy)


def seg_point_dist(ax, ay, bx, by, px, py):
    vx, vy = bx - ax, by - ay
    L2 = vx * vx + vy * vy
    t = 0.0 if L2 == 0 else max(0.0, min(1.0, ((px - ax) * vx + (py - ay) * vy) / L2))
    cx, cy = ax + t * vx, ay + t * vy
    return math.hypot(px - cx, py - cy)


def seg_rect_dist(ax, ay, bx, by, r, steps=None):
    L = math.hypot(bx - ax, by - ay)
    n = max(2, int(L / 0.05) + 1) if steps is None else steps
    best = 1e9
    for i in range(n + 1):
        t = i / n
        d = rect_dist(ax + t * (bx - ax), ay + t * (by - ay), r)
        if d < best:
            best = d
    return best


def seg_seg_dist(a, b, c, d):
    return min(seg_point_dist(a[0], a[1], b[0], b[1], c[0], c[1]),
               seg_point_dist(a[0], a[1], b[0], b[1], d[0], d[1]),
               seg_point_dist(c[0], c[1], d[0], d[1], a[0], a[1]),
               seg_point_dist(c[0], c[1], d[0], d[1], b[0], b[1])) if not segs_cross(a, b, c, d) else 0.0


def segs_cross(a, b, c, d):
    def orient(p, q, r):
        return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
    o1, o2, o3, o4 = orient(a, b, c), orient(a, b, d), orient(c, d, a), orient(c, d, b)
    return (o1 * o2 < 0) and (o3 * o4 < 0)


class CopperMap:
    """Snapshot of copper on the outer layers for clearance queries (mm units)."""

    def __init__(self, board):
        self.board = board
        self.pads = []      # (netcode, layers{'F','B'}, rect, pad)
        self.tracks = []    # (netcode, layer, (ax,ay), (bx,by), halfwidth)
        self.vias = []      # (netcode, x, y, r, drill)
        for fp in board.GetFootprints():
            for p in fp.Pads():
                bb = p.GetBoundingBox()
                r = (tomm(bb.GetLeft()), tomm(bb.GetTop()), tomm(bb.GetRight()), tomm(bb.GetBottom()))
                layers = set()
                if p.IsOnLayer(pcbnew.F_Cu):
                    layers.add('F')
                if p.IsOnLayer(pcbnew.B_Cu):
                    layers.add('B')
                if p.GetAttribute() in (pcbnew.PAD_ATTRIB_PTH, pcbnew.PAD_ATTRIB_NPTH):
                    layers |= {'F', 'B'}
                self.pads.append((p.GetNetCode(), layers, r, p))
        for t in board.GetTracks():
            if t.GetClass() == 'PCB_VIA':
                pos = t.GetPosition()
                self.vias.append((t.GetNetCode(), tomm(pos.x), tomm(pos.y), tomm(t.GetWidth(pcbnew.F_Cu)) / 2,
                                  tomm(t.GetDrillValue())))
            else:
                a, b = t.GetStart(), t.GetEnd()
                lay = 'F' if t.GetLayer() == pcbnew.F_Cu else ('B' if t.GetLayer() == pcbnew.B_Cu else 'I')
                self.tracks.append((t.GetNetCode(), lay, (tomm(a.x), tomm(a.y)), (tomm(b.x), tomm(b.y)),
                                    tomm(t.GetWidth()) / 2))
        self.courtyards = []
        for fp in board.GetFootprints():
            cy = fp.GetCourtyard(pcbnew.F_CrtYd)
            if cy.OutlineCount():
                bb = cy.BBox()
                self.courtyards.append((fp.GetReference(), (tomm(bb.GetLeft()), tomm(bb.GetTop()),
                                                            tomm(bb.GetRight()), tomm(bb.GetBottom()))))

    def via_ok(self, net, x, y, r, drill, clr, own_pad=None, own_clr=0.1, layers=('F', 'B')):
        for (n, lays, rect, p) in self.pads:
            if not (lays & set(layers)):
                continue
            d = rect_dist(x, y, rect)
            if p is own_pad:
                if d < r + own_clr:
                    return False
                continue
            if n == net and n != 0:
                if d < r + 0.05:
                    return False            # never on top of another same-net pad
                continue
            if d < r + clr:
                return False
        for (n, lay, a, b, hw) in self.tracks:
            if lay not in layers:
                continue
            if n == net:
                continue
            if seg_point_dist(a[0], a[1], b[0], b[1], x, y) < r + hw + clr:
                return False
        for (n, vx, vy, vr, vd) in self.vias:
            dist = math.hypot(vx - x, vy - y)
            if dist < drill / 2 + vd / 2 + 0.3:                     # hole-to-hole
                return False
            if n != net and dist < r + vr + clr:
                return False
        return True

    def track_ok(self, net, a, b, hw, clr, own_pad=None, layer='F'):
        for (n, lays, rect, p) in self.pads:
            if layer not in lays or p is own_pad or n == net:
                continue
            if seg_rect_dist(a[0], a[1], b[0], b[1], rect) < hw + clr:
                return False
        for (n, lay, c, d, thw) in self.tracks:
            if lay != layer or n == net:
                continue
            if seg_seg_dist(a, b, c, d) < hw + thw + clr:
                return False
        for (n, vx, vy, vr, vd) in self.vias:
            if n == net:
                continue
            if seg_point_dist(a[0], a[1], b[0], b[1], vx, vy) < hw + vr + clr:
                return False
        return True

    def in_courtyard(self, x, y, exclude=None):
        for ref, r in self.courtyards:
            if ref == exclude:
                continue
            if r[0] <= x <= r[2] and r[1] <= y <= r[3]:
                return True
        return False


def add_via(board, net, x, y, dia=0.6, drill=0.3, locked=False):
    v = pcbnew.PCB_VIA(board)
    v.SetPosition(pcbnew.VECTOR2I(mm(x), mm(y)))
    v.SetWidth(mm(dia))
    v.SetDrill(mm(drill))
    v.SetViaType(pcbnew.VIATYPE_THROUGH)
    v.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
    v.SetNetCode(net)
    v.SetLocked(locked)
    board.Add(v)
    return v


def add_track(board, net, a, b, width, layer=pcbnew.F_Cu, locked=False):
    t = pcbnew.PCB_TRACK(board)
    t.SetStart(pcbnew.VECTOR2I(mm(a[0]), mm(a[1])))
    t.SetEnd(pcbnew.VECTOR2I(mm(b[0]), mm(b[1])))
    t.SetWidth(mm(width))
    t.SetLayer(layer)
    t.SetNetCode(net)
    t.SetLocked(locked)
    board.Add(t)
    return t


def zone_contains(board, netname, layer, x, y, margin=0.0):
    """True if (x, y) lies inside a zone outline of `netname` on `layer` (outline, not fill)."""
    for z in board.Zones():
        if z.GetNetname() != netname or not z.IsOnLayer(layer):
            continue
        ol = z.Outline()
        pts = [(x, y)] if margin == 0 else [(x + margin * math.cos(a), y + margin * math.sin(a))
                                             for a in [k * math.pi / 4 for k in range(8)]] + [(x, y)]
        if all(ol.Contains(pcbnew.VECTOR2I(mm(px), mm(py))) for px, py in pts):
            return True
    return False


def ep_stubs(board, width=0.25, log=print):
    """QFN pins on the exposed pad's net (VSS) get a locked stub straight in to the exposed pad."""
    n = 0
    for fp in board.GetFootprints():
        smd = [q for q in fp.Pads() if q.GetAttribute() == pcbnew.PAD_ATTRIB_SMD]
        eps = [q for q in smd if tomm(max(q.GetBoundingBox().GetWidth(), q.GetBoundingBox().GetHeight())) > 3.0
               and q.IsOnLayer(pcbnew.F_Cu)]
        if not eps:
            continue
        ep = eps[0]
        c = ep.GetPosition()
        ex, ey = tomm(c.x), tomm(c.y)
        half = tomm(ep.GetBoundingBox().GetWidth()) / 2
        for q in smd:
            if q.GetNetCode() != ep.GetNetCode() or not q.IsOnLayer(pcbnew.F_Cu):
                continue
            if tomm(max(q.GetBoundingBox().GetWidth(), q.GetBoundingBox().GetHeight())) > 3.0:
                continue                             # the exposed pad itself (front or back copper)
            p = q.GetPosition()
            px, py = tomm(p.x), tomm(p.y)
            dx, dy = ex - px, ey - py
            if abs(dx) >= abs(dy):                   # side pin: go straight in along x
                tx, ty = ex - math.copysign(half - 0.3, dx), py
            else:
                tx, ty = px, ey - math.copysign(half - 0.3, dy)
            add_track(board, q.GetNetCode(), (px, py), (tx, ty), min(width, 0.9 * tomm(min(q.GetBoundingBox().GetWidth(), q.GetBoundingBox().GetHeight()))), locked=True)
            n += 1
    log('  exposed-pad stubs: %d' % n)
    return n


def plane_fanout(board, plane_nets, via_dia=0.6, drill=0.3, clr=0.19, width=0.3, log=print):
    """Give every SMD pad of a plane net its own via into the plane (locked pre-route)."""
    cm = CopperMap(board)
    added, failed = 0, []
    plane_layer = {'GND': pcbnew.In1_Cu, '+5VA': pcbnew.In2_Cu, '+3V3': pcbnew.In2_Cu}
    pads = []
    for fp in board.GetFootprints():
        c = fp.GetPosition()
        for p in fp.Pads():
            if p.GetNetname() not in plane_nets or p.GetAttribute() != pcbnew.PAD_ATTRIB_SMD:
                continue
            if not p.IsOnLayer(pcbnew.F_Cu):
                continue
            pads.append((fp, p, (tomm(c.x), tomm(c.y))))
    # Escape lanes: on fine-pitch packages (16+ SMD pads) keep a 1.5 mm strip straight out from every pad free
    # of other nets' fan-out vias and necks, or a signal pin squeezed between two plane pins can't get out.
    lanes = {}
    for fp in board.GetFootprints():
        smd = [q for q in fp.Pads() if q.GetAttribute() == pcbnew.PAD_ATTRIB_SMD]
        if len(smd) < 16:
            continue
        c = fp.GetPosition()
        fcx, fcy = tomm(c.x), tomm(c.y)
        ep_nets = {q.GetNetCode() for q in smd if tomm(max(q.GetBoundingBox().GetWidth(), q.GetBoundingBox().GetHeight())) > 3.0}
        for q in smd:
            bb = q.GetBoundingBox()
            x0, y0, x1, y1 = tomm(bb.GetLeft()), tomm(bb.GetTop()), tomm(bb.GetRight()), tomm(bb.GetBottom())
            if max(x1 - x0, y1 - y0) > 3.0:          # exposed pad
                continue
            if q.GetNetCode() <= 0 or q.GetNetCode() in ep_nets:
                continue                             # unused pin, or tied straight in to the exposed pad
            ox, oy = (x0 + x1) / 2 - fcx, (y0 + y1) / 2 - fcy
            if abs(ox) >= abs(oy):
                x0, x1 = (x0, x1 + 1.5) if ox > 0 else (x0 - 1.5, x1)
            else:
                y0, y1 = (y0, y1 + 1.5) if oy > 0 else (y0 - 1.5, y1)
            lanes.setdefault(fp.GetReference(), []).append((q.GetNetCode(), x0 - 0.05, y0 - 0.05, x1 + 0.05, y1 + 0.05))

    def in_lane(ref, net, x, y, r):
        for n_, x0, y0, x1, y1 in lanes.get(ref, ()):
            if n_ == net:
                continue
            dx, dy = max(x0 - x, 0.0, x - x1), max(y0 - y, 0.0, y - y1)
            if dx * dx + dy * dy < r * r:
                return True
        return False

    for fp, p, (cx, cy) in pads:
        net = p.GetNetCode()
        netname = p.GetNetname()
        pos = p.GetPosition()
        px, py = tomm(pos.x), tomm(pos.y)
        bb = p.GetBoundingBox()
        hw, hh = tomm(bb.GetWidth()) / 2, tomm(bb.GetHeight()) / 2
        rv = via_dia / 2
        tw = min(width, 0.85 * min(2 * hw, 2 * hh))      # thinner neck for fine-pitch pins
        # outward direction from footprint centre
        ox, oy = px - cx, py - cy
        on = math.hypot(ox, oy)
        cands = []
        for k in range(16):
            a = 2 * math.pi * k / 16
            dx, dy = math.cos(a), math.sin(a)
            ext = abs(dx) * hw + abs(dy) * hh      # pad half-extent along direction (rect approx)
            for step in range(8):
                d = ext + rv + 0.12 + step * 0.2
                vx, vy = px + dx * d, py + dy * d
                score = d
                if on > 1e-6:
                    score -= 0.35 * (dx * ox + dy * oy) / on      # prefer outward
                cands.append((score, vx, vy))
        cands.sort()
        ok = False
        for score, vx, vy in cands:
            vx, vy = round(vx / 0.05) * 0.05, round(vy / 0.05) * 0.05
            if not zone_contains(board, netname, plane_layer[netname], vx, vy, margin=0.6):
                continue
            ref = fp.GetReference()
            if ref in lanes and (in_lane(ref, net, vx, vy, rv + clr) or any(
                    in_lane(ref, net, px + (vx - px) * t, py + (vy - py) * t, tw / 2 + clr) for t in (0.5, 0.75, 1.0))):
                continue
            if not cm.via_ok(net, vx, vy, rv, drill, clr, own_pad=p):
                continue
            if not cm.track_ok(net, (px, py), (vx, vy), tw / 2, clr, own_pad=p):
                continue
            add_via(board, net, vx, vy, via_dia, drill, locked=True)
            add_track(board, net, (px, py), (vx, vy), tw, locked=True)
            cm.vias.append((net, vx, vy, rv, drill))
            cm.tracks.append((net, 'F', (px, py), (vx, vy), tw / 2))
            added += 1
            ok = True
            break
        if not ok:
            failed.append('%s.%s(%s)' % (fp.GetReference(), p.GetNumber(), netname))
    log('  plane fanout: %d vias added, %d pads left to the router: %s' % (added, len(failed), ' '.join(failed)))
    return added, failed


def stitch_gnd(board, W, H, OX, OY, pitch=2.5, via_dia=0.6, drill=0.3, clr=0.25, edge=1.0, log=print):
    """GND stitching vias on a grid wherever they clear every other-net copper feature."""
    cm = CopperMap(board)
    gnd = board.FindNet('GND').GetNetCode()
    added = 0
    y = OY + edge + 0.5
    while y < OY + H - edge:
        x = OX + edge + 0.5
        while x < OX + W - edge:
            if not cm.in_courtyard(x, y) and cm.via_ok(gnd, x, y, via_dia / 2, drill, clr, layers=('F', 'B')):
                # keep clear of same-net pads too (no via under a pad)
                add_via(board, gnd, x, y, via_dia, drill)
                cm.vias.append((gnd, x, y, via_dia / 2, drill))
                added += 1
            x += pitch
        y += pitch
    log('  stitching vias: %d' % added)
    return added


def place_silk_refs(board, W, H, OX, OY, log=print):
    """Put each reference where it hits no pad, no other silk and stays on the board; else hide it."""
    pads = []
    for fp in board.GetFootprints():
        for p in fp.Pads():
            bb = p.GetBoundingBox()
            pads.append((tomm(bb.GetLeft()) - 0.15, tomm(bb.GetTop()) - 0.15,
                         tomm(bb.GetRight()) + 0.15, tomm(bb.GetBottom()) + 0.15))
    silk = []
    for fp in board.GetFootprints():
        for g in fp.GraphicalItems():
            if g.GetLayer() == pcbnew.F_SilkS and g.GetClass() != 'PCB_TEXT':
                bb = g.GetBoundingBox()
                silk.append((tomm(bb.GetLeft()) - 0.1, tomm(bb.GetTop()) - 0.1,
                             tomm(bb.GetRight()) + 0.1, tomm(bb.GetBottom()) + 0.1))
    for d in board.GetDrawings():
        if d.GetLayer() == pcbnew.F_SilkS:
            bb = d.GetBoundingBox()
            silk.append((tomm(bb.GetLeft()) - 0.1, tomm(bb.GetTop()) - 0.1,
                         tomm(bb.GetRight()) + 0.1, tomm(bb.GetBottom()) + 0.1))
    for fp in board.GetFootprints():             # visible value fields (machine contact labels)
        v = fp.Value()
        if v.IsVisible() and v.GetLayer() == pcbnew.F_SilkS:
            bb = v.GetBoundingBox()
            silk.append((tomm(bb.GetLeft()) - 0.15, tomm(bb.GetTop()) - 0.15,
                         tomm(bb.GetRight()) + 0.15, tomm(bb.GetBottom()) + 0.15))

    def ov(a, b):
        return not (a[2] <= b[0] or a[0] >= b[2] or a[3] <= b[1] or a[1] >= b[3])
    placed, hidden = 0, []
    fps = sorted(board.GetFootprints(), key=lambda f: -tomm(f.GetBoundingBox(False).GetWidth()))
    for fp in fps:
        ref = fp.Reference()
        if not ref.IsVisible() or ref.GetLayer() != pcbnew.F_SilkS:
            continue
        cy = fp.GetCourtyard(pcbnew.F_CrtYd)
        bb = cy.BBox() if cy.OutlineCount() else fp.GetBoundingBox(False)
        x0, y0, x1, y1 = tomm(bb.GetLeft()), tomm(bb.GetTop()), tomm(bb.GetRight()), tomm(bb.GetBottom())
        cx, cy_ = (x0 + x1) / 2, (y0 + y1) / 2
        n = len(fp.GetReference())
        tw, th = 0.85 * n * 0.7 + 0.2, 0.7 + 0.2
        cands = []
        for ang, (tx, ty) in ((0, (cx, y0 - th / 2)), (0, (cx, y1 + th / 2)),
                              (0, (x1 + tw / 2, cy_)), (0, (x0 - tw / 2, cy_)),
                              (90, (x1 + th / 2, cy_)), (90, (x0 - th / 2, cy_)),
                              (0, (x1 + tw / 2, y0)), (0, (x0 - tw / 2, y0)),
                              (0, (x1 + tw / 2, y1)), (0, (x0 - tw / 2, y1)),
                              (0, (cx, y0 - th / 2 - 0.6)), (0, (cx, y1 + th / 2 + 0.6))):
            w_, h_ = (tw, th) if ang == 0 else (th, tw)
            box = (tx - w_ / 2, ty - h_ / 2, tx + w_ / 2, ty + h_ / 2)
            cands.append((ang, tx, ty, box))
        done = False
        for ang, tx, ty, box in cands:
            if box[0] < OX + 0.4 or box[2] > OX + W - 0.4 or box[1] < OY + 0.4 or box[3] > OY + H - 0.4:
                continue
            if any(ov(box, p) for p in pads) or any(ov(box, s) for s in silk):
                continue
            ref.SetTextAngleDegrees(ang)
            ref.SetPosition(pcbnew.VECTOR2I(mm(tx), mm(ty)))
            silk.append(box)
            placed += 1
            done = True
            break
        if not done:
            ref.SetLayer(pcbnew.F_Fab)       # keep it on the assembly drawing instead
            hidden.append(fp.GetReference())
    log('  silk references: %d placed, %d moved to F.Fab' % (placed, len(hidden)))
    return placed, hidden


def place_board_texts(board, W, H, OX, OY, log=print):
    """Nudge free-standing silkscreen texts off pads/courtyards (spiral search); drop to F.Fab if no room."""
    obst = []
    for fp in board.GetFootprints():
        for p in fp.Pads():
            bb = p.GetBoundingBox()
            obst.append((tomm(bb.GetLeft()) - 0.2, tomm(bb.GetTop()) - 0.2,
                         tomm(bb.GetRight()) + 0.2, tomm(bb.GetBottom()) + 0.2))
        cy = fp.GetCourtyard(pcbnew.F_CrtYd)
        if cy.OutlineCount():
            bb = cy.BBox()
            obst.append((tomm(bb.GetLeft()), tomm(bb.GetTop()), tomm(bb.GetRight()), tomm(bb.GetBottom())))
        v = fp.Value()
        if v.IsVisible() and v.GetLayer() == pcbnew.F_SilkS:
            bb = v.GetBoundingBox()
            obst.append((tomm(bb.GetLeft()) - 0.2, tomm(bb.GetTop()) - 0.2,
                         tomm(bb.GetRight()) + 0.2, tomm(bb.GetBottom()) + 0.2))

    def ov(a, b):
        return not (a[2] <= b[0] or a[0] >= b[2] or a[3] <= b[1] or a[1] >= b[3])
    back_obst = []
    for fp in board.GetFootprints():
        for p in fp.Pads():
            if p.IsOnLayer(pcbnew.B_Cu) or p.GetAttribute() != pcbnew.PAD_ATTRIB_SMD:
                bb = p.GetBoundingBox()
                back_obst.append((tomm(bb.GetLeft()) - 0.3, tomm(bb.GetTop()) - 0.3,
                                  tomm(bb.GetRight()) + 0.3, tomm(bb.GetBottom()) + 0.3))
    moved = dropped = 0
    placed = []
    for d in list(board.GetDrawings()):
        if d.GetClass() != 'PCB_TEXT' or d.GetLayer() not in (pcbnew.F_SilkS, pcbnew.B_SilkS):
            continue
        obst_here = obst if d.GetLayer() == pcbnew.F_SilkS else back_obst
        pos = d.GetPosition()
        x0, y0 = tomm(pos.x), tomm(pos.y)
        size0, thick0 = d.GetTextSize(), d.GetTextThickness()
        best = None
        # full size close by first; then shrink (down to 0.8 mm text) and search further before giving up
        for scale, reach in ((1.0, 20), (0.8, 40), (0.65, 60)):
            if scale != 1.0:
                if tomm(size0.y) * scale < 0.8:
                    break
                d.SetTextSize(pcbnew.VECTOR2I(int(size0.x * scale), int(size0.y * scale)))
                d.SetTextThickness(max(mm(0.12), int(thick0 * scale)))
            bb = d.GetBoundingBox()
            w, h = tomm(bb.GetWidth()) + 0.3, tomm(bb.GetHeight()) + 0.3
            for r in [0] + [0.5 * k for k in range(1, reach + 1)]:
                n = 1 if r == 0 else max(8, int(r * 6))
                for k in range(n):
                    a = 2 * math.pi * k / n
                    x, y = x0 + r * math.cos(a), y0 + r * math.sin(a)
                    box = (x - w / 2, y - h / 2, x + w / 2, y + h / 2)
                    if box[0] < OX + 0.5 or box[2] > OX + W - 0.5 or box[1] < OY + 0.5 or box[3] > OY + H - 0.5:
                        continue
                    if any(ov(box, o) for o in obst_here) or any(ov(box, o) for o in placed):
                        continue
                    best = (x, y, box)
                    break
                if best:
                    break
            if best:
                break
        if not best:
            d.SetTextSize(size0)
            d.SetTextThickness(thick0)
        if best:
            if best[0] != x0 or best[1] != y0:
                moved += 1
            d.SetPosition(pcbnew.VECTOR2I(mm(best[0]), mm(best[1])))
            placed.append(best[2])
        else:
            d.SetLayer(pcbnew.F_Fab if d.GetLayer() == pcbnew.F_SilkS else pcbnew.B_Fab)
            dropped += 1
    log('  board texts: %d moved, %d dropped to Fab' % (moved, dropped))


def trim_edge_silk(board, OX, OY, W, H, margin=0.25, log=print):
    """Remove footprint silkscreen strokes that reach past the board edge (edge-mount parts)."""
    n = 0
    for fp in board.GetFootprints():
        for g in list(fp.GraphicalItems()):
            if g.GetLayer() not in (pcbnew.F_SilkS, pcbnew.B_SilkS) or g.GetClass() == 'PCB_TEXT':
                continue
            bb = g.GetBoundingBox()
            if (tomm(bb.GetLeft()) < OX + margin or tomm(bb.GetRight()) > OX + W - margin or
                    tomm(bb.GetTop()) < OY + margin or tomm(bb.GetBottom()) > OY + H - margin):
                fp.Remove(g)
                n += 1
    log('  trimmed %d silkscreen strokes at the board edge' % n)