#!/usr/bin/env python3 """Refresh board-local device outlines without modifying libraries or project settings. Uses pcbnew and Shapely from system Python. Only owned F.SilkS graphics are replaced; source F.Fab outlines, copper, footprints and board edges are retained. """ from pathlib import Path import math import uuid import pcbnew as p from shapely.geometry import LineString, Point, box from shapely.ops import unary_union, polygonize ROOT = Path(__file__).resolve().parent PCB = ROOT / "serial-carrier.kicad_pcb" NS = uuid.UUID("67964aa4-c3a5-45d8-9b54-250fb68e7c41") WIDTH = 0.12 GAP = 0.22 # Additional clearance from mask openings and existing silk strokes. def xy(point): return p.ToMM(point.x), p.ToMM(point.y) def vec(point): return p.VECTOR2I(p.FromMM(point[0]), p.FromMM(point[1])) def outline_lines(shape): if shape.GetShape() == p.SHAPE_T_RECT: x1, y1 = xy(shape.GetStart()) x2, y2 = xy(shape.GetEnd()) return [LineString([(x1,y1),(x2,y1)]), LineString([(x2,y1),(x2,y2)]), LineString([(x2,y2),(x1,y2)]), LineString([(x1,y2),(x1,y1)])] if shape.GetShape() == p.SHAPE_T_SEGMENT: return [LineString([xy(shape.GetStart()), xy(shape.GetEnd())])] return [] def add_outlines(board): owned = {str(uuid.uuid5(NS, str(i))) for i in range(4096)} for drawing in list(board.GetDrawings()): if drawing.m_Uuid.AsString() in owned: board.Delete(drawing) footprints = {f.GetReference(): f for f in board.GetFootprints()} obstacles = [] for fp in board.GetFootprints(): for pad in fp.Pads(): if not pad.IsOnLayer(p.F_Mask): continue assert pad.GetOrientationDegrees() % 90 == 0 x,y = xy(pad.GetPosition()) expansion = max(0, p.ToMM(pad.GetSolderMaskExpansion(p.F_Mask))) if pad.GetShape() == p.PAD_SHAPE_CIRCLE: geom = Point(x,y).buffer(p.ToMM(pad.GetSize().x)/2, quad_segs=32) else: bb=pad.GetBoundingBox() geom=box(p.ToMM(bb.GetX()),p.ToMM(bb.GetY()),p.ToMM(bb.GetRight()),p.ToMM(bb.GetBottom())) obstacles.append(geom.buffer(expansion+GAP+WIDTH/2)) for graphic in fp.GraphicalItems(): if isinstance(graphic,p.PCB_SHAPE) and graphic.GetLayer()==p.F_SilkS: for line in outline_lines(graphic): obstacles.append(line.buffer(p.ToMM(graphic.GetWidth())/2+GAP+WIDTH/2)) # Clear even tented vias for a robust visible guide, independent of mask settings. for track in board.GetTracks(): if isinstance(track,p.PCB_VIA): obstacles.append(Point(xy(track.GetPosition())).buffer(p.ToMM(track.GetWidth(p.F_Cu))/2+GAP+WIDTH/2,quad_segs=32)) blocked=unary_union(obstacles) edges = [LineString([xy(d.GetStart()), xy(d.GetEnd())]) for d in board.GetDrawings() if d.GetLayer() == p.Edge_Cuts] polygons = list(polygonize(edges)) assert len(polygons) == 1, 'Expected a single closed carrier outline' # U2's bare PCB front is flush with the carrier edge; do not print across it. silk_area = polygons[0].buffer(-(GAP + WIDTH / 2)) count=0 def add(shape): nonlocal count assert count < 4096 shape.SetLayer(p.F_SilkS) shape.SetUuid(p.KIID(str(uuid.uuid5(NS,str(count))))) board.Add(shape) count+=1 def line(a,b): clipped=LineString([a,b]).intersection(silk_area).difference(blocked) pieces=list(clipped.geoms) if hasattr(clipped,"geoms") else [clipped] for piece in pieces: if piece.geom_type!="LineString" or piece.length < 0.25: continue shape=p.PCB_SHAPE(board) shape.SetShape(p.SHAPE_T_SEGMENT) shape.SetStart(vec(piece.coords[0])); shape.SetEnd(vec(piece.coords[-1])) shape.SetWidth(p.FromMM(WIDTH)) add(shape) for ref in ("DS1","U2"): assert footprints[ref].GetOrientationDegrees()==0, "Review outlines for rotated modules" for source in footprints[ref].GraphicalItems(): if not isinstance(source,p.PCB_SHAPE) or source.GetLayer()!=p.F_Fab: continue if source.GetShape()==p.SHAPE_T_ARC: # Test small spans for clearance, but retain true circular arcs # for each visible run rather than replacing corners with chords. cx,cy=xy(source.GetCenter()) sx,sy=xy(source.GetStart()); mx,my=xy(source.GetArcMid()) start=math.atan2(sy-cy,sx-cx) sweep=2*math.atan2((sx-cx)*(my-cy)-(sy-cy)*(mx-cx), (sx-cx)*(mx-cx)+(sy-cy)*(my-cy)) radius=math.hypot(sx-cx,sy-cy) steps=max(1,math.ceil(abs(math.degrees(sweep)))) def at(t): angle=start+sweep*t/steps return cx+radius*math.cos(angle),cy+radius*math.sin(angle) run=None for i in range(steps+1): span=LineString([at(i),at(i+0.5),at(i+1)]) clear=i=0.25: arc=p.PCB_SHAPE(board) arc.SetShape(p.SHAPE_T_ARC) arc.SetArcGeometry(vec(at(run)),vec(at((run+i)/2)),vec(at(i))) arc.SetWidth(p.FromMM(WIDTH)) add(arc) run=None continue for edge in outline_lines(source): # Display boundary is visually distinct from the PCB perimeter. dashed = ref=="DS1" and round(edge.bounds[0]-xy(footprints[ref].GetPosition())[0],2) in (1.0,25.5) if dashed: distance=0.0 while distance < edge.length: line(edge.interpolate(distance).coords[0],edge.interpolate(min(distance+0.8,edge.length)).coords[0]) distance+=1.3 else: line(edge.coords[0],edge.coords[-1]) # These are pad-row guides, not inferred socket/body or ESP32 PCB dimensions. fp=footprints["U1"] assert fp.GetOrientationDegrees()==0, "Review guide construction for rotated U1" pads={pad.GetNumber():xy(pad.GetPosition()) for pad in fp.Pads()} for first,last in (("1","22"),("23","44")): x,ytop=pads[first]; _,ybottom=pads[last] corners=[(x-1.2,ytop-1.2),(x+1.2,ytop-1.2),(x+1.2,ybottom+1.2),(x-1.2,ybottom+1.2)] for a,b in zip(corners,corners[1:]+corners[:1]): line(a,b) label=p.PCB_TEXT(board) label.SetText("HEADER GUIDES") a,b=pads["1"],pads["23"] label.SetPosition(vec(((a[0]+b[0])/2,a[1]+28))) label.SetTextSize(p.VECTOR2I(p.FromMM(0.85),p.FromMM(0.85))) label.SetTextThickness(p.FromMM(0.12)) add(label) return count def main(): lock=ROOT/"~serial-carrier.kicad_pcb.lck" if lock.exists(): raise SystemExit("Close PCB Editor before updating silkscreen") before=PCB.read_bytes() project=ROOT/"serial-carrier.kicad_pro" project_before=project.read_bytes() board=p.LoadBoard(str(PCB)) count=add_outlines(board) temp=ROOT/"validation/pcb-routing-silk-tmp.kicad_pcb" try: assert p.SaveBoard(str(temp),board,True) if lock.exists() or PCB.read_bytes()!=before: raise RuntimeError("PCB changed or reopened; refusing to overwrite") assert project.read_bytes()==project_before,"Project settings changed" temp.replace(PCB) finally: temp.unlink(missing_ok=True) print(f"Added {count} board-local silkscreen graphics; project settings preserved.") if __name__=="__main__": main()