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])