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/repair-via-clearance.py
"""Bounded local via-placement repair; emits a candidate, never installs it.
Only move a via and all coincident same-net trace endpoints, retaining pad
contacts, wire widths, layers, source identities and all fixed thermal vias.
Reject candidates that introduce foreign copper, drill, edge or screw-land
violations. Final rebuilt-copper continuity/DRC checks are still mandatory.
"""
from route_geometry import *
from collections import defaultdict
from shapely.strtree import STRtree
import copy, time, sys
c = load_circuit("dist/index/circuit.json")
root = connectivity(c)
source = {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"}
def trace_net(t):
s = source[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(sys.argv[1] if len(sys.argv)>1 else "routing/complete-routes.json").read_text())
segments = []
by_segment = {}
point_refs = defaultdict(list)
for t in routes:
net = trace_net(t)
for p in t["route"]:
point_refs[net, key(p["x"], p["y"])].append(p)
for a,b in zip(t["route"],t["route"][1:]):
if a["route_type"] != "wire" or b["route_type"] != "wire" or a["layer"] != b["layer"]: continue
if math.dist((a["x"],a["y"]),(b["x"],b["y"])) < 1e-7: continue
endpoints = tuple(sorted([key(a["x"],a["y"]),key(b["x"],b["y"])]))
signature = net,a["layer"],max(a["width"],b["width"]),endpoints
if signature in by_segment: continue
by_segment[signature] = len(segments)
segments.append(dict(net=net,layer=a["layer"],width=signature[2],points=list(endpoints)))
def segment_shape(s): return LineString(s["points"]).buffer(s["width"]/2,quad_segs=16)
metal = [dict(net=s["net"],layers=[s["layer"]],shape=segment_shape(s)) for s in segments]
for e in c:
if e["type"] not in ["pcb_smtpad","pcb_plated_hole"]: continue
p = ports.get(e.get("pcb_port_id"))
net = root(p["source_port_id"]) if p else "UNCONNECTED:"+e[e["type"]+"_id"]
metal.append(dict(net=net,layers=e.get("layers",[e.get("layer")]),
shape=geometry(e,"outer_" if e["type"]=="pcb_plated_hole" else "")))
holes = [geometry(e,"hole_") for e in c if e["type"] == "pcb_hole"]
fixed_coordinates = {key(v["x"],v["y"]) for e in [json.loads(Path("routing/native-features.json").read_text())]
for v in e["thermal_vias"]+e.get("auxiliary_vias",[])}
traces = {e["pcb_trace_id"]:e for e in c if e["type"]=="pcb_trace"}
vias = []
for e in c:
if e["type"] != "pcb_via": continue
t = traces.get(e.get("pcb_trace_id"),{})
net = trace_net({"source_trace_id":e.get("source_trace_id",t.get("source_trace_id"))})
vias.append(dict(x=e["x"],y=e["y"],net=net,fixed=key(e["x"],e["y"]) in fixed_coordinates))
tree = STRtree([m["shape"] for m in metal])
reasons=defaultdict(int)
def reject(reason):
reasons[reason]+=1
return None
def rebuild_tree(): return STRtree([m["shape"] for m in metal])
def bad_via(v):
p = Point(v["x"],v["y"])
return [int(i) for i in tree.query(p.buffer(.356))
if metal[int(i)]["net"] != v["net"] and p.distance(metal[int(i)]["shape"]) < .35-.0002]
def valid_move(v, xy, attached):
p = Point(xy)
if max(abs(xy[0]),abs(xy[1])) > 20.72: return reject("edge")
if any(p.distance(h)<.428 for h in holes): return reject("hole")
if any(math.dist(xy,(q["x"],q["y"]))<.553 for q in vias if q is not v): return reject("other-via")
for i in tree.query(p.buffer(.36)):
m=metal[int(i)]
if m["net"]!=v["net"] and p.distance(m["shape"]) < .353: return reject("via-foreign-copper")
changed={}
oldkey=key(v["x"],v["y"])
for i in attached:
s=segments[i]
points=[xy if key(*q)==oldkey else q for q in s["points"]]
g=LineString(points).buffer(s["width"]/2,quad_segs=16)
if max(abs(b) for b in g.bounds)>20.95: return reject("wire-edge")
if any(g.distance(h)<.202 for h in holes): return reject("wire-hole")
for j in tree.query(g.buffer(.103)):
m=metal[int(j)]
if m["net"]==v["net"] or s["layer"] not in m["layers"]: continue
if g.distance(m["shape"]) < .1001: return reject("wire-foreign-copper")
for q in vias:
if q["net"]!=v["net"]:
point=Point(q["x"],q["y"])
if point.distance(g)<min(.353,point.distance(metal[i]["shape"]))-.000001:return reject("wire-other-drill")
# Preserve clearance to exposed screw lands on each assembly face.
radius=2.85 if s["layer"]=="top" else 2.6 if s["layer"]=="bottom" else 0
if radius and any(g.distance(Point(x,y))<radius for x in [-15.5,15.5] for y in [-15.5,15.5]):
# Do not worsen inherited masked/ground contact geometry.
old=metal[i]["shape"]
if any(g.intersection(Point(x,y).buffer(radius)).difference(old.buffer(.0001)).area>1e-7
for x in [-15.5,15.5] for y in [-15.5,15.5]): return reject("screw-land")
changed[i]=(points,g)
# A via can also join a pad or a wire interior without a listed endpoint.
# Preserve every such existing same-net contact on every layer.
oldpad=Point(v["x"],v["y"]).buffer(.225)
newpad=p.buffer(.225)
for i in tree.query(oldpad):
i=int(i);m=metal[i]
if m["net"]==v["net"] and oldpad.intersects(m["shape"]):
if not newpad.intersects(changed[i][1] if i in changed else m["shape"]): return reject("own-contact")
return changed
began=time.monotonic();moves=[];rejected=[]
before=sum(bool(bad_via(v)) for v in vias)
offsets=[(round(r*math.cos(a*math.pi/8),6),round(r*math.sin(a*math.pi/8),6))
for r in [.01,.02,.03,.04,.05,.06,.08,.1,.12,.15,.2,.25,.3,.4,.5] for a in range(16)]
for v in vias:
if time.monotonic()-began>50: break
if not bad_via(v) or v["fixed"]: continue
old=key(v["x"],v["y"])
refs=point_refs.get((v["net"],old),[])
if not any(p["route_type"]=="via" for p in refs): continue
attached=[i for i,s in enumerate(segments) if s["net"]==v["net"] and old in [key(*q) for q in s["points"]]]
if not attached: continue
reasons.clear()
for dx,dy in offsets:
xy=round(v["x"]+dx,6),round(v["y"]+dy,6)
changes=valid_move(v,xy,attached)
if changes is None: continue
for p in refs:p["x"],p["y"]=xy
for i,(points,g) in changes.items():
segments[i]["points"]=points;metal[i]["shape"]=g
moves.append(dict(net=v["net"],before=old,after=xy,attachedSegments=len(attached)))
v["x"],v["y"]=xy
tree=rebuild_tree()
break
else:rejected.append(dict(x=v["x"],y=v["y"],reasons=dict(reasons)))
after=sum(bool(bad_via(v)) for v in vias)
Path("routing/drill-clearance-candidate.json").write_text(json.dumps(routes,indent=2)+"\n")
report=dict(seconds=round(time.monotonic()-began,2),offendingViasBefore=before,offendingViasAfter=after,
moves=moves,notInstalled=True,remaining=[v for v in vias if bad_via(v)],rejected=rejected)
Path("reports/via-repair-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 ["moves","remaining"]}|{"moved":len(moves)}))