shibosoftwaredev/f1c100s-linux-nema17-stepper-controller
A four-layer NEMA 17 motor-controller PCB combining an Allwinner F1C100S Linux computer, USB‑PD/buck power system, W5500 Ethernet, STM32 real-time motion control, DRV8825 stepper driver, protection/temperature/current telemetry, USB interfaces, and local LED/buzzer diagnostics.
- Version
- 0.4.56
- License
- unset
- Stars
- 0
scripts/relocate-tight-vias.py
"""Relocate trapped drills using short, real per-layer connection stubs.
The old drill is removed, not hidden or shrunk. Every old copper junction is
retained and joined to the new full-through 0.30/0.45 mm via. Emits a candidate
only; all emitted-copper checks must pass before adoption.
"""
from route_geometry import *
from collections import defaultdict
from shapely.ops import unary_union
from shapely import contains_xy, prepare
from shapely.strtree import STRtree
import numpy as np
import heapq,time,sys
c=load_circuit(os.environ.get("VIA_CIRCUIT_INPUT","dist/index/circuit.json"));root=connectivity(c)
outward="--outward" in sys.argv
remove_smd="--smd" in sys.argv
ordinary_only="--ordinary" in sys.argv
thermal_relocation="--thermal-land" in sys.argv
search_radius=next((float(a.split("=",1)[1]) for a in sys.argv if a.startswith("--radius=")),None)
outward_launches={(4.199894,1.60001):(4.199894,1.00001),
(5.400044,10.39999):(5.400044,10.99999)}
sources={e["source_trace_id"]:e for e in c if e["type"]=="source_trace"}
ports={e["pcb_port_id"]:e for e in c if e["type"]=="pcb_port"}
components={e["pcb_component_id"]:e for e in c if e["type"]=="pcb_component"}
source_parts={e["source_component_id"]:e for e in c if e["type"]=="source_component"}
source_ports={e["source_port_id"]:e for e in c if e["type"]=="source_port"}
def net(t):
s=sources[t["source_trace_id"]];ids=s.get("connected_source_port_ids",[])+s.get("connected_source_net_ids",[])
return root(ids[0])
def key(x,y):return round(x,6),round(y,6)
routes=json.loads(Path(os.environ.get("VIA_REPLAY_INPUT","routing/complete-routes.json")).read_text());metal=[]
seen=set()
for t in routes:
n=net(t)
for a,b in zip(t["route"],t["route"][1:]):
if a["route_type"]!=b["route_type"] or a["route_type"]!="wire" or a["layer"]!=b["layer"]:continue
aa,bb=key(a["x"],a["y"]),key(b["x"],b["y"])
if math.dist(aa,bb)<1e-7:continue
signature=n,a["layer"],tuple(sorted([aa,bb]))
if signature in seen:continue
seen.add(signature)
metal.append(dict(net=n,layers=[a["layer"]],shape=LineString([aa,bb]).buffer(max(a["width"],b["width"])/2,quad_segs=16),
pad=False,points=[aa,bb],width=max(a["width"],b["width"])))
for e in c:
if e["type"] not in ["pcb_smtpad","pcb_plated_hole"]:continue
p=ports.get(e.get("pcb_port_id"));n=root(p["source_port_id"]) if p else "NC:"+e[e["type"]+"_id"]
component=components[e["pcb_component_id"]]
bare_probe=component.get("do_not_place",False) and source_parts[component["source_component_id"]].get("ftype")=="simple_test_point"
pin=source_ports.get(p.get("source_port_id"),{}).get("pin_number") if p else None
metal.append(dict(net=n,layers=e.get("layers",[e.get("layer")]),
shape=geometry(e,"outer_" if e["type"]=="pcb_plated_hole" else ""),pad=True,bare_probe=bare_probe,
soc_thermal_land=source_parts[component["source_component_id"]]["name"]=="U1" and pin==89))
trace_by_id={e["pcb_trace_id"]:e for e in c if e["type"]=="pcb_trace"}
vias=[dict(x=e["x"],y=e["y"],net=net({"source_trace_id":e.get("source_trace_id",trace_by_id.get(e.get("pcb_trace_id"),{}).get("source_trace_id"))}))
for e in c if e["type"]=="pcb_via"]
fixed=json.loads(Path("routing/native-features.json").read_text())
fixed_xy={key(v["x"],v["y"]) for v in fixed["thermal_vias"]+fixed.get("auxiliary_vias",[])}
ordinary_xy=set()
if ordinary_only:
# Thermal-pad via arrays are a separate stencil/process review. Do not
# disturb them while repairing ordinary populated component solder lands.
pads_by_component={}
source_ports={e["source_port_id"]:e for e in c if e["type"]=="source_port"}
for e in c:
if e["type"]!="pcb_smtpad":continue
component=source_parts[components[e["pcb_component_id"]]["source_component_id"]]
p=source_ports.get(ports.get(e.get("pcb_port_id"),{}).get("source_port_id"),{})
if (component["name"],p.get("pin_number")) in {("U1",89),("U_MOTOR",29),("U_PD",17)}:continue
if component.get("ftype")=="simple_test_point" or components[e["pcb_component_id"]].get("do_not_place"):continue
for v in vias:
if Point(v["x"],v["y"]).buffer(.15).intersection(geometry(e)).area>1e-7:
ordinary_xy.add(key(v["x"],v["y"]))
holes=[geometry(e,"hole_") for e in c if e["type"]=="pcb_hole"]
tree=STRtree([m["shape"] for m in metal]);began=time.monotonic();accepted=[];failures=[]
def bad(v):
p=Point(v["x"],v["y"])
if remove_smd:
return any(metal[int(i)]["pad"] and not metal[int(i)].get("bare_probe") and p.distance(metal[int(i)]["shape"])<.15-.0002
for i in tree.query(p.buffer(.15)))
return any(metal[int(i)]["net"]!=v["net"] and p.distance(metal[int(i)]["shape"])<.35-.0002
for i in tree.query(p.buffer(.35)))
for v in vias:
if time.monotonic()-began>50:break
if key(v["x"],v["y"]) in fixed_xy or not bad(v):continue
if ordinary_only and key(v["x"],v["y"]) not in ordinary_xy:continue
if outward and key(v["x"],v["y"]) not in outward_launches:continue
if not outward and len(sys.argv)>2 and not sys.argv[1].startswith("--") and math.dist((v["x"],v["y"]),tuple(map(float,sys.argv[1:3])))>.001:continue
old=key(v["x"],v["y"]);p=Point(old)
refs=[(t,j,q) for t in routes if net(t)==v["net"] for j,q in enumerate(t["route"])
if q["route_type"]=="via" and key(q["x"],q["y"])==old]
if not refs:continue
layers=set()
for t,j,q in refs:layers.update([q["from_layer"],q["to_layer"]])
own=[metal[int(i)] for i in tree.query(p.buffer(.225)) if metal[int(i)]["net"]==v["net"]
and p.distance(metal[int(i)]["shape"])<.225]
layers.update(l for m in own for l in m["layers"])
# Do not lose an incidental annulus-edge contact which has no center stub.
if any(not any(m["net"]==v["net"] and l in m["layers"] and m["shape"].buffer(.0001).covers(p) for m in own) for l in layers):
failures.append(dict(old=old,reason="noncentral existing copper contact"));continue
width=max(.12,max(q.get("width",.12) for t,j,z in refs for q in [t["route"][max(0,j-1)],t["route"][min(len(t["route"])-1,j+1)]]))
layer_widths={l:max([.12]+[p["width"] for t,j,z in refs
for p in [t["route"][max(0,j-1)],t["route"][min(len(t["route"])-1,j+1)]]
if p["route_type"]=="wire" and p["layer"]==l]) for l in layers}
pitch=.01;radius=search_radius if search_radius is not None else 3.5 if outward else 2.5;half=int(radius/pitch);size=2*half+1
axisx=v["x"]+np.arange(-half,half+1)*pitch;axisy=v["y"]+np.arange(-half,half+1)*pitch
xx,yy=np.meshgrid(axisx,axisy);region=p.buffer(radius*1.5)
nearby=[metal[int(i)] for i in tree.query(region)]
forbidden_vias=[];blocked={l:[] for l in layers}
for m in nearby:
if m["net"]!=v["net"]:
forbidden_vias.append(m["shape"].buffer(.354))
for l in set(m["layers"])&layers:blocked[l].append(m["shape"].buffer(.1002+layer_widths[l]/2))
# Bare same-net factory probes are already via-in-pad features with
# no fitted body or paste. This does not permit vias under SMT parts.
# An existing SoC thermal stitch may move WITHIN its assigned thermal
# land. No ordinary solder land or opposite-face component is exempt.
assigned_thermal=thermal_relocation and m.get("soc_thermal_land") and m["net"]==v["net"] and m["shape"].covers(p)
if m["pad"] and not(m["net"]==v["net"] and m.get("bare_probe")) and not assigned_thermal:
forbidden_vias.append(m["shape"].buffer(.33 if remove_smd else .163))
for q in vias:
if q is v:continue
g=Point(q["x"],q["y"]);forbidden_vias.append(g.buffer(.554))
if q["net"]!=v["net"]:
for l in layers:blocked[l].append(g.buffer(.354+layer_widths[l]/2))
for h in holes:
forbidden_vias.append(h.buffer(.429))
for l in layers:blocked[l].append(h.buffer(.203+layer_widths[l]/2))
for x in [-15.5,15.5]:
for y in [-15.5,15.5]:
for l in layers:
r=2.85 if l=="top" else 2.6 if l=="bottom" else 0
if r:blocked[l].append(Point(x,y).buffer(r+layer_widths[l]/2))
def raster(items):
g=unary_union(items);prepare(g);return contains_xy(g,xx,yy)
via_ok=~raster(forbidden_vias)
if thermal_relocation:
assigned=[m["shape"].buffer(-.225) for m in metal if m.get("soc_thermal_land") and m["net"]==v["net"] and m["shape"].covers(p)]
assert assigned,"thermal relocation must start in its original SoC thermal land"
via_ok&=raster(assigned)
via_ok&=(np.abs(xx)<20.72)&(np.abs(yy)<20.72)
maps={l:raster(items)|((np.abs(xx)>20.95-layer_widths[l]/2)|(np.abs(yy)>20.95-layer_widths[l]/2)) for l,items in blocked.items()}
distances={};previous={};launches={};directions=[(di,dj,math.hypot(di,dj)) for di in [-1,0,1] for dj in [-1,0,1] if di or dj]
for l,m in maps.items():
launch=outward_launches[old] if outward and l=="top" else old
launch_point=Point(launch)
# Dense existing 0.12 mm corridors may have no grid-aligned center.
# Reuse their real, unchanged centerlines to reach an open launch
# point rather than widening a corridor or deleting its obstacles.
graph=defaultdict(list)
for item in nearby:
if item["net"]!=v["net"] or l not in item["layers"] or item["pad"] or item.get("width",0)<layer_widths[l]-1e-7:continue
a,b=item["points"]
graph[a].append((b,math.dist(a,b)));graph[b].append((a,math.dist(a,b)))
if LineString([a,b]).distance(launch_point)<1e-6:
for q in [a,b]:graph[launch].append((q,math.dist(launch,q)));graph[q].append((launch,math.dist(launch,q)))
graph_dist={launch:0};graph_paths={launch:[launch]};queue=[(0,launch)]
while queue:
cost,point=heapq.heappop(queue)
if cost>graph_dist[point]+1e-9:continue
for q,d in graph[point]:
new=cost+d
if new<graph_dist.get(q,math.inf):
graph_dist[q]=new;graph_paths[q]=graph_paths[point]+[q];heapq.heappush(queue,(new,q))
blocked_geometry=unary_union(blocked[l])
candidates=[(point,path) for point,path in graph_paths.items()]
for a,path in list(graph_paths.items()):
for b,d in graph[a]:
if d<.05:continue
for t in np.arange(.05,d,.05):
q=(a[0]+(b[0]-a[0])*t/d,a[1]+(b[1]-a[1])*t/d)
candidates.append((q,path+[q]))
seeds={}
for point,path in candidates:
if math.dist(point,old)>radius:continue
ii=int(round((point[0]-v["x"])/pitch))+half;jj=int(round((point[1]-v["y"])/pitch))+half
for i in range(max(0,ii-1),min(size,ii+2)):
for j in range(max(0,jj-1),min(size,jj+2)):
if m[j,i]:continue
target_point=(float(axisx[i]),float(axisy[j]))
connector=LineString([point,target_point])
if connector.intersects(blocked_geometry):continue
prefix=path+[target_point];cost=sum(math.dist(a,b) for a,b in zip(prefix,prefix[1:]))/pitch
if (i,j) not in seeds or cost<seeds[i,j][0]:seeds[i,j]=(cost,prefix)
launches[l]=seeds
dist=np.full((size,size),np.inf);prev={};heap=[]
for (i,j),(cost,prefix) in seeds.items():dist[j,i]=cost;heapq.heappush(heap,(cost,i,j))
while heap:
cost,i,j=heapq.heappop(heap)
if cost>dist[j,i]+1e-8:continue
for di,dj,step in directions:
ii,jj=i+di,j+dj
if not(0<=ii<size and 0<=jj<size) or m[jj,ii]:continue
if di and dj and (m[j,ii] or m[jj,i]):continue
new=cost+step
if new<dist[jj,ii]:dist[jj,ii]=new;prev[ii,jj]=(i,j);heapq.heappush(heap,(new,ii,jj))
distances[l]=dist;previous[l]=prev
score=sum(distances.values())
score[~via_ok]=np.inf
# Don't replace a drill with an almost coincident near-duplicate.
score[np.hypot(xx-v["x"],yy-v["y"])<.075]=np.inf
j,i=np.unravel_index(np.argmin(score),score.shape)
if not np.isfinite(score[j,i]):failures.append(dict(old=old,reason="no legal local via/stubs"));continue
target=round(float(axisx[i]),6),round(float(axisy[j]),6);paths={}
for l in layers:
path=[(int(i),int(j))]
while path[-1] in previous[l]:path.append(previous[l][path[-1]])
prefix=launches[l][path[-1]][1]
path.reverse();reduced=[path[0]]
for a,b,d in zip(path,path[1:],path[2:]):
if (b[0]-a[0],b[1]-a[1])!=(d[0]-b[0],d[1]-b[1]):reduced.append(b)
reduced.append(path[-1]);paths[l]=prefix[:-1]+[(round(float(axisx[ii]),6),round(float(axisy[jj]),6)) for ii,jj in reduced]
# Replace each actual old via occurrence with its checked round-trip stubs.
for t,j,q in sorted(refs,key=lambda x:x[1],reverse=True):
f,to=q["from_layer"],q["to_layer"]
w=lambda points,layer:[dict(route_type="wire",layer=layer,width=layer_widths[layer],x=x,y=y) for x,y in points]
beg,end=j,j+1
if outward and f=="top":
beg=max(i for i,z in enumerate(t["route"][:j]) if z["route_type"]=="wire" and z["layer"]=="top" and key(z["x"],z["y"])==outward_launches[old])
if outward and to=="top":
end=1+next(i for i,z in enumerate(t["route"]) if i>j and z["route_type"]=="wire" and z["layer"]=="top" and key(z["x"],z["y"])==outward_launches[old])
replacement=w(paths[f],f)+[{**q,"x":target[0],"y":target[1]}]+w(list(reversed(paths[to])),to)
# Circuit JSON consumers use the maximum endpoint width for a segment.
# Keep width transitions at zero-length junctions, otherwise adding a
# wide stub can silently widen the untouched preceding/following wire.
previous=t["route"][beg-1] if beg else None
following=t["route"][end] if end<len(t["route"]) else None
if previous and previous["route_type"]=="wire" and previous["layer"]==f:
replacement.insert(0,{**replacement[0],"width":previous["width"]})
if following and following["route_type"]=="wire" and following["layer"]==to:
replacement.append({**replacement[-1],"width":following["width"]})
for field in ["start_pcb_port_id","end_pcb_port_id"]:
if field in t["route"][beg]:replacement[0][field]=t["route"][beg][field]
if field in t["route"][end-1]:replacement[-1][field]=t["route"][end-1][field]
t["route"][beg:end]=replacement
# Preserve contacts on layers that were incidental to the drilled barrel.
explicit={l for t,j,q in refs for l in [q["from_layer"],q["to_layer"]]}
for l in layers-explicit:
owner=refs[0][0]
routes.append({**{k:z for k,z in owner.items() if k!="route"},
"pcb_trace_id":f"local_via_stub_{len(routes)}",
"route":[dict(route_type="wire",layer=l,width=layer_widths[l],x=x,y=y) for x,y in paths[l]]})
for l,points in paths.items():
for a,b in zip(points,points[1:]):
if math.dist(a,b)>1e-7:metal.append(dict(net=v["net"],layers=[l],shape=LineString([a,b]).buffer(layer_widths[l]/2),pad=False,points=[key(*a),key(*b)],width=layer_widths[l]))
accepted.append(dict(old=old,new=target,width=width,layerWidths=layer_widths,paths=paths))
v["x"],v["y"]=target;tree=STRtree([m["shape"] for m in metal])
Path("routing/relocated-vias-candidate.json").write_text(json.dumps(routes,indent=2)+"\n")
report=dict(seconds=round(time.monotonic()-began,2),accepted=accepted,failures=failures,notInstalled=True,
offendingViasRemaining=sum(bad(v) for v in vias))
Path("reports/relocated-vias-candidate.json").write_text(json.dumps(report,indent=2)+"\n")
print(json.dumps({k:v for k,v in report.items() if k not in ["accepted","failures"]}|{"accepted":len(accepted),"rejected":len(failures)}))