Files
ESP32_Serial_Swiss_Army_Knife/hardware/PCB/add_device_silkscreen.py
T
Commander1024 c0f7a1ad51 Integrate A1 carrier PCB CAD updates
- Add pinned STEP-derived DE-9 mechanical measurements and provenance
- Refresh placement, routing, silkscreen, and nominal CAD envelopes
- Document provisional dimensions, assumptions, and validation status
2026-09-21 12:31:24 +02:00

182 lines
7.9 KiB
Python

#!/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<steps and silk_area.covers(span) and not span.intersects(blocked)
if clear and run is None:
run=i
if not clear and run is not None:
if radius*abs(sweep)*(i-run)/steps>=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()