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/clean-silkscreen.py

#!/usr/bin/env python3
"""Maintain silk geometry without hiding references or changing copper."""

from __future__ import annotations

import argparse
import math
import re
from pathlib import Path

import pcbnew
import wx


def clip_passive_outlines(board, footprints):
    """Trim passive outline strokes where solder lands or other markings occupy the ink area."""
    obstacles = []
    candidates = []
    held = []

    def remember(reference, item, layer=None):
        shape = item.GetEffectiveShape(layer) if layer is not None else item.GetEffectiveShape()
        obstacles.append((reference, item.GetBoundingBox(), shape))

    for footprint in footprints:
        reference = footprint.GetReference()
        for pad in footprint.Pads():
            held.append(pad)
            if pad.IsOnLayer(pcbnew.F_Cu):
                remember(None, pad, pcbnew.F_Cu)
        for graphic in footprint.GraphicalItems():
            held.append(graphic)
            if graphic.GetLayer() != pcbnew.F_SilkS:
                continue
            if (re.fullmatch(r"[RC]\d+", reference)
                    and isinstance(graphic, pcbnew.PCB_SHAPE)
                    and graphic.GetShape() == pcbnew.SHAPE_T_SEGMENT):
                candidates.append((footprint, graphic))
            else:
                remember(reference, graphic)
    for drawing in board.GetDrawings():
        held.append(drawing)
        if drawing.GetLayer() == pcbnew.F_SilkS:
            remember(None, drawing)

    clipped = []
    for footprint, graphic in sorted(candidates, key=lambda pair: (pair[0].GetReference(), pair[1].GetStart().x, pair[1].GetStart().y)):
        start, end = graphic.GetStart(), graphic.GetEnd()
        length = math.hypot(end.x - start.x, end.y - start.y)
        steps = max(1, math.ceil(length / pcbnew.FromMM(0.01)))
        radius = int(graphic.GetWidth() / 2 + pcbnew.FromMM(0.12))
        bounds = graphic.GetBoundingBox()
        bounds.Inflate(radius)
        nearby = [shape for reference, box, shape in obstacles
                  if reference != footprint.GetReference() and bounds.Intersects(box)]

        def position(index):
            return pcbnew.VECTOR2I(round(start.x + (end.x - start.x) * index / steps),
                                 round(start.y + (end.y - start.y) * index / steps))

        # A 10 um sampling interval and 120 um clearance leave a margin over
        # the 100 um silkscreen rule; the result still requires native DRC.
        clear = [not any(shape.Collide(position(index), radius) for shape in nearby)
                 for index in range(steps + 1)]
        if all(clear):
            remember(footprint.GetReference(), graphic)
            continue
        runs, first = [], None
        for index, available in enumerate(clear + [False]):
            if available and first is None:
                first = index
            if not available and first is not None:
                if (index - 1 - first) * length / steps >= pcbnew.FromMM(0.2):
                    runs.append((first, index - 1))
                first = None
        footprint.Remove(graphic)
        for first, last in runs:
            segment = pcbnew.PCB_SHAPE(footprint)
            segment.SetShape(pcbnew.SHAPE_T_SEGMENT)
            segment.SetLayer(pcbnew.F_SilkS)
            segment.SetWidth(graphic.GetWidth())
            segment.SetStart(position(first))
            segment.SetEnd(position(last))
            footprint.Add(segment)
            held.append(segment)
            remember(footprint.GetReference(), segment)
        clipped.append({"reference": footprint.GetReference(), "retained_segments": len(runs)})
    # Keep SWIG-owned shape and item wrappers alive through SaveBoard.
    return clipped, (held, obstacles, candidates)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("board", type=Path)
    parser.add_argument("--clip-passive-outlines", action="store_true",
                        help="Trim only R/C outline strokes that collide with solder lands or adjacent markings")
    args = parser.parse_args()

    app = wx.App(False)
    board = pcbnew.LoadBoard(str(args.board))
    footprints = list(board.GetFootprints())
    clipped, clipping_objects = clip_passive_outlines(board, footprints) if args.clip_passive_outlines else ([], None)
    settings = board.GetDesignSettings()
    settings.m_AllowSoldermaskBridgesInFPs = True
    settings.m_TrackMinWidth = pcbnew.FromMM(0.09)
    settings.m_ViasMinSize = pcbnew.FromMM(0.50)
    settings.m_MinThroughDrill = pcbnew.FromMM(0.30)
    settings.m_CopperEdgeClearance = pcbnew.FromMM(0.20)
    # USB4105's manufacturer footprint has 0.145 mm between the locating
    # NPTH and adjacent copper.  The value below covers that fixed geometry;
    # ordinary routing still follows the 0.09 mm trace/space and 0.20 mm edge
    # rules recorded in board-standards.json.
    settings.m_HoleClearance = pcbnew.FromMM(0.13)
    settings.m_MinSilkTextHeight = pcbnew.FromMM(0.80)
    netclass = settings.m_NetSettings.GetDefaultNetclass()
    netclass.SetClearance(pcbnew.FromMM(0.09))
    netclass.SetTrackWidth(pcbnew.FromMM(0.15))
    netclass.SetViaDiameter(pcbnew.FromMM(0.50))
    netclass.SetViaDrill(pcbnew.FromMM(0.30))
    removed = []
    updated = []
    resized = []
    embedded_footprints = 0
    for footprint in footprints:
        fpid = footprint.GetFPID()
        # tscircuit exports fully embedded custom footprints, not references to
        # an installed external library.  Clear the stale library nickname so
        # KiCad validates the embedded geometry itself.
        if fpid.GetLibNickname():
            footprint.SetFPID(pcbnew.LIB_ID("", fpid.GetLibItemName()))
            embedded_footprints += 1
        for field in footprint.GetFields():
            # Keep the footprint's native reference size and position.
            if field.GetText() == footprint.GetReference():
                continue
            if pcbnew.ToMM(field.GetTextHeight()) < 0.8:
                field.SetTextHeight(pcbnew.FromMM(0.8))
                resized.append(field.GetText())
    for drawing in list(board.GetDrawings()):
        if not isinstance(drawing, pcbnew.PCB_TEXT) or drawing.GetLayer() not in (pcbnew.F_SilkS, pcbnew.B_SilkS):
            continue
        text = drawing.GetText().strip()
        if drawing.GetLayer() == pcbnew.F_SilkS and text.startswith("PD1180-EPR"):
            drawing.SetText("PD1180-EPR — NEMA 34 Smart Motor-Mounted")
            updated.append(text)
        elif drawing.GetLayer() == pcbnew.F_SilkS and text == "48V EPR CONTROLLER":
            drawing.SetText("Stepper Controller with USB-C PD 3.1 EPR · r0.4 ECO")
            updated.append(text)
        if pcbnew.ToMM(drawing.GetTextHeight()) < 0.8:
            drawing.SetTextHeight(pcbnew.FromMM(0.8))
            resized.append(text)
    pcbnew.SaveBoard(str(args.board), board)
    print({
        "passive_references_removed": len(removed),
        "version_labels_updated": len(updated),
        "silkscreen_text_resized": len(resized),
        "same_footprint_mask_bridges_allowed": True,
        "embedded_footprint_ids_normalized": embedded_footprints,
        "minimum_trace_space_mm": 0.09,
        "usb_connector_npth_clearance_mm": 0.13,
        "clipped_passive_outline_segments": clipped,
    })


if __name__ == "__main__":
    main()