- 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
182 lines
7.9 KiB
Python
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()
|