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/reinforce-power-copper.py

#!/usr/bin/env python3
"""Reinforce routed high-current nets with clearance-aware copper corridors.

The imported footprints need short neck-downs at dense pads.  Instead of
blindly widening every segment and creating DRC errors, this script follows
the verified route with 2.4 mm copper zones.  KiCad clips the zones around
foreign copper while retaining the original trace as the connectivity spine.
Layer changes receive additional parallel vias where clearance permits.
"""

from __future__ import annotations

import argparse
import math
from pathlib import Path

import pcbnew
import wx
from critical_copper import load_paths, is_branch_track, distance_to_segment, xy, reservation_polygons, vector


POWER_NETS = {
    "PD_VBUS",
    "EFUSE_IN",
    "VMOTOR",
    "MOTOR_A1",
    "MOTOR_A1_OUT",
    "MOTOR_B1_OUT",
    "MOTOR_A2",
    "MOTOR_B1",
    "MOTOR_B2",
    "SENSE_A",
    "SENSE_B",
    "BRAKE_RETURN",
}

CORRIDOR_WIDTH_MM = 2.4
CLEARANCE_MM = 0.16
VIA_DIAMETER_MM = 0.60
VIA_DRILL_MM = 0.30
VIA_PITCH_MM = 0.90

# r0.4 is a new route. Generate every corridor and remove only isolated zones
# identified by this candidate's native DRC; r0.3 priority numbers are invalid.
SKIP_CORRIDOR_PRIORITIES = set()


def mm(value: int) -> float:
    return pcbnew.ToMM(value)


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


def add_corridor(board: pcbnew.BOARD, track: pcbnew.PCB_TRACK, priority: int) -> None:
    start = track.GetStart()
    end = track.GetEnd()
    x1, y1 = mm(start.x), mm(start.y)
    x2, y2 = mm(end.x), mm(end.y)
    dx, dy = x2 - x1, y2 - y1
    length = math.hypot(dx, dy)
    if length < 0.02:
        return
    ux, uy = dx / length, dy / length
    nx, ny = -uy, ux
    half = CORRIDOR_WIDTH_MM / 2
    extension = half
    corners = [
        (x1 - ux * extension + nx * half, y1 - uy * extension + ny * half),
        (x2 + ux * extension + nx * half, y2 + uy * extension + ny * half),
        (x2 + ux * extension - nx * half, y2 + uy * extension - ny * half),
        (x1 - ux * extension - nx * half, y1 - uy * extension - ny * half),
    ]
    zone = pcbnew.ZONE(board)
    zone.SetLayer(track.GetLayer())
    zone.SetNet(track.GetNet())
    zone.SetLocalClearance(pcbnew.FromMM(CLEARANCE_MM))
    zone.SetMinThickness(pcbnew.FromMM(0.15))
    zone.SetPadConnection(pcbnew.ZONE_CONNECTION_FULL)
    zone.SetAssignedPriority(priority)
    zone.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_ALWAYS)
    outline = zone.Outline()
    outline.NewOutline()
    for x, y in corners:
        outline.Append(pcbnew.FromMM(x), pcbnew.FromMM(y))
    board.Add(zone)


def copper_blockers(board: pcbnew.BOARD):
    layers = (pcbnew.F_Cu, pcbnew.In1_Cu, pcbnew.In2_Cu, pcbnew.B_Cu)
    blockers = []
    for item in board.GetTracks():
        if isinstance(item, pcbnew.PCB_VIA):
            blockers.append((item.GetNetCode(), item.GetEffectiveShape(), None))
        elif item.GetLayer() in layers:
            blockers.append((item.GetNetCode(), item.GetEffectiveShape(item.GetLayer()), item.GetLayer()))
    for footprint in board.GetFootprints():
        for pad in footprint.Pads():
            for layer in layers:
                if pad.IsOnLayer(layer):
                    blockers.append((pad.GetNetCode(), pad.GetEffectiveShape(layer), layer))
    return blockers


def safe_for_via(candidate, net_code: int, blockers) -> bool:
    radius = pcbnew.FromMM(VIA_DIAMETER_MM / 2 + CLEARANCE_MM)
    for blocker_net, shape, layer in blockers:
        # Same-net traces and pads may accept the via.  Existing drills still
        # need physical spacing regardless of electrical net.
        if blocker_net == net_code and layer is not None:
            continue
        if shape.Collide(candidate, radius):
            return False
    return True


def add_parallel_vias(board: pcbnew.BOARD) -> tuple[int, int]:
    blockers = copper_blockers(board)
    for zone in board.Zones():
        if zone.GetIsRuleArea() and zone.GetDoNotAllowVias():
            blockers.append((None, zone.Outline(), None))
    # A same-net via can bypass a Kelvin branch without causing a DRC short.
    # Treat those branches as obstacles for transfer vias regardless of net.
    for path in load_paths(board):
        for points in reservation_polygons(path):
            polygon = pcbnew.SHAPE_POLY_SET()
            polygon.NewOutline()
            for p in points:
                q = vector(p)
                polygon.Append(q.x, q.y)
            blockers.append((None, polygon, None))
    lands = [pad.GetEffectiveShape(layer) for fp in board.GetFootprints() for pad in fp.Pads()
             for layer in (pcbnew.F_Cu, pcbnew.B_Cu) if pad.IsOnLayer(layer)]
    outline = pcbnew.SHAPE_POLY_SET()
    if not board.GetBoardPolygonOutlines(outline, False, None, False, False):
        raise ValueError("Invalid board outline")
    boundary = outline.Outline(0)
    vertices = [xy(boundary.CPoint(i)) for i in range(boundary.PointCount())]
    originals = [
        item
        for item in board.GetTracks()
        if isinstance(item, pcbnew.PCB_VIA) and item.GetNetname() in POWER_NETS
    ]
    added = 0
    sparse = 0
    offsets = [
        (VIA_PITCH_MM, 0),
        (-VIA_PITCH_MM, 0),
        (0, VIA_PITCH_MM),
        (0, -VIA_PITCH_MM),
        (VIA_PITCH_MM, VIA_PITCH_MM),
        (-VIA_PITCH_MM, VIA_PITCH_MM),
        (VIA_PITCH_MM, -VIA_PITCH_MM),
        (-VIA_PITCH_MM, -VIA_PITCH_MM),
    ]
    for original in originals:
        origin = original.GetPosition()
        net = original.GetNet()
        # The ECO reuses verified copper, including existing parallel vias.
        # Count same-net neighbours before adding more to an existing group.
        made = min(3, sum(
            1 for other in board.GetTracks()
            if isinstance(other, pcbnew.PCB_VIA)
            and other.m_Uuid != original.m_Uuid
            and other.GetNetname() == original.GetNetname()
            and math.hypot(mm(other.GetPosition().x - origin.x), mm(other.GetPosition().y - origin.y)) <= 1.4
        ))
        if made >= 3:
            continue
        for dx, dy in offsets:
            candidate = point(mm(origin.x) + dx, mm(origin.y) + dy)
            # Extra transfer vias must not create unreviewed via-in-pad sites
            # or extend copper past the fabrication edge clearance.
            if any(land.Collide(candidate, pcbnew.FromMM(VIA_DIAMETER_MM / 2 + 0.10)) for land in lands):
                continue
            if not outline.Contains(candidate) or any(
                distance_to_segment(xy(candidate), a, z) < VIA_DIAMETER_MM / 2 + 0.20
                for a, z in zip(vertices, vertices[1:] + vertices[:1])
            ):
                continue
            if not safe_for_via(candidate, original.GetNetCode(), blockers):
                continue
            via = pcbnew.PCB_VIA(board)
            via.SetPosition(candidate)
            via.SetWidth(pcbnew.FromMM(VIA_DIAMETER_MM))
            via.SetDrill(pcbnew.FromMM(VIA_DRILL_MM))
            via.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
            via.SetNet(net)
            board.Add(via)
            blockers.append((via.GetNetCode(), via.GetEffectiveShape(), None))
            made += 1
            added += 1
            if made == 3:
                break
        if made < 2:
            sparse += 1
    return added, sparse


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("input", type=Path)
    parser.add_argument("output", type=Path)
    args = parser.parse_args()
    app = wx.App(False)
    board = pcbnew.LoadBoard(str(args.input))
    sense_paths = load_paths(board)
    tracks = [
        item
        for item in board.GetTracks()
        if not isinstance(item, pcbnew.PCB_VIA)
        and item.GetNetname() in POWER_NETS
        and not is_branch_track(item, sense_paths)
        and item.GetLayer() in (pcbnew.F_Cu, pcbnew.In1_Cu, pcbnew.In2_Cu, pcbnew.B_Cu)
    ]
    # KiCad requires intersecting zones to have distinct priorities, including
    # same-net corridors.  A unique priority keeps the generated board clean.
    added_corridors = 0
    for index, track in enumerate(tracks, start=100):
        if index in SKIP_CORRIDOR_PRIORITIES:
            continue
        add_corridor(board, track, index)
        added_corridors += 1

    added_vias, sparse_vias = add_parallel_vias(board)
    pcbnew.ZONE_FILLER(board).Fill(board.Zones())
    board.BuildConnectivity()
    args.output.parent.mkdir(parents=True, exist_ok=True)
    pcbnew.SaveBoard(str(args.output), board)
    print(
        {
            "power_corridors": added_corridors,
            "corridors_omitted_for_clean_fill": len(SKIP_CORRIDOR_PRIORITIES),
            "parallel_vias_added": added_vias,
            "transitions_with_fewer_than_3_new_vias": sparse_vias,
            "unconnected": board.GetConnectivity().GetUnconnectedCount(True),
            "output": str(args.output),
        }
    )


if __name__ == "__main__":
    main()