Files
train-map/converter/main.py
2026-02-09 20:47:03 +01:00

438 lines
10 KiB
Python
Executable File

#!/bin/python
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,
)
import xml.etree.ElementTree as ET
# Constants / Global Variables set through argparse
SCALE = 1.0
TEXT_SPACING_MM = 12
NO_POWER_NETS = False
PCB_FILE = "trainmap.kicad_pcb"
LOG_LEVEL = logging.INFO
INPUT_XML = ""
MC_POS = Point(0, 0)
TITLE_FONTFACE = ""
FONTFACE = ""
MARGIN = {"left": 10, "top": 5}
# Fonts
LINE_FONT = Font(size=(16, 16))
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"
# Mappings
TEXT_POS_MAPPING = {
"above": Vector(0, TEXT_SPACING_MM),
"right": Vector(TEXT_SPACING_MM, 0),
"below": Vector(0, -TEXT_SPACING_MM),
"left": Vector(-TEXT_SPACING_MM, 0),
}
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:
<map>
<line name="%line_name%">
<station x="%x_coord" y="%y_coord">%station_name</station>
</line>
</map>
"""
def parse_map(xml_file):
root = ET.parse(xml_file)
title = root.find("title").text
map = Map(title)
logging.debug(f"map title: {title}")
width = 0
height = 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 = int(station.get("id"))
name = station.text
x = int(station.get("x"))
# search width
if x > width:
width = x
y = int(station.get("y"))
# search height
if y > height:
height = y
text_pos = station.get("textpos")
text_angle = int(station.get("angle"))
station_parsed = Station(id, name, x, y, text_angle, text_pos)
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
def create_capacity(station):
cap_pos_index = ([*TEXT_POS_MAPPING].index(station.text_pos) + 2) % len(
TEXT_POS_MAPPING
)
cap_pos = [*TEXT_POS_MAPPING.values()][cap_pos_index]
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
"""
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)
# text
ref = fp.properties[KFootprintProperty.Type.Reference]
ref.value = station.name
ref.angle = station.angle
ref.at = TEXT_POS_MAPPING[station.text_pos]
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)
fp1.pads[LED_PADS["DO"]].net = net
fp2.pads[LED_PADS["DI"]].net = net
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):
# get next position
text_pos_index = ([*TEXT_POS_MAPPING].index(station.text_pos) + 1) % len(
TEXT_POS_MAPPING
)
text_pos = [*TEXT_POS_MAPPING.values()][text_pos_index]
line_pos = Point(station.pos.x + 2 * text_pos.x,
station.pos.y + 2 * text_pos.y)
text = KGrText(
layer="F.SilkS", text=f"{line.name}", at=line_pos, angle=station.angle
)
text.font = LINE_FONT
board.add(text)
"""
adds a board title top middle
"""
def add_title_label(board, map):
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
for station in line.stations:
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_POWER_NETS, 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-power-nets",
action="store_true",
help="do not connect (easier visual debugging)",
)
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_POWER_NETS = args.no_power_nets
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 args.no_power_nets:
print("Mode: Power nets disabled.")
if __name__ == "__main__":
main()