AnasSarkiz/muse-voice-remote

Muse Voice Remote — bare ESP32-S3R8, 155 TOP JLCPCB parts with real supplier CAD, native 3D assembly and GLB exports. Revised 17 mm case allocation; 0.30 mm via drill /0.45 mm pad. Checked prototype PCB; mechanical, RF and MUSE qualification pending.

Version
1.3.2
License
unset
Stars
0

scripts/finish-unrouted.py

#!/usr/bin/env python3
"""Complete KiCad's actual unconnected pairs after FreeRouting.

Use imported pads and existing copper as obstacles, search four copper layers,
and accept each branch only when independent KiCad connectivity improves.
This is a routing finish utility, not a replacement for electrical qualification.
"""
import sys
sys.path.insert(0,"/workspace/.cache/native-python")
import heapq, json, math, os, pathlib, subprocess
import pcbnew
from shapely.geometry import Point, LineString, MultiPoint, Polygon, box
from shapely.ops import unary_union
from shapely.prepared import prep

root=pathlib.Path(__file__).resolve().parents[1]
path=root/"dist/MuseVoiceRemote-library.kicad_pcb"
report_path=root/"dist/native-route-drc.json"
env={**os.environ,"XDG_CONFIG_HOME":"/workspace/.config","XDG_DATA_HOME":"/workspace/.local/share","XDG_CACHE_HOME":"/workspace/.cache"}
layers=[pcbnew.F_Cu,pcbnew.In1_Cu,pcbnew.In2_Cu,pcbnew.B_Cu]
step=0.125; width=0.15; clearance=0.15
via_diameter=0.45; via_drill=0.3
def xy(p):return pcbnew.ToMM(p.x),pcbnew.ToMM(p.y)
def vector(p):return pcbnew.VECTOR2I(pcbnew.FromMM(p[0]),pcbnew.FromMM(p[1]))
def check():
    # Keep actual poured-copper clearance/connectivity current after every edit.
    with (root/"dist/plane-fill-check.log").open("a") as log:
        subprocess.run(["/usr/bin/python3",str(root/"scripts/fill-ground-plane.py"),str(path)],
                       env=env,check=True,stdout=log,stderr=log)
    subprocess.run(["kicad-cli","pcb","drc",str(path),"--format","json","--output",str(report_path)],env=env,check=True,stdout=subprocess.DEVNULL)
    return json.loads(report_path.read_text())
def endpoints(item):
    if isinstance(item,pcbnew.PCB_VIA):return [(xy(item.GetPosition()),i) for i in range(4)]
    if isinstance(item,pcbnew.PCB_TRACK):return [(xy(p),layers.index(item.GetLayer())) for p in [item.GetStart(),item.GetEnd()]]
    return [(xy(item.GetPosition()),i) for i,l in enumerate(layers) if item.GetLayerSet().Contains(l)]
def obstacles(board,net):
    wire=[[] for _ in layers]; via=[]
    for x in [-31.5,31.5]:
        for y in [-22,22]:
            for bucket in wire:bucket.append(Point(x,y).buffer(1.8+width/2))
            via.append(Point(x,y).buffer(1.8+via_diameter/2))
    if net!="GND" and any(z.GetLayer()==pcbnew.In1_Cu and z.GetNetname()=="GND" for z in board.Zones()):
        # Keep an uninterrupted reference beneath RF and crystal copper.
        # Sparse inner-layer signal paths elsewhere are cleared by the zone filler.
        wire[1].append(box(-33,-24,33,24))
    if net not in {"GND","V3_3"} and any(z.GetLayer()==pcbnew.In2_Cu and z.GetNetname()=="V3_3" for z in board.Zones()):
        # General finishing must preserve the supply plane. Existing inner
        # copper remains; new signal paths use top/bottom with legal vias.
        wire[2].append(box(-33,-24,33,24))
    for t in board.GetTracks():
        if isinstance(t,pcbnew.PCB_VIA):
            radius=pcbnew.ToMM(t.GetWidth(pcbnew.F_Cu))/2
            via.append(Point(xy(t.GetPosition())).buffer(radius+via_diameter/2+clearance))
            if t.GetNetname()!=net:
                for bucket in wire:bucket.append(Point(xy(t.GetPosition())).buffer(radius+width/2+clearance))
        elif t.GetNetname()!=net:
            shape=LineString([xy(t.GetStart()),xy(t.GetEnd())])
            r=pcbnew.ToMM(t.GetWidth())/2
            wire[layers.index(t.GetLayer())].append(shape.buffer(r+width/2+clearance))
            via.append(shape.buffer(r+via_diameter/2+clearance))
    for f in board.GetFootprints():
        for p in f.Pads():
            bounds=p.GetBoundingBox();shape=box(*xy(bounds.GetOrigin()),*xy(bounds.GetEnd()))
            copper=p.GetEffectivePolygon(pcbnew.F_Cu)
            if copper.OutlineCount()>0:
                polygons=[]
                for index in range(copper.OutlineCount()):
                    outline=copper.COutline(index)
                    outer=[xy(outline.CPoint(i)) for i in range(outline.PointCount())]
                    holes=[]
                    for h in range(copper.HoleCount(index)):
                        hole=copper.CHole(index,h)
                        holes.append([xy(hole.CPoint(i)) for i in range(hole.PointCount())])
                    polygons.append(Polygon(outer,holes).buffer(0))
                shape=unary_union(polygons)
            if p.GetAttribute()==pcbnew.PAD_ATTRIB_NPTH and f.GetReference() in {"MIC1","MIC2"}:
                # Factory derivative uses 0.50 mm acoustic ports; route against
                # that cutout while retaining the original supplier import.
                shape=Point(xy(p.GetPosition())).buffer(.25)
            # No new via-in-pad holes; retain the original imported thermal holes.
            # Same-net via copper may join a land, but its drill must remain
            # outside the solderable pad. Other nets retain full Cu clearance.
            via.append(shape.buffer(via_drill/2+0.03 if p.GetNetname()==net else via_diameter/2+clearance))
            if p.GetNetname()!=net:
                for i,l in enumerate(layers):
                    if p.GetLayerSet().Contains(l):wire[i].append(shape.buffer(width/2+clearance))
    return [prep(unary_union(b)) for b in wire],prep(unary_union(via))
def route(board,a,b,front_only=False,min_x=None,max_x=None):
    net=a.GetNetname()
    assert net and b.GetNetname()==net
    occupied,via_obstacles=obstacles(board,net)
    connect=board.GetConnectivity()
    via_neighbors={}
    for v in board.GetTracks():
        if not isinstance(v,pcbnew.PCB_VIA) or v.GetNetname()!=net:continue
        for neighbor in list(connect.GetConnectedTracks(v))+list(connect.GetConnectedPads(v)):
            via_neighbors.setdefault(neighbor.m_Uuid.AsString(),[]).append(v)
    def island(item):
        found={};todo=[item]
        while todo:
            current=todo.pop();identifier=current.m_Uuid.AsString()
            if identifier in found or current.GetNetname()!=net:continue
            found[identifier]=current
            todo.extend(connect.GetConnectedTracks(current))
            todo.extend(connect.GetConnectedPads(current))
            todo.extend(via_neighbors.get(identifier,[]))
        return list(found.values())
    def point(n):return n[0]*step,n[1]*step
    def seeds(item):
        result={}
        for connected in island(item):
          for pos,layer in endpoints(connected):
            ix,iy=round(pos[0]/step),round(pos[1]/step)
            for dx in [-1,0,1]:
                for dy in [-1,0,1]:
                    n=(ix+dx,iy+dy,layer);p=point(n)
                    if not occupied[layer].intersects(LineString([pos,p])):result[n]=pos
        return result
    starts,goals=seeds(a),seeds(b)
    def in_bounds(k):return (min_x is None or point(k)[0]>=min_x) and (max_x is None or point(k)[0]<=max_x)
    starts={k:v for k,v in starts.items() if in_bounds(k)}
    goals={k:v for k,v in goals.items() if in_bounds(k)}
    if front_only:
        starts={k:v for k,v in starts.items() if k[2]==0}
        goals={k:v for k,v in goals.items() if k[2]==0}
    if not starts or not goals:raise RuntimeError(f"No clear endpoint for {net}")
    if len(starts)>len(goals):starts,goals=goals,starts
    target_points=MultiPoint(list({point(n) for n in goals}))
    def heuristic(n):return target_points.distance(Point(point(n)))/step
    print(f"Searching {net}: {len(starts)} accessible starts, {len(goals)} goals, {step} mm grid",flush=True)
    queue=[];cost={};prev={};origin={}
    for n,pos in starts.items():
        g=math.dist(pos,point(n))/step;cost[n]=g;origin[n]=pos
        heapq.heappush(queue,(g+heuristic(n),g,n))
    directions=[(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)]
    cache={}; settled=0
    while queue:
        _,g,n=heapq.heappop(queue)
        if g>cost[n]:continue
        settled+=1
        search_limit = 500000 if net in {"TALK", "STATUS_B", "STATUS_G", "STATUS_R", "BAT", "BL_SENSE", "LCD_BL_ENABLE", "LCD_DC", "USB_EN1", "POWER_GOOD_N"} else 400000 if net=="LCD_LEDA2" else 100000
        if settled>search_limit:raise RuntimeError(f"No clear four-layer route for {net} within search budget; inspect escapes")
        if n in goals:
            nodes=[n]
            while nodes[-1] in prev:nodes.append(prev[nodes[-1]])
            nodes.reverse()
            segments=[(origin[nodes[0]],point(nodes[0]),nodes[0][2])]
            new_vias=set()
            for u,v in zip(nodes,nodes[1:]):
                if u[2]!=v[2]:new_vias.add(point(u))
                else:segments.append((point(u),point(v),u[2]))
            segments.append((point(n),goals[n],n[2]))
            code=a.GetNetCode()
            for p,q,l in segments:
                if math.dist(p,q)<0.000001:continue
                t=pcbnew.PCB_TRACK(board);t.SetStart(vector(p));t.SetEnd(vector(q))
                t.SetLayer(layers[l]);t.SetWidth(pcbnew.FromMM(width));t.SetNetCode(code);board.Add(t)
            for p in new_vias:
                v=pcbnew.PCB_VIA(board);v.SetPosition(vector(p))
                for l in layers:v.SetWidth(l,pcbnew.FromMM(via_diameter))
                v.SetDrill(pcbnew.FromMM(via_drill));v.SetViaType(pcbnew.VIATYPE_THROUGH)
                v.SetLayerPair(pcbnew.F_Cu,pcbnew.B_Cu);v.SetNetCode(code);board.Add(v)
            return len(segments),len(new_vias),settled
        choices=[]
        for dx,dy in directions:
            nxt=(n[0]+dx,n[1]+dy,n[2]);p=point(nxt)
            if abs(p[0])>32.3 or abs(p[1])>23.3:continue
            if not in_bounds(nxt):continue
            key=(n,nxt)
            if key not in cache:cache[key]=occupied[n[2]].intersects(LineString([point(n),p]))
            if not cache[key]:choices.append((nxt,math.hypot(dx,dy)*(1 if n[2]==1 else 1.4)))
        p=point(n)
        if not front_only and abs(p[0])<32.15 and abs(p[1])<23.15 and not via_obstacles.intersects(Point(p)):
            choices.extend(((n[0],n[1],l),2.5/step) for l in range(4) if l!=n[2] and
                           not occupied[l].intersects(Point(p)))
        for nxt,increment in choices:
            new=g+increment
            if new>=cost.get(nxt,float("inf")):continue
            cost[nxt]=new;prev[nxt]=n;origin[nxt]=origin[n]
            heapq.heappush(queue,(new+heuristic(nxt),new,nxt))
    raise RuntimeError(f"No clear four-layer route for {net}")

# A FreeRouting pattern at this connector joins adjacent ground pins using an
# outside U-shaped link that encloses RD. Join the same ground pins directly
# across their adjacent pads so the existing electrical net is preserved and
# the neighboring control pad can escape. No component geometry is changed.
board=pcbnew.LoadBoard(str(path))
connector=next(f for f in board.GetFootprints() if f.GetReference()=="J_LCD")
p31=next(p for p in connector.Pads() if p.GetNumber()=="31")
p32=next(p for p in connector.Pads() if p.GetNumber()=="32")
assert p31.GetNetname()==p32.GetNetname()=="GND"
current_catalog=json.loads((root/"docs/component-catalog.json").read_text())
legacy_te=not any(p["library"].startswith("JLCPCB/") for p in current_catalog)
for horizontal in list(board.GetTracks()) if legacy_te else []:
    if isinstance(horizontal,pcbnew.PCB_VIA) or horizontal.GetNetname()!="GND" or horizontal.GetLayer()!=pcbnew.F_Cu:continue
    a,z=xy(horizontal.GetStart()),xy(horizontal.GetEnd());base=xy(p32.GetPosition())
    if math.dist(a,base)<0.00001:landing=z
    elif math.dist(z,base)<0.00001:landing=a
    else:continue
    if abs(landing[1]-base[1])>0.00001 or abs(landing[0]-base[0])<0.4:continue
    other=(landing[0],xy(p31.GetPosition())[1])
    vertical=next((t for t in board.GetTracks() if not isinstance(t,pcbnew.PCB_VIA) and t.GetNetname()=="GND" and t.GetLayer()==pcbnew.F_Cu and
                   (math.dist(xy(t.GetStart()),landing)<0.00001 and math.dist(xy(t.GetEnd()),other)<0.00001 or
                    math.dist(xy(t.GetEnd()),landing)<0.00001 and math.dist(xy(t.GetStart()),other)<0.00001)),None)
    if vertical is None:continue
    before=path.read_bytes();old=check()
    board.Remove(horizontal);board.Remove(vertical)
    bridge=pcbnew.PCB_TRACK(board);bridge.SetStart(p31.GetPosition());bridge.SetEnd(p32.GetPosition())
    bridge.SetLayer(pcbnew.F_Cu);bridge.SetWidth(pcbnew.FromMM(width));bridge.SetNetCode(p31.GetNetCode());board.Add(bridge)
    pcbnew.SaveBoard(str(path),board);new=check()
    if len(new["unconnected_items"])>len(old["unconnected_items"]) or any(v["type"] not in {"via_dangling","track_dangling"} for v in new["violations"]):
        path.write_bytes(before);raise RuntimeError("Connector ground-link rewrite failed independent checks")
    print("Joined adjacent FPC ground pins directly; freed control-pad escape",flush=True)
    break

deferred_pairs=set()
for iteration in range(512):
    report=check();pending=report["unconnected_items"]
    if not pending:break
    before=path.read_bytes();board=pcbnew.LoadBoard(str(path))
    items={i.m_Uuid.AsString():i for i in list(board.GetTracks())+[p for f in board.GetFootprints() for p in f.Pads()]}
    pending.sort(key=lambda r:({"MIC_WS_HOST":-8,"MIC_BCLK_HOST":-7,"AMP_ENABLE":-6,"AMP_ENABLE_HOST":-6,"SPK_BCLK":-5,"USB_DM_MCU":-4,"HAPTIC":-3,"LCD_2V8":0,"CHG_ITERM":1,"USB_CC2":2,"GND":-10}.get(items[r["items"][0]["uuid"]].GetNetname(),3),
                              math.dist(tuple(r["items"][0]["pos"].values()),tuple(r["items"][1]["pos"].values()))))
    result=None
    for candidate in pending:
        key=tuple(sorted(i["uuid"] for i in candidate["items"]))
        if key in deferred_pairs:continue
        pair=candidate["items"];a,b=[items[p["uuid"]] for p in pair]
        for grid in [0.125,0.05]:
            step=grid
            try:
                result=route(board,a,b)
                break
            except RuntimeError as error:
                if not str(error).startswith(("No clear four-layer route","No clear endpoint")):raise
                print(f"Deferred {a.GetNetname()} at {grid} mm: {error}",flush=True)
        if result is not None:break
        deferred_pairs.add(key)
    if result is None:raise RuntimeError("Remaining nets need local escape repair")
    pcbnew.SaveBoard(str(path),board)
    after=check()
    if len(after["unconnected_items"])>=len(pending) or any(v["type"] not in {"via_dangling","track_dangling"} for v in after["violations"]):
        path.write_bytes(before)
        deferred_pairs.add(key)
        print(f"Independent DRC rejected {a.GetNetname()}; restored previous CAD and deferred this pair",flush=True)
        continue
    print(f"Finished {a.GetNetname()}: {result}; {len(after['unconnected_items'])} pairs remain",flush=True)
else:raise RuntimeError("Unexpected number of remaining branches")

# Copper cleanup is separate: retain and report all intermediate warnings.
final=check()
if final["unconnected_items"] or any(v["type"] not in {"via_dangling","track_dangling"} for v in final["violations"]):
    raise RuntimeError("Connections or geometry still need repair")
print(f"All native pads connected; {len(final['violations'])} redundant-copper flags remain for prune-route-stubs.py",flush=True)
# Avoid KiCad 9 SWIG finalization of detached board-item wrappers.
os._exit(0)