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-r03-dsn.py

#!/usr/bin/env python3
"""Apply the release routing rules to a KiCad-exported Specctra DSN."""

from __future__ import annotations

from pathlib import Path
import re
import sys
import textwrap


POWER_NETS = (
    "PD_VBUS",
    "EFUSE_IN",
    "VMOTOR",
    "MOTOR_A1",
    "MOTOR_A2",
    "MOTOR_B1",
    "MOTOR_B2",
    "SENSE_A",
    "SENSE_B",
    "BRAKE_RETURN",
)


def wrapped_names(names: list[str], indent: str = "      ") -> str:
    return textwrap.fill(
        " ".join(names),
        width=94,
        initial_indent=indent,
        subsequent_indent=indent,
    ).lstrip()


def main() -> None:
    if len(sys.argv) != 3:
        raise SystemExit("usage: prepare-r03-dsn.py INPUT.dsn OUTPUT.dsn")
    source = Path(sys.argv[1])
    output = Path(sys.argv[2])
    data = source.read_text()

    # circuit-json-to-kicad writes bottom-side footprints with a 180 degree
    # placement offset. KiCad applies that offset while mirroring the package,
    # but Specctra/freerouting mirrors it a second time and targets the opposite
    # pads. Compensate only in the routing interchange file; the KiCad board and
    # component rotations remain unchanged.
    bottom_placements = 0

    def compensate_bottom_rotation(match: re.Match[str]) -> str:
        nonlocal bottom_placements
        bottom_placements += 1
        angle = (float(match.group("angle")) + 180.0) % 360.0
        angle_text = str(int(angle)) if angle.is_integer() else str(angle)
        return f'{match.group("prefix")}{angle_text}'

    data = re.sub(
        r'(?P<prefix>\(place\s+[^\s]+\s+[-.0-9]+\s+[-.0-9]+\s+back\s+)'
        r'(?P<angle>[-.0-9]+)',
        compensate_bottom_rotation,
        data,
    )
    if bottom_placements == 0:
        raise SystemExit("no bottom-side placements found for Specctra compensation")

    # The package-specific SMD spacing remains governed by pad geometry;
    # routed copper uses a 0.16 mm target where the router is in control.
    data, replaced = re.subn(
        r"\(clearance (?:90|100|200)\)\n      \(clearance (?:22\.5|25|50) \(type smd_smd\)\)",
        "(clearance 160)\n      (clearance 22.5 (type smd_smd))",
        data,
        count=1,
    )
    if replaced != 1:
        raise SystemExit("board-level routing rule not found")

    class_re = re.compile(
        r"    \(class kicad_default (?P<names>.*?)"
        r"(?P<body>\n      \(circuit\n        \(use_via .*?\n      \)\n"
        r"      \(rule\n        \(width (?:150|200)\)\n        \(clearance (?:90|100|200)\)\n      \)\n    \))",
        re.S,
    )
    match = class_re.search(data)
    if not match:
        raise SystemExit("kicad_default class not found")

    names = re.findall(r"[A-Za-z0-9_]+", match.group("names"))
    missing = sorted(set(POWER_NETS) - set(names))
    if missing:
        raise SystemExit(f"power nets missing from default class: {missing}")
    default_names = [name for name in names if name not in POWER_NETS]
    default_body = re.sub(r"\(width (?:150|200)\)", "(width 150)", match.group("body"))
    default_body = re.sub(r"\(clearance (?:90|100|200)\)", "(clearance 160)", default_body)
    default_class = (
        "    (class kicad_default "
        + wrapped_names(default_names)
        + default_body
    )
    power_class = (
        "\n    (class POWER_2OZ "
        + " ".join(POWER_NETS)
        + "\n      (circuit\n        (use_via \"Via[0-3]_600:300_um\")\n      )"
        # Dense QFN and PowerPAK pins need a short neck-down. The release
        # board reinforces these 0.50 mm connections with broad copper zones
        # and via arrays; forcing 2 mm uniformly cannot enter the packages.
        + "\n      (rule\n        (width 500)\n        (clearance 200)\n      )\n    )"
    )
    data = data[: match.start()] + default_class + power_class + data[match.end() :]
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(data)
    print(
        f"wrote {output} with {len(default_names)} signal nets, "
        f"{len(POWER_NETS)} power nets and {bottom_placements} corrected bottom placements"
    )


if __name__ == "__main__":
    main()