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:
parent
4d1b573546
commit
00fb40fbf4
301
src/mckicad/utils/ses_apply.py
Normal file
301
src/mckicad/utils/ses_apply.py
Normal 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
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
140
tests/fixtures/arduino_mega_routed.ses
vendored
Normal 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
262
tests/test_ses_apply.py
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user