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/export-routing-interchange.py
#!/usr/bin/env python3
"""Export world-aligned pad geometry for Specctra routing without changing CAD placement."""
import argparse
import hashlib
import json
from pathlib import Path
import pcbnew
import wx
def pad_geometry(board):
return sorted(
(fp.GetReference(), pad.GetNumber(), pad.GetPosition().x,
pad.GetPosition().y, pad.GetOrientationDegrees(), pad.GetLayerSet().FmtHex(),
pad.GetNetname())
for fp in board.GetFootprints() for pad in fp.Pads()
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("board", type=Path)
parser.add_argument("output_directory", type=Path)
args = parser.parse_args()
app = wx.App(False)
board = pcbnew.LoadBoard(str(args.board.resolve()))
if board is None:
raise SystemExit("Cannot load source PCB")
args.output_directory.mkdir(parents=True, exist_ok=True)
held = []
references = set()
for index, fp in enumerate(board.GetFootprints()):
if not fp.GetReference():
fp.SetReference(f"ROUTING_HOLE_{index}")
if fp.GetReference() in references:
raise SystemExit(f"Duplicate reference: {fp.GetReference()}")
references.add(fp.GetReference())
before = pad_geometry(board)
for fp in board.GetFootprints():
pads = list(fp.Pads())
held.extend(pads)
geometry = [(p, p.GetPosition(), p.GetOrientationDegrees(), p.GetLayerSet()) for p in pads]
# The routing copy uses unique, unrotated package images. Bottom pads
# remain explicitly on B.Cu; their actual locations and shapes are kept.
fp.SetOrientationDegrees(0)
fp.SetLayer(pcbnew.F_Cu)
fp.SetFPID(pcbnew.LIB_ID("", fp.GetReference() + "_routing"))
for pad, position, angle, layers in geometry:
pad.SetPosition(position)
pad.SetOrientationDegrees(angle)
pad.SetLayerSet(layers)
if pad_geometry(board) != before:
raise SystemExit("Routing normalization changed pad geometry or net identity")
# DSN has no equivalent of a fill-only rule area. Exporting one as a
# general keepout incorrectly forbids signal tracks as well. Preserve it
# on the original PCB; explicit Kelvin routing guards are added separately.
pour_only = [z for z in board.Zones() if z.GetIsRuleArea()
and z.GetDoNotAllowZoneFills() and not z.GetDoNotAllowTracks()
and not z.GetDoNotAllowVias() and not z.GetDoNotAllowPads()
and not z.GetDoNotAllowFootprints()]
held.extend(pour_only)
for zone in pour_only:
board.Remove(zone)
pcb_path = args.output_directory / "routing-base.kicad_pcb"
dsn_path = args.output_directory / "routing-input.dsn"
pcbnew.SaveBoard(str(pcb_path.resolve()), board)
if not pcbnew.ExportSpecctraDSN(board, str(dsn_path.resolve())):
raise SystemExit("Specctra export failed")
report = {
"source": str(args.board),
"source_sha256": hashlib.sha256(args.board.read_bytes()).hexdigest(),
"pads_verified": len(before),
"pad_geometry_unchanged": True,
"pour_only_rules_retained_on_original_pcb": len(pour_only),
"bottom_rotation_compensation_required": False,
"import_method": "import-routing-copper.py; never import routing-copy placement",
}
(args.output_directory / "interchange.json").write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()