Add native headless SES to .kicad_pcb applier

KiCad 10's kicad-cli has no specctra subcommands, kipy has no Specctra
support, and pcbnew.ImportSpecctraSES needs a GUI display. This adds a
pure-Python applier that injects FreeRouting's routed wires and vias
directly into a .kicad_pcb, reusing the existing sexp_tree engine.

Reads the (resolution unit value) scope for scaling, applies the
Specctra Y-up to KiCad Y-down flip, maps SES net names to board net
numbers, and splices (segment)/(via) nodes in before the board's
closing paren so the rest of the file is preserved byte-for-byte.

Fixtures are a real Arduino_Mega template board and a FreeRouting-routed
SES; the integration test applies 23 segments across GND and +5V.
This commit is contained in:
Ryan Malloy 2026-07-12 11:10:25 -06:00
parent 4d1b573546
commit 00fb40fbf4
4 changed files with 4743 additions and 0 deletions

View File

@ -0,0 +1,301 @@
"""Native Specctra SES -> .kicad_pcb applier.
KiCad 10's ``kicad-cli`` has no ``specctra-dsn``/``specctra-ses`` subcommands,
the ``kipy`` IPC API has no Specctra support, and ``pcbnew.ImportSpecctraSES``
requires a GUI display. This module applies FreeRouting's SES output to a
``.kicad_pcb`` directly, fully headless: it reads the routed wires/vias from
the SES, transforms their coordinates into KiCad millimetres, and injects
``(segment ...)`` / ``(via ...)`` nodes as top-level board items.
The rest of the board is preserved byte-for-byte; only the new track items are
spliced in just before the board's closing paren.
Coordinate model
----------------
SES/Specctra units come from the ``(resolution <unit> <value>)`` in scope.
For ``(resolution um 10)`` one SES unit is 0.1 um, so ``mm = ses_unit / 10000``.
General: ``mm = ses_unit * unit_in_mm / value``. Specctra is Y-up, KiCad is
Y-down, so ``x_mm = ses_x * scale`` and ``y_mm = -ses_y * scale``.
"""
from __future__ import annotations
import logging
import re
import uuid
from .sexp_tree import (
SexpNode,
find,
find_one,
make_atom,
make_node,
make_string,
parse,
parse_file,
serialize,
)
logger = logging.getLogger(__name__)
# Millimetres per one unit of each Specctra length unit.
_UNIT_IN_MM = {
"um": 0.001,
"mm": 1.0,
"mil": 0.0254,
"inch": 25.4,
}
# Fallback via geometry (mm) when neither the padstack name nor its shape
# gives usable dimensions. 0.6 mm pad / 0.3 mm drill is KiCad's default via.
_DEFAULT_VIA_SIZE_MM = 0.6
_DEFAULT_VIA_DRILL_MM = 0.3
# Pulls "600:300" (pad:drill) and a trailing unit out of padstack names such as
# "Via[0-1]_600:300_um".
_PADSTACK_DIMS_RE = re.compile(r"(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)_(um|mm|mil|inch)")
class SesApplyError(Exception):
"""Raised when an SES file cannot be applied to a board."""
def resolution_scale(unit: str, value: float) -> float:
"""Return millimetres-per-SES-unit for a ``(resolution unit value)`` scope."""
if unit not in _UNIT_IN_MM:
raise SesApplyError(f"Unsupported SES resolution unit: {unit!r}")
if value == 0:
raise SesApplyError("SES resolution value must be non-zero")
return _UNIT_IN_MM[unit] / value
def _fmt(mm: float) -> str:
"""Format a millimetre value the way KiCad writes coordinates."""
s = f"{mm:.6f}".rstrip("0").rstrip(".")
if s in ("", "-", "-0"):
return "0"
return s
def _transform(x: float, y: float, scale: float) -> tuple[str, str]:
"""SES (x, y) -> KiCad (x_mm, y_mm) strings, applying the Y-axis flip."""
return _fmt(x * scale), _fmt(-y * scale)
def _find_routes(session: SexpNode) -> SexpNode:
routes = find_one(session, "routes")
if routes is None:
raise SesApplyError("SES has no (routes ...) scope")
return routes
def _read_scale(routes: SexpNode) -> float:
res = find_one(routes, "resolution")
if res is None or len(res.values) < 2:
raise SesApplyError("SES (routes ...) has no (resolution unit value)")
unit = res.values[0]
try:
value = float(res.values[1])
except ValueError as exc: # pragma: no cover - malformed input
raise SesApplyError(f"Bad SES resolution value: {res.values[1]!r}") from exc
return resolution_scale(unit, value)
def build_net_map(board_root: SexpNode) -> dict[str, int]:
"""Map net name -> net number from the board's top-level ``(net N "name")``."""
net_map: dict[str, int] = {}
for net in find(board_root, "net"):
# (net <number> "<name>") -- number is values[0], name is values[1]
if len(net.values) >= 2:
try:
number = int(net.values[0])
except ValueError:
continue
net_map[net.values[1]] = number
return net_map
def _padstack_dims(name: str, shape_diameter_mm: float | None) -> tuple[float, float]:
"""Derive (size_mm, drill_mm) for a via from its padstack.
Prefers the ``<pad>:<drill>_<unit>`` encoding in the padstack name (which
carries both pad and drill), then falls back to the shape diameter for the
pad size, then to KiCad's default via dimensions.
"""
match = _PADSTACK_DIMS_RE.search(name)
if match:
unit = match.group(3)
factor = _UNIT_IN_MM[unit]
return float(match.group(1)) * factor, float(match.group(2)) * factor
size = shape_diameter_mm if shape_diameter_mm else _DEFAULT_VIA_SIZE_MM
return size, _DEFAULT_VIA_DRILL_MM
def _padstack_shape_diameter(padstack: SexpNode, scale: float) -> float | None:
"""Return the pad diameter (mm) from a padstack's first ``(circle ...)`` shape."""
for shape in find(padstack, "shape"):
circle = find_one(shape, "circle")
# (circle <layer> <diameter> [x y]) -- diameter is values[1]
if circle is not None and len(circle.values) >= 2:
try:
return float(circle.values[1]) * scale
except ValueError:
continue
return None
def _build_padstack_table(routes: SexpNode, scale: float) -> dict[str, tuple[float, float]]:
table: dict[str, tuple[float, float]] = {}
library = find_one(routes, "library_out")
if library is None:
return table
for padstack in find(library, "padstack"):
if not padstack.values:
continue
name = padstack.values[0]
if name in table:
continue
diameter = _padstack_shape_diameter(padstack, scale)
table[name] = _padstack_dims(name, diameter)
return table
def _segment_node(
p1: tuple[str, str], p2: tuple[str, str], width: str, layer: str, net: int
) -> SexpNode:
return make_node(
"segment",
children=[
make_atom("start", p1[0], p1[1]),
make_atom("end", p2[0], p2[1]),
make_atom("width", width),
make_string("layer", layer),
make_atom("net", str(net)),
make_string("uuid", str(uuid.uuid4())),
],
)
def _via_node(
at: tuple[str, str], size: str, drill: str, net: int, layers: tuple[str, str]
) -> SexpNode:
return make_node(
"via",
children=[
make_atom("at", at[0], at[1]),
make_atom("size", size),
make_atom("drill", drill),
make_string("layers", layers[0], layers[1]),
make_atom("net", str(net)),
make_string("uuid", str(uuid.uuid4())),
],
)
def _wire_segments(wire: SexpNode, scale: float, net: int) -> tuple[list[SexpNode], str | None]:
"""Turn one ``(wire (path L W x1 y1 ...))`` into KiCad segment nodes."""
path = find_one(wire, "path")
if path is None or len(path.values) < 4:
return [], None
layer = path.values[0]
width = _fmt(float(path.values[1]) * scale)
coords = [float(v) for v in path.values[2:]]
if len(coords) % 2 != 0:
logger.warning("Odd coordinate count in wire path; dropping trailing value")
coords = coords[:-1]
points = [
_transform(coords[i], coords[i + 1], scale) for i in range(0, len(coords), 2)
]
segments = [
_segment_node(points[i], points[i + 1], width, layer, net)
for i in range(len(points) - 1)
]
return segments, layer
def apply_ses_to_board(pcb_path: str, ses_path: str, out_path: str) -> dict:
"""Apply a routed SES file to a KiCad board, writing the result to ``out_path``.
Returns a dict with ``segments_added``, ``vias_added``, ``nets_routed``,
and ``unknown_nets`` (SES nets with no matching board net).
"""
with open(pcb_path, encoding="utf-8") as f:
board_text = f.read()
board_root = parse(board_text)
net_map = build_net_map(board_root)
session = parse_file(ses_path)
routes = _find_routes(session)
scale = _read_scale(routes)
padstacks = _build_padstack_table(routes, scale)
network = find_one(routes, "network_out")
if network is None:
raise SesApplyError("SES (routes ...) has no (network_out ...)")
new_nodes: list[SexpNode] = []
segments_added = 0
vias_added = 0
nets_routed = 0
unknown_nets: list[str] = []
for net in find(network, "net"):
if not net.values:
continue
net_name = net.values[0]
if net_name not in net_map:
unknown_nets.append(net_name)
logger.warning("SES net %r has no matching board net; skipping", net_name)
continue
net_number = net_map[net_name]
net_had_track = False
for wire in find(net, "wire"):
segments, _layer = _wire_segments(wire, scale, net_number)
if segments:
new_nodes.extend(segments)
segments_added += len(segments)
net_had_track = True
for via in find(net, "via"):
# (via <padstack> x y)
if len(via.values) < 3:
continue
padstack = via.values[0]
size_mm, drill_mm = padstacks.get(
padstack, (_DEFAULT_VIA_SIZE_MM, _DEFAULT_VIA_DRILL_MM)
)
at = _transform(float(via.values[1]), float(via.values[2]), scale)
new_nodes.append(
_via_node(at, _fmt(size_mm), _fmt(drill_mm), net_number, ("F.Cu", "B.Cu"))
)
vias_added += 1
net_had_track = True
if net_had_track:
nets_routed += 1
_write_board(board_text, board_root, new_nodes, out_path)
return {
"segments_added": segments_added,
"vias_added": vias_added,
"nets_routed": nets_routed,
"unknown_nets": unknown_nets,
"out_path": out_path,
}
def _write_board(
board_text: str, board_root: SexpNode, new_nodes: list[SexpNode], out_path: str
) -> None:
"""Splice serialized track nodes in just before the board's closing paren."""
if new_nodes:
block = "".join(f"\t{serialize(node, indent=1)}\n" for node in new_nodes)
# board_root.span = (0, offset-after-closing-paren); the ')' sits at span[1]-1.
insert_pos = board_root.span[1] - 1
out_text = board_text[:insert_pos] + block + board_text[insert_pos:]
else:
out_text = board_text
with open(out_path, "w", encoding="utf-8") as f:
f.write(out_text)

4040
tests/fixtures/arduino_mega.kicad_pcb vendored Normal file

File diff suppressed because it is too large Load Diff

140
tests/fixtures/arduino_mega_routed.ses vendored Normal file
View File

@ -0,0 +1,140 @@
(session oracle
(base_design oracle)
(placement
(resolution um 10)
(component "Connector_PinSocket_2.54mm:PinSocket_2x18_P2.54mm_Vertical"
(place J7 1939800 -923800 front 180)
)
(component "Connector_PinSocket_2.54mm:PinSocket_1x08_P2.54mm_Vertical"
(place J1 1279400 -974600 front 90)
(place J3 1508000 -974600 front 90)
(place J5 1736600 -974600 front 90)
(place J4 1457200 -492000 front 90)
(place J6 1685800 -492000 front 90)
)
(component "Connector_PinSocket_2.54mm:PinSocket_1x10_P2.54mm_Vertical"
(place J2 1187960 -492000 front 90)
)
(component "Arduino_MountingHole:MountingHole_3.2mm"
(place MH6 1965200 -974600 front 0)
(place MH1 1152400 -492000 front 0)
(place MH2 1139700 -974600 front 0)
(place MH3 1660400 -644400 front 0)
(place MH4 1660400 -923800 front 0)
(place MH5 1901700 -492000 front 0)
)
)
(was_is
)
(routes
(resolution um 10)
(parser
(host_cad "KiCad's Pcbnew")
(host_version "10.0.4")
)
(library_out
(padstack "Via[0-1]_600:300_um"
(shape
(circle F.Cu 6000 0 0)
)
(shape
(circle B.Cu 6000 0 0)
)
(attach off)
)
(padstack "Via[0-1]_600:300_um"
(shape
(circle F.Cu 6000 0 0)
)
(shape
(circle B.Cu 6000 0 0)
)
(attach off)
)
)
(network_out
(net GND
(wire
(path F.Cu 2000
1965200 -923800
1939800 -923800
)
)
(wire
(path F.Cu 2000
1939800 -923800
1928283 -923800
)
)
(wire
(path F.Cu 2000
1431800 -974600
1443317 -974600
)
)
(wire
(path F.Cu 2000
1443317 -974600
1443317 -971720
1451954 -963083
1889000 -963083
1928283 -923800
)
)
(wire
(path F.Cu 2000
1431800 -974600
1406400 -974600
)
)
(wire
(path B.Cu 2000
1406400 -974600
1406400 -963083
)
)
(wire
(path B.Cu 2000
1264160 -492000
1264160 -820843
1406400 -963083
)
)
)
(net +5V
(wire
(path F.Cu 2000
1965200 -492000
1939800 -492000
)
)
(wire
(path B.Cu 2000
1939800 -492000
1928283 -492000
)
)
(wire
(path B.Cu 2000
1381000 -974600
1392517 -974600
)
)
(wire
(path B.Cu 2000
1392517 -974600
1392517 -977479
1401428 -986390
1411199 -986390
1419100 -978489
1419100 -970794
1651108 -738786
1686270 -738786
1928283 -496773
1928283 -492000
)
)
)
)
)
)

262
tests/test_ses_apply.py Normal file
View File

@ -0,0 +1,262 @@
"""Tests for the native SES -> .kicad_pcb applier."""
from pathlib import Path
import pytest
from mckicad.utils.ses_apply import (
SesApplyError,
_fmt,
_padstack_dims,
_transform,
apply_ses_to_board,
build_net_map,
resolution_scale,
)
from mckicad.utils.sexp_tree import find, parse, parse_file
FIXTURES = Path(__file__).parent / "fixtures"
BOARD = FIXTURES / "arduino_mega.kicad_pcb"
ROUTED_SES = FIXTURES / "arduino_mega_routed.ses"
# A hand-built SES with a single-segment wire and one via, in um/10 units.
SMALL_SES = """\
(session small
(routes
(resolution um 10)
(library_out
(padstack "Via[0-1]_600:300_um"
(shape (circle F.Cu 6000 0 0))
(shape (circle B.Cu 6000 0 0))
(attach off)
)
)
(network_out
(net GND
(wire
(path F.Cu 2000
1965200 -923800
1939800 -923800
)
)
(via "Via[0-1]_600:300_um" 1928283 -923800)
)
(net NO_SUCH_NET
(wire
(path B.Cu 2000
100000 -100000
200000 -100000
)
)
)
)
)
)
"""
# --------------------------------------------------------------------------
# Resolution parsing / scaling
# --------------------------------------------------------------------------
@pytest.mark.unit
def test_resolution_scale_um_10():
# (resolution um 10): 1 SES unit = 0.1 um = 1/10000 mm
assert resolution_scale("um", 10) == pytest.approx(1e-4)
@pytest.mark.unit
def test_resolution_scale_units():
assert resolution_scale("mm", 1) == pytest.approx(1.0)
assert resolution_scale("um", 1) == pytest.approx(0.001)
assert resolution_scale("mil", 1) == pytest.approx(0.0254)
assert resolution_scale("inch", 1) == pytest.approx(25.4)
@pytest.mark.unit
def test_resolution_scale_rejects_bad_unit():
with pytest.raises(SesApplyError):
resolution_scale("furlong", 10)
@pytest.mark.unit
def test_resolution_scale_rejects_zero():
with pytest.raises(SesApplyError):
resolution_scale("um", 0)
# --------------------------------------------------------------------------
# Coordinate transform (the de-risked J7 anchor)
# --------------------------------------------------------------------------
@pytest.mark.unit
def test_transform_j7_anchor():
# SES (place J7 1939800 -923800) <-> board (at 193.98 92.38)
scale = resolution_scale("um", 10)
x, y = _transform(1939800, -923800, scale)
assert x == "193.98"
assert y == "92.38"
@pytest.mark.unit
def test_transform_y_axis_flip():
scale = resolution_scale("um", 10)
# Positive SES y flips to negative KiCad y.
_, y = _transform(0, 100000, scale)
assert y == "-10"
# Origin stays clean (no "-0").
assert _transform(0, 0, scale) == ("0", "0")
@pytest.mark.unit
def test_fmt_trims_trailing_zeros():
assert _fmt(0.2) == "0.2"
assert _fmt(196.52) == "196.52"
assert _fmt(-0.0) == "0"
# --------------------------------------------------------------------------
# Net-name -> number mapping
# --------------------------------------------------------------------------
@pytest.mark.unit
def test_build_net_map():
root = parse_file(str(BOARD))
net_map = build_net_map(root)
assert net_map["GND"] == 1
assert net_map["+5V"] == 34
assert net_map["VCC"] == 76
# The empty net 0 is still present.
assert net_map[""] == 0
# --------------------------------------------------------------------------
# Padstack -> via geometry
# --------------------------------------------------------------------------
@pytest.mark.unit
def test_padstack_dims_from_name():
# "600:300_um" -> 0.6 mm pad, 0.3 mm drill
size, drill = _padstack_dims("Via[0-1]_600:300_um", shape_diameter_mm=0.6)
assert size == pytest.approx(0.6)
assert drill == pytest.approx(0.3)
@pytest.mark.unit
def test_padstack_dims_fallback_to_shape_and_default():
size, drill = _padstack_dims("MysteryPad", shape_diameter_mm=0.8)
assert size == pytest.approx(0.8)
assert drill == pytest.approx(0.3) # default drill
size, drill = _padstack_dims("MysteryPad", shape_diameter_mm=None)
assert size == pytest.approx(0.6) # default size
assert drill == pytest.approx(0.3)
# --------------------------------------------------------------------------
# Small hand-built SES -> expected segment/via s-expr
# --------------------------------------------------------------------------
@pytest.mark.unit
def test_small_ses_emits_expected_segment_and_via(tmp_path):
ses = tmp_path / "small.ses"
ses.write_text(SMALL_SES)
out = tmp_path / "out.kicad_pcb"
result = apply_ses_to_board(str(BOARD), str(ses), str(out))
# One GND wire (1 segment) + one via; NO_SUCH_NET skipped as unknown.
assert result["segments_added"] == 1
assert result["vias_added"] == 1
assert result["nets_routed"] == 1
assert result["unknown_nets"] == ["NO_SUCH_NET"]
root = parse_file(str(out))
segments = find(root, "segment")
vias = find(root, "via")
assert len(segments) == 1
assert len(vias) == 1
seg = segments[0]
def child_vals(node, tag):
for c in node.children:
if c.tag == tag:
return c.values
return None
assert child_vals(seg, "start") == ["196.52", "92.38"]
assert child_vals(seg, "end") == ["193.98", "92.38"]
assert child_vals(seg, "width") == ["0.2"]
assert child_vals(seg, "layer") == ["F.Cu"]
assert child_vals(seg, "net") == ["1"] # GND
via = vias[0]
assert child_vals(via, "at") == ["192.8283", "92.38"]
assert child_vals(via, "size") == ["0.6"]
assert child_vals(via, "drill") == ["0.3"]
assert child_vals(via, "layers") == ["F.Cu", "B.Cu"]
assert child_vals(via, "net") == ["1"]
@pytest.mark.unit
def test_apply_preserves_original_board_bytes(tmp_path):
ses = tmp_path / "small.ses"
ses.write_text(SMALL_SES)
out = tmp_path / "out.kicad_pcb"
apply_ses_to_board(str(BOARD), str(ses), str(out))
original = BOARD.read_text()
produced = out.read_text()
# Everything up to the injected block is untouched.
insert = produced.index("\t(segment")
assert produced[:insert] == original[: len(produced[:insert])]
# The board still closes cleanly.
assert produced.rstrip().endswith(")")
# --------------------------------------------------------------------------
# Integration: real routed SES applied to real board
# --------------------------------------------------------------------------
@pytest.mark.integration
def test_apply_real_routed_ses(tmp_path):
out = tmp_path / "routed.kicad_pcb"
result = apply_ses_to_board(str(BOARD), str(ROUTED_SES), str(out))
# The oracle SES routes GND (11 segments) and +5V (12 segments), no vias.
assert result["segments_added"] == 23
assert result["vias_added"] == 0
assert result["nets_routed"] == 2
assert result["unknown_nets"] == []
assert out.exists()
@pytest.mark.integration
def test_applied_segments_reference_valid_nets(tmp_path):
out = tmp_path / "routed.kicad_pcb"
apply_ses_to_board(str(BOARD), str(ROUTED_SES), str(out))
root = parse_file(str(out))
net_numbers = {int(n.values[0]) for n in find(root, "net") if n.values}
segments = find(root, "segment")
assert len(segments) > 0
for seg in segments:
net_child = next((c for c in seg.children if c.tag == "net"), None)
assert net_child is not None
assert int(net_child.values[0]) in net_numbers
@pytest.mark.integration
def test_applied_board_reparses(tmp_path):
out = tmp_path / "routed.kicad_pcb"
apply_ses_to_board(str(BOARD), str(ROUTED_SES), str(out))
# The produced file must still be a single well-formed s-expression.
root = parse(out.read_text())
assert root.tag == "kicad_pcb"