Files
train-map/converter/main.py
2026-02-01 22:36:56 +01:00

350 lines
9.2 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
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 = 5
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 = ""
FOOTPRINT_LED = "Dialight_587_1032_147F:587"
FOOTPRINT_MC = "DFR0654:MODULE_DFR0654"
# Mappings
TEXT_POS_MAPPING = {'above': Point(0, 2*TEXT_SPACING_MM), 'right': Point(TEXT_SPACING_MM, 0), 'below': Point(0, -2*TEXT_SPACING_MM), 'left': Point(-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_station_footprint(station):
fp = KFootprint(FOOTPRINT_LED, at=station.pos, angle=station.angle)
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 and station 2 in
Connects them on the PCB
"""
def connect_stations(board, station1, station2, fp1, fp2):
net = KNet(station1.id+2, f'{station1.name[0:3]}-{station2.name[0:3]}')
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.debug(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
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
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[[*TEXT_POS_MAPPING][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)
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)
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)
if prev_station != 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()
# Mapping to Global Variables
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()