diff --git a/src/freeroute/ses/indent.py b/src/freeroute/ses/indent.py new file mode 100644 index 0000000..8a3d1c6 --- /dev/null +++ b/src/freeroute/ses/indent.py @@ -0,0 +1,103 @@ +"""Indented S-expression output and identifier quoting for SES writing. + +Ports two FreeRouting helpers used by the session writer: + +* :class:`IndentWriter` mirrors ``datastructures/IndentFileWriter`` — a 2-space + indenting writer with ``start_scope`` / ``end_scope`` / ``new_line``. +* :class:`Identifier` mirrors ``datastructures/IdentifierType`` — quotes a name + when it contains a reserved character, a non-ASCII character, or starts with a + digit; strips surrounding quotes and any embedded quote character first. + +The SES reserved-character set is the one ``SesWriter`` constructs: +``( ) ; - _ / ~ { }``. Note that ``-`` and ``_`` are reserved, so net +names like ``NET_A`` and padstacks like ``Via[0-1]_600:300_um`` are quoted. +""" + +from __future__ import annotations + +from io import StringIO +import re + +__all__ = ["IndentWriter", "Identifier", "SES_RESERVED_CHARS"] + +_INDENT = " " + +#: Reserved characters for SES identifiers (from SesWriter.write). +SES_RESERVED_CHARS: tuple[str, ...] = ( + "(", + ")", + " ", + ";", + "-", + "_", + "/", + "~", + "{", + "}", +) + +_LEADING_DIGIT = re.compile(r"-?\d") + + +class IndentWriter: + """Accumulates indented S-expression text, mirroring ``IndentFileWriter``.""" + + def __init__(self) -> None: + self._buf = StringIO() + self._level = 0 + + def write(self, text: str) -> None: + self._buf.write(text) + + def new_line(self) -> None: + """Start a new line at the current indent level.""" + self._buf.write("\n") + self._buf.write(_INDENT * self._level) + + def start_scope(self, new_line: bool = True) -> None: + """Open a ``(`` scope, optionally on a fresh line, and indent.""" + if new_line: + self.new_line() + self._buf.write("(") + self._level += 1 + + def end_scope(self) -> None: + """Close the innermost scope with ``)`` on its own aligned line.""" + self._level -= 1 + self.new_line() + self._buf.write(")") + + def getvalue(self) -> str: + return self._buf.getvalue() + + +class Identifier: + """Formats a name as a DSN/SES token, quoting it when required.""" + + def __init__( + self, string_quote: str = '"', reserved: tuple[str, ...] = SES_RESERVED_CHARS + ) -> None: + self.quote = string_quote or '"' + self.reserved = tuple(reserved) + + def format(self, name: str) -> str: + """Return ``name`` quoted if it needs quoting, else unchanged.""" + # Strip a surrounding matching pair of quote characters. + while len(name) >= 2 and name[0] == self.quote and name[-1] == self.quote: + name = name[1:-1] + # Remove any embedded quote character (it cannot be escaped). + if self.quote in name: + name = name.replace(self.quote, "") + + need_quotes = any(rc in name for rc in self.reserved) + if not need_quotes and any(ord(ch) > 127 for ch in name): + need_quotes = True + if not need_quotes and _LEADING_DIGIT.match(name): + need_quotes = True + + if need_quotes: + return f"{self.quote}{name}{self.quote}" + return name + + def write(self, name: str, out: IndentWriter) -> None: + out.write(self.format(name)) diff --git a/src/freeroute/ses/model.py b/src/freeroute/ses/model.py new file mode 100644 index 0000000..8e104b1 --- /dev/null +++ b/src/freeroute/ses/model.py @@ -0,0 +1,80 @@ +"""In-memory routing result — the input the SES writer serializes. + +The maze router (a later stage) populates a :class:`RoutingResult`; the SES +writer turns it into the ``(routes (network_out ...))`` scope. Coordinates and +widths are in DSN units (the same units the DSN was parsed in); the writer +rounds them to integers, matching FreeRouting's ``SesWriter`` which emits +integer session coordinates. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +__all__ = ["RoutedWire", "RoutedVia", "RoutedNet", "RoutingResult"] + + +@dataclass +class RoutedWire: + """A routed trace segment on one layer. + + ``coords`` is a flat ``[x1, y1, x2, y2, ...]`` list (DSN units); ``width`` is + the full trace width (DSN units). ``fixed`` maps to an emitted + ``(type fix)`` marker when set. + """ + + layer: str + width: float + coords: list[float] + fixed: bool = False + + +@dataclass +class RoutedVia: + """A routed via at ``(x, y)`` (DSN units) using a named padstack.""" + + padstack: str + x: float + y: float + fixed: bool = False + + +@dataclass +class RoutedNet: + """The routed wires and vias belonging to one net.""" + + name: str + wires: list[RoutedWire] = field(default_factory=list) + vias: list[RoutedVia] = field(default_factory=list) + + @property + def has_items(self) -> bool: + return bool(self.wires or self.vias) + + +@dataclass +class RoutingResult: + """The complete routing output for a board, grouped by net.""" + + nets: list[RoutedNet] = field(default_factory=list) + + def net(self, name: str, *, create: bool = False) -> RoutedNet | None: + """Return the :class:`RoutedNet` with ``name`` (creating it if asked).""" + for n in self.nets: + if n.name == name: + return n + if create: + n = RoutedNet(name=name) + self.nets.append(n) + return n + return None + + def add_wire(self, net_name: str, wire: RoutedWire) -> None: + self.net(net_name, create=True).wires.append(wire) + + def add_via(self, net_name: str, via: RoutedVia) -> None: + self.net(net_name, create=True).vias.append(via) + + def routed_nets(self) -> list[RoutedNet]: + """Nets that actually carry routed geometry (in insertion order).""" + return [n for n in self.nets if n.has_items]