pixalynx/usbc-led-switch

A single-sided USB-C–powered LED light board with a latching pushbutton, 100 Ω current-limiting resistor, white 5730 SMD LED, and dual 5.1 kΩ USB-C CC pull-down resistors.

Version
0.0.1
License
unset
Stars
0

scripts/flatten_gerber.py

#!/usr/bin/env python3
"""Rewrite a tscircuit copper Gerber so it uses dark (LPD) polarity only.

tscircuit draws a copper pour as one dark region followed by clear (%LPC%) regions for its clearance holes.
That is valid Gerber, but not every CAM program honours clear polarity (tracespace 5 ignores it), and a CAM
that drops it sees the whole board as copper and mills nothing. KiCad never relies on it: it "fractures" each
zone into a single contour whose holes are joined to the outline by zero-width cut-ins. This script does the
same, so MakeraCAM / FlatCAM / anything else reads the copper correctly.

Hole bridging follows Eberly's ear-clipping preprocessing: holes are merged right-most first, each joined from
its right-most vertex along a +x ray to the nearest edge of the outline built so far, so no cut-in can cross
another hole or another cut-in.

Usage: flatten_gerber.py <in.gbr> <out.gbr>   (pure Python, no dependencies)
"""
import re
import sys

COORD = re.compile(r"X(-?\d+)Y(-?\d+)D0([12])\*")
COORD_FLASH = re.compile(r"X(-?\d+)Y(-?\d+)D03\*")


def parse_format(text):
    m = re.search(r"%FSLAX(\d)(\d)Y(\d)(\d)\*%", text)
    if not m:
        sys.exit("no %FSLA..% format statement")
    return int(m.group(2))


def area(ring):
    return 0.5 * sum(x0 * y1 - x1 * y0 for (x0, y0), (x1, y1) in zip(ring, ring[1:] + ring[:1]))


def inside(pt, ring):
    x, y = pt
    hit = False
    for (x0, y0), (x1, y1) in zip(ring, ring[1:] + ring[:1]):
        if (y0 > y) != (y1 > y) and x < x0 + (y - y0) * (x1 - x0) / (y1 - y0):
            hit = not hit
    return hit


def bridge(outer, hole):
    """Splice `hole` (opposite winding to `outer`) into `outer` with a zero-width cut-in."""
    mi = max(range(len(hole)), key=lambda i: (hole[i][0], -hole[i][1]))
    mx, my = hole[mi]
    best = None
    n = len(outer)
    for i in range(n):
        (x0, y0), (x1, y1) = outer[i], outer[(i + 1) % n]
        if not (y0 <= my < y1 or y1 <= my < y0):  # half-open, so a vertex on the ray counts once
            continue
        xi = x0 + (my - y0) / (y1 - y0) * (x1 - x0)
        if xi < mx:
            continue
        if best is None or xi < best[0]:
            best = (xi, i)
    if best is None:
        sys.exit(f"no outline edge to the right of hole vertex {hole[mi]}")
    xi, i = best
    ip = (xi, my)
    loop = hole[mi:] + hole[:mi] + [hole[mi]]
    return outer[: i + 1] + [ip] + loop + [ip] + outer[i + 1 :]


def main(src, dst):
    text = open(src).read()
    dec = parse_format(text)
    scale = 10 ** dec

    # Split into polarity blocks and pull every G36..G37 region out of them.
    parts = re.split(r"(%LP[DC]\*%)", text)
    blocks = []  # (polarity, body)
    pol = "D"
    head = parts[0]
    for tok in parts[1:]:
        if tok.startswith("%LP"):
            pol = tok[3]
            blocks.append([pol, ""])
        else:
            blocks[-1][1] += tok

    def regions(body):
        out = []
        for reg in re.findall(r"G36\*(.*?)G37\*", body, re.S):
            pts = [(int(x) / scale, int(y) / scale) for x, y, _ in COORD.findall(reg)]
            if len(pts) > 1 and pts[0] == pts[-1]:
                pts = pts[:-1]
            out.append(pts)
        return out

    # tscircuit also ends with clear FLASHES at non-plated holes (connector locating pegs). The drill removes
    # that copper anyway, so they are dropped and reported rather than kept as clear polarity.
    dropped = []
    for j, (p, body) in enumerate(blocks):
        if p == "C" and "G36" not in body:
            if re.sub(r"D\d+\*|X-?\d+Y-?\d+D03\*|\s", "", body):
                sys.exit("clear block holds more than flashes; refusing to guess")
            dropped += COORD_FLASH.findall(body)
            blocks[j][1] = None
    blocks = [b for b in blocks if b[1] is not None]

    # Expected tscircuit layout: LPD(pour regions) LPC(clearance regions) LPD(pads, traces, ...)
    clear_idx = [k for k, (p, _) in enumerate(blocks) if p == "C"]
    if not clear_idx:
        open(dst, "w").write(text)
        print("no clear polarity found; copied unchanged")
        return
    if len(clear_idx) != 1 or clear_idx[0] == 0:
        sys.exit("unexpected polarity layout; refusing to guess")
    k = clear_idx[0]
    pour_body = blocks[k - 1][1]
    clear_body = blocks[k][1]
    if re.sub(r"G36\*.*?G37\*|D\d+\*|G01\*|G04[^*]*\*|\s", "", clear_body, flags=re.S):
        sys.exit("clear block holds more than regions; refusing to guess")
    outers = regions(pour_body)
    holes = regions(clear_body)
    pour_rest = re.sub(r"G36\*.*?G37\*", "", pour_body, flags=re.S)

    assigned = {i: [] for i in range(len(outers))}
    for h in holes:
        owner = [i for i, o in enumerate(outers) if inside(h[0], o)]
        if not owner:
            sys.exit(f"clearance region at {h[0]} lies outside every pour region")
        assigned[owner[-1]].append(h)

    fractured = []
    for i, outer in enumerate(outers):
        ring = outer if area(outer) > 0 else outer[::-1]  # outline CCW
        hs = [h if area(h) < 0 else h[::-1] for h in assigned[i]]  # holes CW
        for h in sorted(hs, key=lambda h: -max(p[0] for p in h)):
            ring = bridge(ring, h)
        fractured.append(ring)

    def fmt(v):
        return str(round(v * scale))

    out_regions = []
    for ring in fractured:
        pts = ring + [ring[0]]
        lines = ["G36*", f"X{fmt(pts[0][0])}Y{fmt(pts[0][1])}D02*", "G01*"]
        lines += [f"X{fmt(x)}Y{fmt(y)}D01*" for x, y in pts[1:]]
        lines.append("G37*")
        out_regions.append("\n".join(lines))

    new_pour = pour_rest.rstrip() + "\n" + "\n".join(out_regions) + "\n"
    rebuilt = head
    for j, (p, body) in enumerate(blocks):
        if j == k - 1:
            rebuilt += f"%LP{p}*%" + new_pour
        elif j == k:
            continue
        else:
            rebuilt += f"%LP{p}*%" + body
    rebuilt = rebuilt.replace("%TF.FilePolarity,Positive*%", "%TF.FilePolarity,Positive*%\nG04 Pour fractured to dark polarity only (scripts/flatten_gerber.py)*", 1)
    if "%LPC*%" in rebuilt:
        sys.exit("clear polarity still present")
    open(dst, "w").write(rebuilt)
    for x, y in dropped:
        print(f"dropped clear flash at ({int(x) / scale:g}, {int(y) / scale:g}) - check it is a drilled hole")
    print(f"{len(outers)} pour region(s), {len(holes)} clearance hole(s) bridged -> {dst}")


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit(__doc__)
    main(sys.argv[1], sys.argv[2])