techmannih/nema34-mounted-stepper-controller

A 48 V USB‑C PD 3.1 EPR NEMA‑34 stepper controller PCB combining STM32/TMC5160 control, external MOSFET motor bridges, protected power and regenerative braking, USB data, telemetry, diagnostics, status LEDs, and buzzer alarms.

Version
0.3.92
License
unset
Stars
1

scripts/prepare-routing-seed.py

#!/usr/bin/env python3
"""Add the reviewed thermal arrays and local capacitor ground returns before routing."""

import argparse
import json
import math
from pathlib import Path

import pcbnew
import wx
from critical_copper import check, load_paths


def point(x, y):
    return pcbnew.VECTOR2I(pcbnew.FromMM(x), pcbnew.FromMM(y))


def coordinates(position):
    return pcbnew.ToMM(position.x), pcbnew.ToMM(position.y)


def add_via(board, position, net, diameter=0.6, drill=0.3):
    via = pcbnew.PCB_VIA(board)
    via.SetPosition(position)
    via.SetWidth(pcbnew.FromMM(diameter))
    via.SetDrill(pcbnew.FromMM(drill))
    via.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
    via.SetNet(net)
    board.Add(via)


def add_track(board, start, end, net, layer, width):
    track = pcbnew.PCB_TRACK(board)
    track.SetStart(start)
    track.SetEnd(end)
    track.SetWidth(pcbnew.FromMM(width))
    track.SetLayer(layer)
    track.SetNet(net)
    board.Add(track)


def via_clear(board, candidate, pad):
    layers = (pcbnew.F_Cu, pcbnew.In1_Cu, pcbnew.In2_Cu, pcbnew.B_Cu)
    clearance_radius = pcbnew.FromMM(0.3 + 0.16)
    for footprint in board.GetFootprints():
        for other in footprint.Pads():
            if other.GetNetCode() == pad.GetNetCode():
                continue
            for layer in layers:
                if other.IsOnLayer(layer) and other.GetEffectiveShape(layer).Collide(candidate, clearance_radius):
                    return False
    for track in board.GetTracks():
        if track.GetNetCode() == pad.GetNetCode() and not isinstance(track, pcbnew.PCB_VIA):
            continue
        if track.GetEffectiveShape().Collide(candidate, clearance_radius):
            return False
    return True


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("source", type=Path)
    parser.add_argument("output", type=Path)
    args = parser.parse_args()
    app = wx.App(False)
    board = pcbnew.LoadBoard(str(args.source.resolve()))
    if any(isinstance(track, pcbnew.PCB_VIA) for track in board.GetTracks()):
        raise SystemExit("Use a fresh top-side source export; this command is not additive to an existing route")
    footprints = {fp.GetReference(): fp for fp in board.GetFootprints()}
    policy = json.loads(Path("board-standards.json").read_text())["fabrication"]["thermal_via_in_pad"]
    thermal = []
    for reference in policy["references"]:
        pad = next(p for p in footprints[reference].Pads() if p.GetNumber() == policy["pad_number"])
        x, y = coordinates(pad.GetPosition())
        corners = [(x - 0.5, y - 0.5), (x + 0.5, y - 0.5), (x + 0.5, y + 0.5), (x - 0.5, y + 0.5)]
        shape = pad.GetEffectiveShape(pcbnew.F_Cu)
        for vx, vy in corners:
            if not all(shape.Collide(point(vx + dx, vy + dy), 0)
                       for dx, dy in ((-0.3, 0), (0.3, 0), (0, -0.3), (0, 0.3))):
                raise SystemExit(f"{reference}: thermal via does not fit within drain pad")
            add_via(board, point(vx, vy), pad.GetNet())
            thermal.append({"reference": reference, "net": pad.GetNetname(), "x": vx, "y": vy})
        for start, end in zip(corners, corners[1:] + corners[:1]):
            add_track(board, point(*start), point(*end), pad.GetNet(), pcbnew.B_Cu, 0.25)

    # Include the two buck-controller internal VCC bypass capacitors as well as
    # the separately constrained input/supply bypasses in the source design.
    capacitors = [pair[2] for pair in json.loads(Path("docs/decoupling-targets.json").read_text())] + ["C13", "C64"]
    returns = []
    for reference in capacitors:
        footprint = footprints[reference]
        if footprint.GetLayer() != pcbnew.F_Cu:
            raise SystemExit(f"{reference}: expected top-side capacitor")
        pad = next(p for p in footprint.Pads() if p.GetNumber() == "2")
        if pad.GetNetname() != "GND":
            raise SystemExit(f"{reference}: pin 2 is not the expected ground return")
        x, y = coordinates(pad.GetPosition())
        cx, cy = coordinates(footprint.GetPosition())
        angle = math.atan2(y - cy, x - cx)
        candidate = None
        for distance in (0.9, 1.1, 1.3, 1.5):
            for delta in (0, math.pi / 4, -math.pi / 4, math.pi / 2, -math.pi / 2):
                proposed = point(x + distance * math.cos(angle + delta), y + distance * math.sin(angle + delta))
                # Keep the return via outside the capacitor solderable land.
                if pad.GetEffectiveShape(pcbnew.F_Cu).Collide(proposed, pcbnew.FromMM(0.4)):
                    continue
                if via_clear(board, proposed, pad):
                    candidate = proposed
                    break
            if candidate is not None:
                break
        if candidate is None:
            raise SystemExit(f"{reference}: no clear local ground-return via position")
        add_via(board, candidate, pad.GetNet())
        add_track(board, pad.GetPosition(), candidate, pad.GetNet(), pcbnew.F_Cu, 0.2)
        returns.append({"reference": reference, "via": coordinates(candidate), "length_mm": math.dist((x, y), coordinates(candidate))})

    # These checked escapes reserve the narrow corridors around the TMC5160
    # before general routing can block them. Positions remain pad-relative.
    routing = json.loads(Path("routing/pin-escapes.json").read_text())
    pads = {(fp.GetReference(), p.GetNumber()): p for fp in footprints.values() for p in fp.Pads()}
    for escape in routing["pin_escapes"]:
        pad = pads[escape["reference"], escape["pin"]]
        if pad.GetNetname() != escape["net"]:
            raise SystemExit(f"Pin-escape net changed: {escape['reference']}.{escape['pin']}")
        x, y = coordinates(pad.GetPosition())
        points = [point(x + dx, y + dy) for dx, dy in escape["points_from_pad_mm"]]
        if points[0] != pad.GetPosition():
            raise SystemExit("Pin escape must start at its physical pad")
        for start, end in zip(points, points[1:]):
            add_track(board, start, end, pad.GetNet(), pcbnew.F_Cu, escape["trace_width_mm"])
        add_via(board, points[-1], pad.GetNet(), escape["via_diameter_mm"], escape["via_drill_mm"])
    for link in routing["mosfet_pad_links"]:
        start, end = [pads[link["reference"], pin] for pin in link["pins"]]
        if start.GetNetname() != link["net"] or end.GetNetname() != link["net"]:
            raise SystemExit(f"MOSFET common-pin net changed: {link['reference']}")
        add_track(board, start.GetPosition(), end.GetPosition(), start.GetNet(), pcbnew.F_Cu, link["width_mm"])
    problems = check(board, load_paths(board))["errors"]
    if problems:
        raise SystemExit(f"Routing seed violates Kelvin isolation: {problems}")

    # Full-board planes are regenerated on the final routed board. Native DRC
    # must check every short escape and the completed connections to the planes.
    for zone in list(board.Zones()):
        board.Remove(zone)
    board.BuildConnectivity()
    args.output.parent.mkdir(parents=True, exist_ok=True)
    pcbnew.SaveBoard(str(args.output.resolve()), board)
    report = {"thermal_vias": thermal, "capacitor_ground_returns": returns,
              "pin_escapes": routing["pin_escapes"], "mosfet_pad_links": routing["mosfet_pad_links"],
              "native_drc_still_required": True}
    args.output.with_suffix(".seed.json").write_text(json.dumps(report, indent=2) + "\n")
    print(f"Added {len(thermal)} thermal vias and {len(returns)} local capacitor ground returns")


if __name__ == "__main__":
    main()