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)}))