#!/bin/python import math import time import os import sys import argparse import logging from pycbnew.primitives.project import KProject from pycbnew.primitives.footprint import KFootprint, KFootprintProperty from pycbnew.primitives.net import KNet from pycbnew.utils.geometry import Point, Vector from pycbnew.composites.gr_line_path import KGrLinePath from pycbnew.primitives.gr_elements import ( KGrRect, KGrPoly, Stroke, KGrText, Font, KGrDimension, DimensionFormat, DimensionStyle, TextJustify, KGrLine, KTable, ) from pycbnew.primitives.segment import KSegment import xml.etree.ElementTree as ET # Global Variables set through argparse LOG_LEVEL = logging.INFO NO_POWER_NETS = False NO_TEXT = False NO_TRACES = False SCALE = 1.0 PCB_FILE = "trainmap.kicad_pcb" INPUT_XML = "" MC_POS = Point(0, 0) MARGIN = {"left": 10, "top": 5} TRACE_WIDTH_MM=0.5 CAP_DISTANCE_MM=5 TEXT_SPACING_MM=15 # Fonts TITLE_FONTFACE = "" FONTFACE = "" LINE_FONT = Font(size=(12, 12)) LINE_FONT.face = FONTFACE TITLE_FONT = Font(size=(28, 28)) TITLE_FONT.face = TITLE_FONTFACE # Footprints KFootprint.set_footprint_folders([f"{os.path.dirname(__file__)}/kicad_libs"]) FOOTPRINT_C = "Capacitor_SMD:C_0603_1608Metric" FOOTPRINT_LED = "Dialight_587_1032_147F:587" FOOTPRINT_MC = "DFR0654:MODULE_DFR0654" MC_PADS = { "GND": 30, "GND1": 4, "VCC": 32, "gpios": [4, 12, 13, 14, 15, 18, 19, 21, 22, 23, 25, 26, 34, 35, 2], } LED_PADS = {"GND": 0, "VCC": 1, "DI": 2, "DO": 3} # Special Nets NET_GND = KNet(1, "GND") NET_VCC = KNet(2, "5V") class Map: title: str lines: list width: int height: int def __init__(self, title): self.title = title self.lines = [] self.width = 0 self.height = 0 def add(self, line): self.lines.append(line) class Line: name: str color: str stations: list def __init__(self, name, color): self.name = name self.color = color self.stations = [] def __str__(self): str = self.name if self.color != None: str += f" (color: {self.color})" return str def add(self, station): self.stations.append(station) class Station: id: int name: str pos: Point angle: int text_pos: Point def __init__(self, id, name, x, y, angle=0, text_pos="right"): self.id = id self.name = name self.pos = Point(x * SCALE, y * SCALE) self.text_pos = text_pos self.angle = angle def __str__(self): return f"station: {self.name}, id: {self.id}, pos: ({str(self.pos.x)},{str(self.pos.y)}, angle: {self.angle} text_pos: {self.text_pos})" """ parses the xml with the map syntax: %station_name """ def parse_map(xml_file): root = ET.parse(xml_file) title_node = root.find("title") title="" if title_node != None: title=title_node.text map = Map(title) logging.debug(f"map title: {title}") width = 0 height = 0 id=0 for line in root.findall("line"): line_name = line.get("name") try: line_color = line.get("color") except Error: line_color = None line_parsed = Line(line_name, line_color) logging.debug(f"Parsing line: {line_parsed}") for station in line.findall("station"): id=id+1 name = station.text x = int(float(station.get("x"))) # search width if x > width: width = x y = int(float(station.get("y"))) # search height if y > height: height = y station_parsed = Station(id, name, x, y) logging.debug(station_parsed) line_parsed.add(station_parsed) map.add(line_parsed) map.width = width map.height = height logging.debug(f"done parsing xml. pcb width: { map.width}, height: {map.height}") return map """ Creates a capacity between LED's VCC and GND """ def create_capacity(station): cap_pos_x = CAP_DISTANCE_MM * math.sin(station.angle) cap_pos_y = CAP_DISTANCE_MM * math.cos(station.angle) cap_pos = Vector(cap_pos_x, cap_pos_y) fp = KFootprint(FOOTPRINT_C, at=station.pos + cap_pos, angle=station.angle) ref = fp.properties[KFootprintProperty.Type.Reference] ref.hide = 'yes' if not NO_POWER_NETS: fp.pads[0].net = NET_GND fp.pads[1].net = NET_VCC return fp def calculate_angle(station1, station2): vec = Vector(station2.pos.x, station2.pos.y) - Vector(station1.pos.x, station1.pos.y) angle_rad = vec.self_angle() return math.degrees(angle_rad) """ Creates the footprint for a station led adds led, text and C """ def create_station_footprint(station): fp = KFootprint(FOOTPRINT_LED, at=station.pos, angle=station.angle) ref = fp.properties[KFootprintProperty.Type.Reference] if NO_TEXT: ref.layer = 'User.Comments' ref.value = station.name text_pos_x = TEXT_SPACING_MM * math.cos(station.angle) text_pos_y = TEXT_SPACING_MM * math.sin(station.angle) ref.at = Vector(text_pos_x, text_pos_y) if not NO_POWER_NETS: fp.pads[LED_PADS["GND"]].net = NET_GND fp.pads[LED_PADS["VCC"]].net = NET_VCC return fp """ Creates a new net between station 1 out pad and station 2 in pad Connects them on the PCB """ def connect_stations(board, station1, station2, fp1, fp2): net = KNet(station1.id + 2, f"{station1.id}-{station2.id}") board.add(net) do = fp1.pads[LED_PADS["DO"]] di = fp2.pads[LED_PADS["DI"]] # connect the net do.net = net di.net = net if NO_TRACES: return # do the trace trace = KSegment(net=net, layer="F.Cu", point1=do.absolute_at, point2=di.absolute_at, width=TRACE_WIDTH_MM) board.add(trace) """ Connects the line to the MC """ def connect_line(board, mc, line, station_fp): if connect_line.gpio_ptr > len(MC_PADS["gpios"]): logging.critical( f"line {line.name} not connected! cannot have more than { len(MC_PADS['gpios'])} lines." ) return gpio = MC_PADS["gpios"][connect_line.gpio_ptr] logging.info(f"connecting line {line.name} to gpio {gpio}") net = KNet(1000 + gpio, f"{line}") board.add(net) mc.pads[gpio].net = net station_fp.pads[LED_PADS["DI"]].net = net connect_line.gpio_ptr += 1 connect_line.gpio_ptr = 0 """ Adds the main microcontroller """ def add_mc(board): mc = KFootprint(FOOTPRINT_MC, at=MC_POS) if not NO_POWER_NETS: mc.pads[MC_PADS["GND"]].net = NET_GND mc.pads[MC_PADS["VCC"]].net = NET_VCC board.add(mc) return mc """ adds labels for the lines to the next position in the TEXT_POS_MAPPING list from the position of the text label of the station (just a best estimate) """ def add_line_label(board, line, station): text_pos_x = station.pos.x + TEXT_SPACING_MM * math.sin(-0.25*station.angle) text_pos_y = station.pos.y + TEXT_SPACING_MM * math.cos(-0.25*station.angle) text_pos = Vector(text_pos_x, text_pos_y) text = KGrText( layer="F.SilkS", text=f"{line.name}", at=text_pos ) #text.font = LINE_FONT board.add(text) """ adds a board title top middle """ def add_title_label(board, map): if NO_TEXT: return logging.debug(f"add map title: {map.title}") text = KGrText( layer="F.SilkS", text=f"{map.title}", at=Point(map.width / 2, MARGIN["top"]) ) text.font = TITLE_FONT board.add(text) """ main pcb generation loop """ def generate_pcb(map): board = KProject(PCB_FILE) board.add(NET_GND) board.add(NET_VCC) add_title_label(board, map) mc = add_mc(board) for line in map.lines: prev_station = None prev_led = None if len(line.stations) == 0: continue for station in line.stations: if prev_station is not None: station.angle = calculate_angle(prev_station, station) else: station.angle = calculate_angle(station, line.stations[1]) logging.debug(f"angle between stations: {station.angle}") led = create_station_footprint(station) board.add(led) cap = create_capacity(station) board.add(cap) if prev_station is not None: connect_stations(board, prev_station, station, prev_led, led) else: logging.debug(f"add line label {line.name} to start of line") add_line_label(board, line, station) connect_line(board, mc, line, led) prev_station = station prev_led = led logging.debug(f"add line label {line.name} to end of line") add_line_label(board, line, prev_station) board.save() return board def main(): parse_arguments() logging.basicConfig( level=LOG_LEVEL, format="%(asctime)s - %(levelname)s - %(message)s", stream=sys.stdout, ) logging.info(f"creating {PCB_FILE} from {INPUT_XML}") start_time = time.perf_counter() map = parse_map(INPUT_XML) pcb = generate_pcb(map) end_time = time.perf_counter() elapsed = end_time - start_time logging.info(f"finished in {elapsed:.4f} seconds") def parse_arguments(): global SCALE, TEXT_SPACING_MM, NO_TEXT, NO_POWER_NETS, NO_TRACES, PCB_FILE, LOG_LEVEL, INPUT_XML parser = argparse.ArgumentParser( description="train map pcb generator", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) # Positional Argument parser.add_argument("input", help="input xml file") # Optional Arguments parser.add_argument("--scale", type=float, default=1.0, help="scaling of the pcb") parser.add_argument( "--text-space", type=int, default=5, help="spacing of the text from the center point of led (mm)", ) parser.add_argument( "--no-text", action="store_true", help="do not produce any text", ) parser.add_argument( "--no-power-nets", action="store_true", help="do not connect GND and VCC (easier visual debugging)", ) parser.add_argument( "--no-traces", action="store_true", help="do not implement traces between leds", ) parser.add_argument( "--output", "-o", type=str, default="trainmap.kicad_pcb", help="output file. should end in .kicad_pcb", ) parser.add_argument( "--verbose", "-v", action="count", default=0, help="verbose output (use -v, -vv, etc.)", ) args = parser.parse_args() INPUT_XML = args.input SCALE = args.scale TEXT_SPACING_MM = args.text_space NO_TEXT = args.no_text NO_POWER_NETS = args.no_power_nets NO_TRACES = args.no_traces PCB_FILE = args.output if args.verbose > 0: LOG_LEVEL = logging.DEBUG # Basic Validation if not PCB_FILE.endswith(".kicad_pcb"): print(f"Warning: Output file '{ PCB_FILE}' does not have .kicad_pcb extension.") if NO_TEXT: print("Mode: No text") if args.no_power_nets: print("Mode: Power nets disabled.") if NO_TRACES: print("Mode: No traces") if __name__ == "__main__": main()