Implement Specctra SES session-file writer
Ports the write path of io/specctra/SesWriter.java to emit a valid session from a parsed DsnBoard plus a RoutingResult. Each _write_* function mirrors a write* method upstream: - session scope with base_design, placement (resolution + components echoed from the DSN), an empty was_is, and routes - routes carries resolution, a reduced parser scope, library_out with the via padstacks, and network_out - network_out emits (net (wire (path layer width x1 y1 ...)) (via padstack x y)) for each routed net; integer coordinates via round- half-up to match Java Math.round write_ses(board) with no result produces a valid no-op session, proving the DSN-in / SES-out round trip. Only routes > network_out is required by SesReader and kicad-cli; the rest is echoed for a well-formed file. 18 tests: indent/quoting rules, no-op session structure, placement and library_out echo, routed wire/via serialization, integer rounding, and round-trips re-parsed through the project's own DSN tokenizer/sexp (64 tests total across the suite, all green; ruff clean).
This commit is contained in:
parent
650c732a30
commit
12b0a231f0
31
src/freeroute/ses/__init__.py
Normal file
31
src/freeroute/ses/__init__.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""Specctra SES (session) writing for freeroute.
|
||||
|
||||
Public API::
|
||||
|
||||
from freeroute.dsn import parse_dsn
|
||||
from freeroute.ses import write_ses, RoutingResult, RoutedWire, RoutedVia
|
||||
|
||||
board = parse_dsn(dsn_text)
|
||||
ses_text = write_ses(board) # valid no-op session
|
||||
# ... or with routed geometry:
|
||||
result = RoutingResult()
|
||||
result.add_wire("NET_A", RoutedWire("Top", 200, [0, 0, 1000, 0]))
|
||||
ses_text = write_ses(board, result)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .indent import Identifier, IndentWriter
|
||||
from .model import RoutedNet, RoutedVia, RoutedWire, RoutingResult
|
||||
from .writer import write_ses, write_ses_file
|
||||
|
||||
__all__ = [
|
||||
"write_ses",
|
||||
"write_ses_file",
|
||||
"RoutingResult",
|
||||
"RoutedNet",
|
||||
"RoutedWire",
|
||||
"RoutedVia",
|
||||
"Identifier",
|
||||
"IndentWriter",
|
||||
]
|
||||
292
src/freeroute/ses/writer.py
Normal file
292
src/freeroute/ses/writer.py
Normal file
@ -0,0 +1,292 @@
|
||||
"""Specctra SES session-file writer.
|
||||
|
||||
Ports the write path of FreeRouting's ``io/specctra/SesWriter.java`` to operate
|
||||
on this project's parsed :class:`~freeroute.dsn.model.DsnBoard` plus an
|
||||
in-memory :class:`~freeroute.ses.model.RoutingResult`. The emitted structure is::
|
||||
|
||||
(session <name>
|
||||
(base_design <name>)
|
||||
(placement
|
||||
(resolution <unit> <n>)
|
||||
(component <image> (place <ref> x y front|back rot) ...))
|
||||
(was_is)
|
||||
(routes
|
||||
(resolution <unit> <n>)
|
||||
(parser ...)
|
||||
(library_out (padstack <name> (shape (...)) (attach off)))
|
||||
(network_out
|
||||
(net <name>
|
||||
(wire (path <layer> <width> x1 y1 ...))
|
||||
(via <padstack> x y)))))
|
||||
|
||||
FreeRouting's ``SesReader`` (and, in practice, ``kicad-cli pcb import
|
||||
specctra-ses``) only *require* the ``routes > network_out > net > wire/via``
|
||||
subtree plus the ``routes`` ``resolution`` for coordinate scaling; the other
|
||||
scopes are echoed for a well-formed file and skipped on read. Each ``_write_*``
|
||||
here corresponds to a ``write*`` method in ``SesWriter``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from freeroute.dsn.model import DsnBoard
|
||||
from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
|
||||
|
||||
from .indent import Identifier, IndentWriter
|
||||
from .model import RoutedNet, RoutedVia, RoutedWire, RoutingResult
|
||||
|
||||
__all__ = ["write_ses", "write_ses_file"]
|
||||
|
||||
|
||||
def _iround(value: float) -> int:
|
||||
"""Round half up, matching Java's ``(int) Math.round(x)``."""
|
||||
return math.floor(value + 0.5)
|
||||
|
||||
|
||||
def write_ses(
|
||||
board: DsnBoard,
|
||||
result: RoutingResult | None = None,
|
||||
design_name: str | None = None,
|
||||
) -> str:
|
||||
"""Serialize ``board`` + ``result`` to a Specctra SES string.
|
||||
|
||||
``result`` may be ``None`` (or empty) for a valid no-op session that carries
|
||||
no wires or vias — enough to prove the DSN-in / SES-out round trip.
|
||||
``design_name`` defaults to the board's pcb name; the session name is the
|
||||
design name with a ``.dsn`` suffix rewritten to ``.ses``.
|
||||
"""
|
||||
if result is None:
|
||||
result = RoutingResult()
|
||||
|
||||
design = design_name if design_name is not None else board.name
|
||||
session_name = design.replace(".dsn", ".ses")
|
||||
ident = Identifier(string_quote=board.parser.string_quote)
|
||||
|
||||
out = IndentWriter()
|
||||
_write_session_scope(out, ident, board, result, session_name, design)
|
||||
text = out.getvalue()
|
||||
if not text.endswith("\n"):
|
||||
text += "\n"
|
||||
return text
|
||||
|
||||
|
||||
def write_ses_file(
|
||||
board: DsnBoard,
|
||||
path: str,
|
||||
result: RoutingResult | None = None,
|
||||
design_name: str | None = None,
|
||||
) -> None:
|
||||
"""Write the SES output to ``path`` (UTF-8)."""
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(write_ses(board, result, design_name))
|
||||
|
||||
|
||||
# --- scope writers (mirror SesWriter.write*) ---------------------------------
|
||||
|
||||
|
||||
def _write_session_scope(
|
||||
out: IndentWriter,
|
||||
ident: Identifier,
|
||||
board: DsnBoard,
|
||||
result: RoutingResult,
|
||||
session_name: str,
|
||||
design_name: str,
|
||||
) -> None:
|
||||
out.start_scope(new_line=False)
|
||||
out.write("session ")
|
||||
ident.write(session_name, out)
|
||||
out.new_line()
|
||||
out.write("(base_design ")
|
||||
ident.write(design_name, out)
|
||||
out.write(")")
|
||||
_write_placement(out, ident, board)
|
||||
_write_was_is(out)
|
||||
_write_routes(out, ident, board, result)
|
||||
out.end_scope()
|
||||
|
||||
|
||||
def _write_resolution(out: IndentWriter, board: DsnBoard) -> None:
|
||||
out.new_line()
|
||||
out.write(f"(resolution {board.resolution.unit} {board.resolution.value})")
|
||||
|
||||
|
||||
def _write_placement(out: IndentWriter, ident: Identifier, board: DsnBoard) -> None:
|
||||
out.start_scope()
|
||||
out.write("placement")
|
||||
_write_resolution(out, board)
|
||||
for placement in board.placements:
|
||||
placed = [p for p in placement.places if p.x is not None and p.y is not None]
|
||||
if not placed:
|
||||
continue
|
||||
out.start_scope()
|
||||
out.write("component ")
|
||||
ident.write(placement.lib_name, out)
|
||||
for place in placed:
|
||||
out.new_line()
|
||||
out.write("(place ")
|
||||
ident.write(place.name, out)
|
||||
out.write(f" {_iround(place.x)} {_iround(place.y)}")
|
||||
out.write(" front " if place.is_front else " back ")
|
||||
out.write(str(_iround(place.rotation)))
|
||||
if place.position_fixed:
|
||||
out.new_line()
|
||||
out.write(" (lock_type position)")
|
||||
out.write(")")
|
||||
out.end_scope()
|
||||
out.end_scope()
|
||||
|
||||
|
||||
def _write_was_is(out: IndentWriter) -> None:
|
||||
# No pin/gate swaps are modelled yet, so this scope is intentionally empty.
|
||||
out.start_scope()
|
||||
out.write("was_is")
|
||||
out.end_scope()
|
||||
|
||||
|
||||
def _write_routes(
|
||||
out: IndentWriter, ident: Identifier, board: DsnBoard, result: RoutingResult
|
||||
) -> None:
|
||||
out.start_scope()
|
||||
out.write("routes ")
|
||||
_write_resolution(out, board)
|
||||
_write_parser(out, ident, board)
|
||||
_write_library_out(out, ident, board)
|
||||
_write_network_out(out, ident, result)
|
||||
out.end_scope()
|
||||
|
||||
|
||||
def _write_parser(out: IndentWriter, ident: Identifier, board: DsnBoard) -> None:
|
||||
# Reduced parser scope (SesWriter passes p_reduced=true): omit string_quote /
|
||||
# space_in_quoted_tokens / generated_by_freerouting; echo host_cad/version.
|
||||
out.start_scope()
|
||||
out.write("parser")
|
||||
if board.parser.host_cad is not None:
|
||||
out.new_line()
|
||||
out.write("(host_cad ")
|
||||
ident.write(board.parser.host_cad, out)
|
||||
out.write(")")
|
||||
if board.parser.host_version is not None:
|
||||
out.new_line()
|
||||
out.write("(host_version ")
|
||||
ident.write(board.parser.host_version, out)
|
||||
out.write(")")
|
||||
out.end_scope()
|
||||
|
||||
|
||||
def _write_library_out(out: IndentWriter, ident: Identifier, board: DsnBoard) -> None:
|
||||
out.start_scope()
|
||||
out.write("library_out ")
|
||||
for name in board.via_padstack_names:
|
||||
padstack = board.padstack(name)
|
||||
if padstack is not None:
|
||||
_write_padstack(out, ident, padstack)
|
||||
out.end_scope()
|
||||
|
||||
|
||||
def _write_padstack(out: IndentWriter, ident: Identifier, padstack) -> None:
|
||||
out.start_scope()
|
||||
out.write("padstack ")
|
||||
ident.write(padstack.name, out)
|
||||
for shape in padstack.shapes:
|
||||
out.start_scope()
|
||||
out.write("shape")
|
||||
_write_shape_int(out, ident, shape)
|
||||
out.end_scope()
|
||||
if not padstack.attach_allowed:
|
||||
out.new_line()
|
||||
out.write("(attach off)")
|
||||
out.end_scope()
|
||||
|
||||
|
||||
def _write_shape_int(out: IndentWriter, ident: Identifier, shape: Shape) -> None:
|
||||
"""Write a padstack shape with integer coordinates (mirrors write_scope_int)."""
|
||||
out.start_scope()
|
||||
if isinstance(shape, Rectangle):
|
||||
out.write("rect ")
|
||||
ident.write(shape.layer, out)
|
||||
for c in shape.coords[:4]:
|
||||
out.write(f" {_iround(c)}")
|
||||
elif isinstance(shape, Circle):
|
||||
out.write("circle ")
|
||||
ident.write(shape.layer, out)
|
||||
out.write(f" {_iround(shape.diameter)}")
|
||||
if shape.center_x or shape.center_y:
|
||||
out.write(f" {_iround(shape.center_x)} {_iround(shape.center_y)}")
|
||||
elif isinstance(shape, Polygon):
|
||||
out.write("polygon ")
|
||||
ident.write(shape.layer, out)
|
||||
out.write(f" {_iround(shape.aperture_width)}")
|
||||
for c in shape.coords:
|
||||
out.write(f" {_iround(c)}")
|
||||
elif isinstance(shape, Path):
|
||||
out.write("path ")
|
||||
ident.write(shape.layer, out)
|
||||
out.write(f" {_iround(shape.width)}")
|
||||
for c in shape.coords:
|
||||
out.write(f" {_iround(c)}")
|
||||
else: # pragma: no cover - defensive: unknown shape type
|
||||
out.write("rect ")
|
||||
ident.write(shape.layer, out)
|
||||
out.end_scope()
|
||||
|
||||
|
||||
def _write_network_out(out: IndentWriter, ident: Identifier, result: RoutingResult) -> None:
|
||||
out.start_scope()
|
||||
out.write("network_out ")
|
||||
for net in result.routed_nets():
|
||||
_write_net(out, ident, net)
|
||||
out.end_scope()
|
||||
|
||||
|
||||
def _write_net(out: IndentWriter, ident: Identifier, net: RoutedNet) -> None:
|
||||
out.start_scope()
|
||||
out.write("net ")
|
||||
ident.write(net.name, out)
|
||||
for wire in net.wires:
|
||||
_write_wire(out, ident, wire)
|
||||
for via in net.vias:
|
||||
_write_via(out, ident, via)
|
||||
out.end_scope()
|
||||
|
||||
|
||||
def _write_wire(out: IndentWriter, ident: Identifier, wire: RoutedWire) -> None:
|
||||
out.start_scope()
|
||||
out.write("wire")
|
||||
_write_path(out, ident, wire.layer, wire.width, wire.coords)
|
||||
if wire.fixed:
|
||||
out.new_line()
|
||||
out.write("(type fix)")
|
||||
out.end_scope()
|
||||
|
||||
|
||||
def _write_path(
|
||||
out: IndentWriter,
|
||||
ident: Identifier,
|
||||
layer: str,
|
||||
width: float,
|
||||
coords: list[float],
|
||||
) -> None:
|
||||
out.start_scope()
|
||||
out.write("path ")
|
||||
ident.write(layer, out)
|
||||
out.write(" ")
|
||||
out.write(str(_iround(width)))
|
||||
corner_count = len(coords) // 2
|
||||
for i in range(corner_count):
|
||||
out.new_line()
|
||||
out.write(str(_iround(coords[2 * i])))
|
||||
out.write(" ")
|
||||
out.write(str(_iround(coords[2 * i + 1])))
|
||||
out.end_scope()
|
||||
|
||||
|
||||
def _write_via(out: IndentWriter, ident: Identifier, via: RoutedVia) -> None:
|
||||
out.start_scope()
|
||||
out.write("via ")
|
||||
ident.write(via.padstack, out)
|
||||
out.write(f" {_iround(via.x)} {_iround(via.y)}")
|
||||
if via.fixed:
|
||||
out.new_line()
|
||||
out.write("(type fix)")
|
||||
out.end_scope()
|
||||
63
tests/ses/test_indent.py
Normal file
63
tests/ses/test_indent.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""Tests for the SES indent writer and identifier quoting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from freeroute.ses.indent import Identifier, IndentWriter
|
||||
|
||||
|
||||
def test_nested_scopes_indent_by_two_spaces():
|
||||
out = IndentWriter()
|
||||
out.start_scope(new_line=False)
|
||||
out.write("session x")
|
||||
out.start_scope()
|
||||
out.write("routes")
|
||||
out.end_scope()
|
||||
out.end_scope()
|
||||
assert out.getvalue() == "(session x\n (routes\n )\n)"
|
||||
|
||||
|
||||
def test_new_line_uses_current_indent():
|
||||
out = IndentWriter()
|
||||
out.start_scope(new_line=False)
|
||||
out.write("a")
|
||||
out.new_line()
|
||||
out.write("(b)")
|
||||
out.end_scope()
|
||||
assert out.getvalue() == "(a\n (b)\n)"
|
||||
|
||||
|
||||
def test_plain_name_not_quoted():
|
||||
ident = Identifier()
|
||||
assert ident.format("Top") == "Top"
|
||||
assert ident.format("U1") == "U1"
|
||||
assert ident.format("F.Cu") == "F.Cu" # '.' is not reserved
|
||||
|
||||
|
||||
def test_reserved_chars_force_quotes():
|
||||
ident = Identifier()
|
||||
assert ident.format("NET_A") == '"NET_A"' # underscore reserved
|
||||
assert ident.format("a-b") == '"a-b"' # hyphen reserved
|
||||
assert ident.format("with space") == '"with space"'
|
||||
assert ident.format("Via[0-1]_600:300_um") == '"Via[0-1]_600:300_um"'
|
||||
|
||||
|
||||
def test_leading_digit_forces_quotes():
|
||||
ident = Identifier()
|
||||
assert ident.format("2layer") == '"2layer"'
|
||||
assert ident.format("-3x") == '"-3x"'
|
||||
|
||||
|
||||
def test_non_ascii_forces_quotes():
|
||||
ident = Identifier()
|
||||
assert ident.format("Паяльная") == '"Паяльная"'
|
||||
|
||||
|
||||
def test_surrounding_quotes_stripped_before_reprocessing():
|
||||
ident = Identifier()
|
||||
# already-quoted plain name comes back unquoted
|
||||
assert ident.format('"Top"') == "Top"
|
||||
|
||||
|
||||
def test_embedded_quote_removed():
|
||||
ident = Identifier()
|
||||
assert ident.format('a"b') == "ab"
|
||||
139
tests/ses/test_writer.py
Normal file
139
tests/ses/test_writer.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""End-to-end tests for the SES writer.
|
||||
|
||||
Where possible these re-parse the emitted SES with the project's own tokenizer /
|
||||
S-expression parser (:mod:`freeroute.dsn.sexp`) and assert structural
|
||||
invariants, mirroring what FreeRouting's ``SesReader`` and ``kicad-cli pcb
|
||||
import specctra-ses`` walk: ``session > routes > network_out > net >
|
||||
wire(path)/via``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from freeroute.dsn import parse_dsn
|
||||
from freeroute.dsn.sexp import SExp, parse
|
||||
from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses
|
||||
|
||||
FIXTURES = Path(__file__).parent.parent / "dsn" / "fixtures"
|
||||
|
||||
|
||||
def load_board(name: str):
|
||||
return parse_dsn((FIXTURES / name).read_text())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def board():
|
||||
return load_board("smd_demo.dsn")
|
||||
|
||||
|
||||
def child(node: SExp, head: str) -> SExp:
|
||||
found = node.child(head)
|
||||
assert found is not None, f"missing ({head} ...) scope"
|
||||
return found
|
||||
|
||||
|
||||
# --- no-op session -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_noop_session_is_parseable_and_well_formed(board):
|
||||
text = write_ses(board)
|
||||
top = parse(text) # must be a single balanced S-expression
|
||||
assert top.head == "session"
|
||||
# session name argument: design name with .dsn -> .ses
|
||||
assert top.values()[0].text == "smd-demo.ses"
|
||||
|
||||
|
||||
def test_noop_session_has_required_scopes(board):
|
||||
top = parse(write_ses(board))
|
||||
assert child(top, "base_design").values()[0].text == "smd-demo.dsn"
|
||||
assert top.child("placement") is not None
|
||||
assert top.child("was_is") is not None
|
||||
routes = child(top, "routes")
|
||||
# routes must carry a resolution (KiCad scales session coords by it)
|
||||
assert child(routes, "resolution").values()[0].text == "um"
|
||||
assert routes.child("network_out") is not None
|
||||
|
||||
|
||||
def test_noop_network_out_is_empty(board):
|
||||
routes = child(parse(write_ses(board)), "routes")
|
||||
network = child(routes, "network_out")
|
||||
assert network.children("net") == []
|
||||
|
||||
|
||||
def test_placement_echoes_components(board):
|
||||
placement = child(parse(write_ses(board)), "placement")
|
||||
comps = placement.children("component")
|
||||
names = [c.values()[0].text for c in comps]
|
||||
assert names == ["MiniQFN-6", "0603"]
|
||||
# U1 place: coords rounded to int, side, rotation
|
||||
u1 = comps[0].child("place")
|
||||
vals = [v.text for v in u1.values()]
|
||||
assert vals == ["U1", "75000", "-45000", "front", "90"]
|
||||
|
||||
|
||||
def test_library_out_echoes_via_padstack(board):
|
||||
routes = child(parse(write_ses(board)), "routes")
|
||||
lib = child(routes, "library_out")
|
||||
pads = lib.children("padstack")
|
||||
assert [p.values()[0].text for p in pads] == ["Via[0-1]_600:300_um"]
|
||||
|
||||
|
||||
# --- routed geometry ---------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def routed():
|
||||
result = RoutingResult()
|
||||
result.add_wire("NET_A", RoutedWire(layer="Top", width=200, coords=[0, 0, 1000, -500]))
|
||||
result.add_via("NET_A", RoutedVia(padstack="Via[0-1]_600:300_um", x=1000, y=-500))
|
||||
result.add_wire("NET_B", RoutedWire(layer="Bottom", width=250, coords=[10, 10, 20, 20]))
|
||||
return result
|
||||
|
||||
|
||||
def test_routed_network_out_has_nets(board, routed):
|
||||
routes = child(parse(write_ses(board, routed)), "routes")
|
||||
network = child(routes, "network_out")
|
||||
nets = network.children("net")
|
||||
assert [n.values()[0].text for n in nets] == ["NET_A", "NET_B"]
|
||||
|
||||
|
||||
def test_routed_wire_path_and_via(board, routed):
|
||||
routes = child(parse(write_ses(board, routed)), "routes")
|
||||
net_a = child(routes, "network_out").children("net")[0]
|
||||
|
||||
wire = child(net_a, "wire")
|
||||
path = child(wire, "path")
|
||||
pvals = [v.text for v in path.values()]
|
||||
# (path <layer> <width> x1 y1 x2 y2)
|
||||
assert pvals == ["Top", "200", "0", "0", "1000", "-500"]
|
||||
|
||||
via = child(net_a, "via")
|
||||
vvals = [v.text for v in via.values()]
|
||||
assert vvals == ["Via[0-1]_600:300_um", "1000", "-500"]
|
||||
|
||||
|
||||
def test_coordinates_are_integers(board):
|
||||
result = RoutingResult()
|
||||
result.add_wire("NET_A", RoutedWire(layer="Top", width=199.6, coords=[0.4, -0.4, 2.5, 3.5]))
|
||||
routes = child(parse(write_ses(board, result)), "routes")
|
||||
path = child(child(child(routes, "network_out").children("net")[0], "wire"), "path")
|
||||
pvals = [v.text for v in path.values()]
|
||||
# width 199.6 -> 200; coords 0.4->0, -0.4->0, 2.5->3, 3.5->4 (round half up)
|
||||
assert pvals == ["Top", "200", "0", "0", "3", "4"]
|
||||
assert all("." not in v for v in pvals)
|
||||
|
||||
|
||||
def test_empty_result_matches_noop(board):
|
||||
assert write_ses(board, RoutingResult()) == write_ses(board)
|
||||
|
||||
|
||||
def test_round_trip_from_empty_board_fixture():
|
||||
board = load_board("empty_board.dsn")
|
||||
top = parse(write_ses(board))
|
||||
assert top.head == "session"
|
||||
assert top.values()[0].text == "freeroute-empty.ses"
|
||||
# empty board has no placements -> placement scope has no components
|
||||
assert child(top, "placement").children("component") == []
|
||||
Loading…
x
Reference in New Issue
Block a user