diff --git a/src/freeroute/ses/__init__.py b/src/freeroute/ses/__init__.py new file mode 100644 index 0000000..9519ffd --- /dev/null +++ b/src/freeroute/ses/__init__.py @@ -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", +] diff --git a/src/freeroute/ses/writer.py b/src/freeroute/ses/writer.py new file mode 100644 index 0000000..63138fe --- /dev/null +++ b/src/freeroute/ses/writer.py @@ -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 + (base_design ) + (placement + (resolution ) + (component (place x y front|back rot) ...)) + (was_is) + (routes + (resolution ) + (parser ...) + (library_out (padstack (shape (...)) (attach off))) + (network_out + (net + (wire (path x1 y1 ...)) + (via 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() diff --git a/tests/ses/test_indent.py b/tests/ses/test_indent.py new file mode 100644 index 0000000..2f91630 --- /dev/null +++ b/tests/ses/test_indent.py @@ -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" diff --git a/tests/ses/test_writer.py b/tests/ses/test_writer.py new file mode 100644 index 0000000..81d2d32 --- /dev/null +++ b/tests/ses/test_writer.py @@ -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 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") == []