diff --git a/src/freeroute/dsn/__init__.py b/src/freeroute/dsn/__init__.py new file mode 100644 index 0000000..c6364f2 --- /dev/null +++ b/src/freeroute/dsn/__init__.py @@ -0,0 +1,69 @@ +"""Specctra DSN parsing for freeroute. + +Public API:: + + from freeroute.dsn import parse_dsn + board = parse_dsn(dsn_text) + +``parse_dsn`` returns a typed :class:`~freeroute.dsn.model.DsnBoard`. The +lower-level :func:`~freeroute.dsn.tokenizer.tokenize` and +:func:`~freeroute.dsn.sexp.parse` are exposed for tooling and tests. +""" + +from __future__ import annotations + +from .model import ( + ClearanceRule, + ComponentPlace, + ComponentPlacement, + DsnBoard, + Image, + Keepout, + Layer, + Net, + NetClass, + NetPin, + Padstack, + ParserInfo, + Pin, + Resolution, + StructureRules, + WidthRule, +) +from .reader import DsnParseError, parse_dsn +from .sexp import SExp, parse +from .shapes import Area, Circle, Path, Polygon, Rectangle, Shape +from .tokenizer import DsnSyntaxError, Token, TokenKind, tokenize + +__all__ = [ + "parse_dsn", + "DsnParseError", + "parse", + "SExp", + "tokenize", + "Token", + "TokenKind", + "DsnSyntaxError", + "DsnBoard", + "ParserInfo", + "Resolution", + "Layer", + "WidthRule", + "ClearanceRule", + "StructureRules", + "Padstack", + "Pin", + "Image", + "ComponentPlace", + "ComponentPlacement", + "NetPin", + "Net", + "NetClass", + "Keepout", + "Shape", + "Rectangle", + "Circle", + "Polygon", + "Path", + "Area", +] diff --git a/src/freeroute/dsn/model.py b/src/freeroute/dsn/model.py new file mode 100644 index 0000000..782271c --- /dev/null +++ b/src/freeroute/dsn/model.py @@ -0,0 +1,218 @@ +"""Typed board model produced by parsing a Specctra DSN file. + +These dataclasses are the Python analogue of the data that FreeRouting's +``io/specctra/parser`` scope readers accumulate before constructing a +``RoutingBoard``. They stay at the DSN level (names, raw coordinates in DSN +units) — turning them into board geometry is a later port stage. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .shapes import Area, Shape + +__all__ = [ + "ParserInfo", + "Resolution", + "Layer", + "WidthRule", + "ClearanceRule", + "StructureRules", + "Padstack", + "Pin", + "Image", + "ComponentPlace", + "ComponentPlacement", + "NetPin", + "Net", + "NetClass", + "Keepout", + "DsnBoard", +] + + +@dataclass +class ParserInfo: + """The ``(parser ...)`` scope.""" + + string_quote: str = '"' + host_cad: str | None = None + host_version: str | None = None + constants: list[tuple[str, str]] = field(default_factory=list) + generated_by_freerouting: bool = False + + +@dataclass +class Resolution: + """The ``(resolution )`` scope. Defaults match FreeRouting.""" + + unit: str = "mil" + value: int = 100 + + +@dataclass +class Layer: + """A ``(layer ...)`` entry. ``index`` is the physical layer number, 0 = top.""" + + name: str + index: int + is_signal: bool = True + net_names: list[str] = field(default_factory=list) + + +@dataclass +class WidthRule: + value: float + + +@dataclass +class ClearanceRule: + value: float + #: class-pair names from ``(type a-b)`` / ``(type smd_smd)``; empty = default + class_pairs: list[str] = field(default_factory=list) + + +@dataclass +class StructureRules: + """The default ``(rule ...)`` block plus control/snap settings.""" + + width_rules: list[WidthRule] = field(default_factory=list) + clearance_rules: list[ClearanceRule] = field(default_factory=list) + + +@dataclass +class Padstack: + """A ``(padstack ...)`` — one or more per-layer pad shapes plus flags.""" + + name: str + shapes: list[Shape] = field(default_factory=list) + attach_allowed: bool = True + placed_absolute: bool = False + + +@dataclass +class Pin: + """A pin within an image: ``(pin [ (rotate d) ] x y)``.""" + + padstack_name: str + name: str + x: float + y: float + rotation: float = 0.0 + + +@dataclass +class Image: + """A ``(image ...)`` — a component footprint definition.""" + + name: str + is_front: bool = True + pins: list[Pin] = field(default_factory=list) + outlines: list[Shape] = field(default_factory=list) + keepouts: list[Area] = field(default_factory=list) + via_keepouts: list[Area] = field(default_factory=list) + place_keepouts: list[Area] = field(default_factory=list) + + +@dataclass +class ComponentPlace: + """One placed instance: ``(place x y front|back rot ...)``. + + ``x`` / ``y`` are ``None`` when the component is declared but not yet placed. + """ + + name: str + x: float | None = None + y: float | None = None + is_front: bool = True + rotation: float = 0.0 + position_fixed: bool = False + part_number: str | None = None + + +@dataclass +class ComponentPlacement: + """A ``(component (place ...) ...)`` group.""" + + lib_name: str + places: list[ComponentPlace] = field(default_factory=list) + + +@dataclass +class NetPin: + """A ``Comp-Pin`` reference inside a net's ``(pins ...)`` list.""" + + component: str + pin: str + + +@dataclass +class Net: + """A ``(net [subnet] (pins ...))`` entry.""" + + name: str + subnet: int = 1 + pins: list[NetPin] = field(default_factory=list) + + +@dataclass +class NetClass: + """A ``(class net... (circuit (use_via ...)) (rule ...))`` entry.""" + + name: str + net_names: list[str] = field(default_factory=list) + width_rules: list[WidthRule] = field(default_factory=list) + clearance_rules: list[ClearanceRule] = field(default_factory=list) + use_via: list[str] = field(default_factory=list) + use_layer: list[str] = field(default_factory=list) + via_rule: str | None = None + + +@dataclass +class Keepout: + """A structure-level keepout area with its kind.""" + + area: Area + kind: str = "keepout" # keepout | via_keepout | place_keepout + + +@dataclass +class DsnBoard: + """The fully parsed DSN file.""" + + name: str = "" + parser: ParserInfo = field(default_factory=ParserInfo) + resolution: Resolution = field(default_factory=Resolution) + unit: str = "mil" + layers: list[Layer] = field(default_factory=list) + #: bounding-box boundary shape (layer ``pcb``), if present + boundary: Shape | None = None + #: outline shapes (layer ``signal``) + outlines: list[Shape] = field(default_factory=list) + via_padstack_names: list[str] = field(default_factory=list) + structure_rules: StructureRules = field(default_factory=StructureRules) + snap_angle: str = "fortyfive_degree" + via_at_smd_allowed: bool = False + keepouts: list[Keepout] = field(default_factory=list) + padstacks: list[Padstack] = field(default_factory=list) + images: list[Image] = field(default_factory=list) + placements: list[ComponentPlacement] = field(default_factory=list) + nets: list[Net] = field(default_factory=list) + net_classes: list[NetClass] = field(default_factory=list) + + # Convenience lookups ----------------------------------------------------- + def layer_names(self) -> list[str]: + return [layer.name for layer in self.layers] + + def net(self, name: str) -> Net | None: + for n in self.nets: + if n.name == name: + return n + return None + + def padstack(self, name: str) -> Padstack | None: + for p in self.padstacks: + if p.name == name: + return p + return None diff --git a/src/freeroute/dsn/reader.py b/src/freeroute/dsn/reader.py new file mode 100644 index 0000000..81940b7 --- /dev/null +++ b/src/freeroute/dsn/reader.py @@ -0,0 +1,506 @@ +"""Build a typed :class:`DsnBoard` from a Specctra DSN S-expression tree. + +This is the Python analogue of FreeRouting's ``ScopeKeyword`` recursive-descent +readers (``Parser``, ``Resolution``, ``Structure``, ``Library``, ``Package``, +``Component``, ``Network`` ...). Each ``_read_*`` function here corresponds to a +``read_scope`` method there. Unknown scopes are ignored, matching FreeRouting's +tolerant ``skip_scope`` behaviour. +""" + +from __future__ import annotations + +from .model import ( + ClearanceRule, + ComponentPlace, + ComponentPlacement, + DsnBoard, + Image, + Keepout, + Layer, + Net, + NetClass, + NetPin, + Padstack, + ParserInfo, + Pin, + Resolution, + WidthRule, +) +from .sexp import SExp, parse +from .shapes import Shape, read_area, read_shape +from .tokenizer import Token + +__all__ = ["parse_dsn", "DsnParseError"] + + +class DsnParseError(ValueError): + """Raised when the input is not a valid ``(pcb ...)`` DSN file.""" + + +# --- small leaf helpers ------------------------------------------------------ + + +def _first_value(node: SExp) -> str | None: + """Text of the first leaf-token item of ``node`` (its name argument).""" + for it in node.items: + if isinstance(it, Token): + return it.text + return None + + +def _on_off(node: SExp, default: bool = False) -> bool: + """Interpret an ``(... on|off)`` scope as a bool.""" + val = _first_value(node) + if val is None: + return default + return val.lower() == "on" + + +def _split_pin_ref(ref: str) -> NetPin: + """Split a ``Comp-Pin`` reference on the first hyphen (FreeRouting rule).""" + comp, sep, pin = ref.partition("-") + if not sep: + return NetPin(component=ref, pin="") + return NetPin(component=comp, pin=pin) + + +# --- scope readers ----------------------------------------------------------- + + +def _read_parser(node: SExp) -> ParserInfo: + info = ParserInfo() + for it in node.items: + if not isinstance(it, SExp): + continue + if it.head == "string_quote": + val = _first_value(it) + if val: + info.string_quote = val + elif it.head == "host_cad": + info.host_cad = _first_value(it) + elif it.head == "host_version": + info.host_version = _first_value(it) + elif it.head == "generated_by_freerouting": + info.generated_by_freerouting = True + elif it.head == "constant": + vals = [v.text for v in it.values()] + if len(vals) >= 2: + info.constants.append((vals[0], vals[1])) + return info + + +def _read_resolution(node: SExp) -> Resolution: + vals = node.values() + res = Resolution() + if vals: + res.unit = vals[0].text + if len(vals) > 1: + as_int = vals[1].as_int() + if as_int is not None: + res.value = as_int + return res + + +def _read_layer(node: SExp, index: int) -> Layer: + name = _first_value(node) or f"layer_{index}" + is_signal = True + net_names: list[str] = [] + for it in node.items: + if not isinstance(it, SExp): + continue + if it.head == "type": + t = _first_value(it) + if t is not None and t.lower() == "power": + is_signal = False + elif it.head == "use_net": + net_names.extend(v.text for v in it.values()) + return Layer(name=name, index=index, is_signal=is_signal, net_names=net_names) + + +def _collect_via_names(node: SExp) -> list[str]: + """Collect padstack names from a ``(via ...)`` scope, incl. ``(spare ...)``.""" + names: list[str] = [] + spare: list[str] = [] + for it in node.items: + if isinstance(it, Token) and it.is_value: + names.append(it.text) + elif isinstance(it, SExp) and it.head == "spare": + spare.extend(_collect_via_names(it)) + names.extend(spare) + return names + + +def _read_rules_into( + node: SExp, width_out: list[WidthRule], clear_out: list[ClearanceRule] +) -> None: + """Read a ``(rule (width v) (clearance v (type ...)))`` block.""" + for it in node.items: + if not isinstance(it, SExp): + continue + if it.head == "width": + vals = it.values() + if vals and vals[0].as_float() is not None: + width_out.append(WidthRule(value=vals[0].as_float())) + elif it.head in ("clearance", "clear"): + vals = it.values() + if not vals or vals[0].as_float() is None: + continue + value = vals[0].as_float() + class_pairs: list[str] = [] + type_scope = it.child("type") + if type_scope is not None: + class_pairs = [v.text for v in type_scope.values()] + clear_out.append(ClearanceRule(value=value, class_pairs=class_pairs)) + + +def _read_structure(node: SExp, board: DsnBoard) -> None: + layer_index = 0 + for it in node.items: + if not isinstance(it, SExp): + continue + head = it.head + if head == "layer": + board.layers.append(_read_layer(it, layer_index)) + layer_index += 1 + elif head == "boundary": + _read_boundary(it, board) + elif head == "via": + board.via_padstack_names = _collect_via_names(it) + elif head == "rule": + _read_rules_into( + it, board.structure_rules.width_rules, board.structure_rules.clearance_rules + ) + elif head == "keepout": + area = read_area(it) + if area is not None: + board.keepouts.append(Keepout(area=area, kind="keepout")) + elif head == "via_keepout": + area = read_area(it) + if area is not None: + board.keepouts.append(Keepout(area=area, kind="via_keepout")) + elif head == "place_keepout": + area = read_area(it) + if area is not None: + board.keepouts.append(Keepout(area=area, kind="place_keepout")) + elif head == "control": + smd = it.child("via_at_smd") + if smd is not None: + board.via_at_smd_allowed = _on_off(smd) + elif head == "snap_angle": + val = _first_value(it) + if val: + board.snap_angle = val + + +def _read_boundary(node: SExp, board: DsnBoard) -> None: + for it in node.items: + if not isinstance(it, SExp): + continue + shape = read_shape(it) + if shape is None: + continue + if shape.layer == "pcb": + board.boundary = shape + else: # signal (or a named layer treated as outline) + board.outlines.append(shape) + + +def _read_padstack(node: SExp) -> tuple[str, list[Shape], bool, bool]: + name = _first_value(node) or "" + shapes: list[Shape] = [] + attach_allowed = True + placed_absolute = False + for it in node.items: + if not isinstance(it, SExp): + continue + if it.head == "shape": + for sub in it.items: + if isinstance(sub, SExp): + shape = read_shape(sub) + if shape is not None: + shapes.append(shape) + elif it.head == "attach": + attach_allowed = _on_off(it, default=True) + elif it.head == "absolute": + placed_absolute = _on_off(it, default=False) + return name, shapes, attach_allowed, placed_absolute + + +def _read_pin(node: SExp) -> Pin | None: + """Read ``(pin [ (rotate d) ] x y [ (rotate d) ])``.""" + padstack_name: str | None = None + pin_name: str | None = None + coords: list[float] = [] + rotation = 0.0 + for it in node.items: + if isinstance(it, SExp): + if it.head == "rotate": + rv = it.values() + if rv and rv[0].as_float() is not None: + rotation = rv[0].as_float() + continue + # leaf token: padstack, pin name, then two coordinates + if padstack_name is None: + padstack_name = it.text + elif it.is_number and len(coords) < 2 and pin_name is not None: + coords.append(float(it.text)) + elif pin_name is None: + pin_name = it.text + elif len(coords) < 2 and it.is_number: + coords.append(float(it.text)) + if padstack_name is None or pin_name is None or len(coords) < 2: + return None + return Pin( + padstack_name=padstack_name, name=pin_name, x=coords[0], y=coords[1], rotation=rotation + ) + + +def _read_image(node: SExp) -> Image | None: + name = _first_value(node) + if name is None: + return None + image = Image(name=name) + for it in node.items: + if not isinstance(it, SExp): + continue + head = it.head + if head == "pin": + pin = _read_pin(it) + if pin is not None: + image.pins.append(pin) + elif head == "side": + side = _first_value(it) + image.is_front = side is None or side.lower() != "back" + elif head == "outline": + for sub in it.items: + if isinstance(sub, SExp): + shape = read_shape(sub) + if shape is not None: + image.outlines.append(shape) + elif head == "keepout": + area = read_area(it) + if area is not None: + image.keepouts.append(area) + elif head == "via_keepout": + area = read_area(it) + if area is not None: + image.via_keepouts.append(area) + elif head == "place_keepout": + area = read_area(it) + if area is not None: + image.place_keepouts.append(area) + return image + + +def _read_library(node: SExp, board: DsnBoard) -> None: + for it in node.items: + if not isinstance(it, SExp): + continue + if it.head == "padstack": + name, shapes, attach, absolute = _read_padstack(it) + board.padstacks.append( + Padstack(name=name, shapes=shapes, attach_allowed=attach, placed_absolute=absolute) + ) + elif it.head == "image": + image = _read_image(it) + if image is not None: + board.images.append(image) + + +def _read_place(node: SExp) -> ComponentPlace | None: + """Read ``(place x y front|back rot [ (lock_type position) ] ...)``.""" + name: str | None = None + coords: list[float] = [] + is_front = True + rotation = 0.0 + position_fixed = False + part_number: str | None = None + seen_side = False + + for it in node.items: + if isinstance(it, SExp): + if it.head == "lock_type": + position_fixed = any( + isinstance(v, Token) and v.text == "position" for v in it.items + ) + elif it.head in ("PN", "pn"): + part_number = _first_value(it) + continue + if name is None: + name = it.text + elif not seen_side and it.is_number and len(coords) < 2: + coords.append(float(it.text)) + elif it.text.lower() in ("front", "back"): + is_front = it.text.lower() != "back" + seen_side = True + elif seen_side and it.is_number: + rotation = float(it.text) + + if name is None: + return None + x = coords[0] if len(coords) >= 2 else None + y = coords[1] if len(coords) >= 2 else None + return ComponentPlace( + name=name, + x=x, + y=y, + is_front=is_front, + rotation=rotation, + position_fixed=position_fixed, + part_number=part_number, + ) + + +def _read_placement(node: SExp, board: DsnBoard) -> None: + for it in node.items: + if not isinstance(it, SExp) or it.head != "component": + continue + lib_name = _first_value(it) or "" + placement = ComponentPlacement(lib_name=lib_name) + for sub in it.items: + if isinstance(sub, SExp) and sub.head == "place": + place = _read_place(sub) + if place is not None: + placement.places.append(place) + board.placements.append(placement) + + +def _read_pins_list(node: SExp) -> list[NetPin]: + """Read a ``(pins Comp-Pin ...)`` list into :class:`NetPin` entries. + + The common case is a single atom per reference (``U1-1``) which splits on + its first hyphen. When component or pin names are quoted (Eagle exports with + ``space_in_quoted_tokens``), the reference arrives as several tokens around + a standalone ``-`` separator (``"J3" - "GND"``); those are stitched back + together here. + """ + parts = [v.text for v in node.values() if v.text] + refs: list[NetPin] = [] + i = 0 + while i < len(parts): + p = parts[i] + if p == "-": + i += 1 + continue + if p.endswith("-") and i + 1 < len(parts): # unquoted comp + quoted pin + refs.append(NetPin(component=p[:-1], pin=parts[i + 1])) + i += 2 + continue + if "-" in p: # single-token Comp-Pin (the common case) + refs.append(_split_pin_ref(p)) + i += 1 + continue + if i + 1 < len(parts) and parts[i + 1] == "-" and i + 2 < len(parts): + refs.append(NetPin(component=p, pin=parts[i + 2])) # both quoted + i += 3 + continue + refs.append(NetPin(component=p, pin="")) # name without a pin + i += 1 + return refs + + +def _read_net(node: SExp) -> Net | None: + name = _first_value(node) + if name is None: + return None + net = Net(name=name) + for it in node.items: + if isinstance(it, Token): + as_int = it.as_int() + if as_int is not None and it.text == name: + continue # (defensive) name repeated + if as_int is not None: + net.subnet = as_int + continue + if it.head in ("pins", "order", "fromto"): + net.pins.extend(_read_pins_list(it)) + return net + + +def _read_net_class(node: SExp) -> NetClass | None: + name = _first_value(node) + if name is None: + return None + net_class = NetClass(name=name) + # Leading bareword/string values after the name are member net names. + first_seen = False + for it in node.items: + if isinstance(it, Token): + if not first_seen: + first_seen = True # skip the class name itself + continue + net_class.net_names.append(it.text) + continue + head = it.head + if head == "rule": + _read_rules_into(it, net_class.width_rules, net_class.clearance_rules) + elif head == "circuit": + use_via = it.child("use_via") + if use_via is not None: + net_class.use_via.extend(v.text for v in use_via.values()) + use_layer = it.child("use_layer") + if use_layer is not None: + net_class.use_layer.extend(v.text for v in use_layer.values()) + elif head == "use_via": + net_class.use_via.extend(v.text for v in it.values()) + elif head == "use_layer": + net_class.use_layer.extend(v.text for v in it.values()) + elif head == "via_rule": + net_class.via_rule = _first_value(it) + return net_class + + +def _read_network(node: SExp, board: DsnBoard) -> None: + for it in node.items: + if not isinstance(it, SExp): + continue + if it.head == "net": + net = _read_net(it) + if net is not None: + board.nets.append(net) + elif it.head == "class": + net_class = _read_net_class(it) + if net_class is not None: + board.net_classes.append(net_class) + + +# --- top level --------------------------------------------------------------- + + +def parse_dsn(text: str) -> DsnBoard: + """Parse DSN ``text`` into a :class:`DsnBoard`. + + Raises :class:`DsnParseError` if the top-level scope is not ``(pcb ...)``. + """ + top = parse(text) + if top.head != "pcb": + raise DsnParseError( + f"not a Specctra DSN file: expected top-level '(pcb ...)', got '({top.head} ...)'" + ) + + board = DsnBoard(name=_first_value(top) or "") + + for it in top.items: + if not isinstance(it, SExp): + continue + head = it.head + if head == "parser": + board.parser = _read_parser(it) + elif head == "resolution": + board.resolution = _read_resolution(it) + board.unit = board.resolution.unit + elif head == "unit": + val = _first_value(it) + if val: + board.unit = val + elif head == "structure": + _read_structure(it, board) + elif head == "library": + _read_library(it, board) + elif head == "placement": + _read_placement(it, board) + elif head == "network": + _read_network(it, board) + # `wiring` and unknown scopes are intentionally ignored for now. + + return board diff --git a/src/freeroute/dsn/sexp.py b/src/freeroute/dsn/sexp.py new file mode 100644 index 0000000..8481d4b --- /dev/null +++ b/src/freeroute/dsn/sexp.py @@ -0,0 +1,101 @@ +"""Parse a flat token stream into a nested S-expression tree. + +A DSN file is one top-level list ``(pcb ...)``. Each list's first element is +normally a keyword atom (its *head*). This module builds a generic tree; the +semantic interpretation (layers, nets, shapes ...) happens in :mod:`reader`, +which walks the tree scope by scope the way FreeRouting's ``ScopeKeyword`` +recursive-descent readers walk the token stream. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .tokenizer import DsnSyntaxError, Token, TokenKind, tokenize + +__all__ = ["SExp", "parse", "parse_tokens"] + + +@dataclass +class SExp: + """A parenthesised list. + + ``head`` is the text of the first token when it is an atom (e.g. ``pcb``, + ``layer``, ``net``), else ``None``. ``items`` holds the remaining elements, + each either a :class:`~freeroute.dsn.tokenizer.Token` (a leaf atom/string) + or a nested :class:`SExp`. + """ + + head: str | None + items: list[Token | SExp] = field(default_factory=list) + line: int = 0 + + def children(self, head: str) -> list[SExp]: + """Return all direct sub-lists whose head equals ``head``.""" + return [it for it in self.items if isinstance(it, SExp) and it.head == head] + + def child(self, head: str) -> SExp | None: + """Return the first direct sub-list with the given head, or ``None``.""" + for it in self.items: + if isinstance(it, SExp) and it.head == head: + return it + return None + + def values(self) -> list[Token]: + """Return the leaf-token items (skipping nested lists).""" + return [it for it in self.items if isinstance(it, Token)] + + +def parse(text: str, quote_chars: str = "\"'") -> SExp: + """Tokenize and parse ``text``, returning the single top-level S-expression.""" + return parse_tokens(tokenize(text, quote_chars)) + + +def parse_tokens(tokens: list[Token]) -> SExp: + """Parse a token list into one top-level :class:`SExp`. + + Raises :class:`DsnSyntaxError` on unbalanced brackets or trailing junk. + """ + pos = 0 + n = len(tokens) + + def parse_list() -> SExp: + nonlocal pos + open_tok = tokens[pos] # the '(' + pos += 1 + head: str | None = None + items: list[Token | SExp] = [] + # A leading atom is the scope head. Heads are keywords, which the DSN + # grammar matches case-insensitively (JFlex `%ignorecase`), so `(PCB` + # and `(pcb` are the same scope — normalize to lowercase for dispatch. + if pos < n and tokens[pos].kind is TokenKind.ATOM: + head = tokens[pos].text.lower() + pos += 1 + while pos < n and tokens[pos].kind is not TokenKind.RPAREN: + tok = tokens[pos] + if tok.kind is TokenKind.LPAREN: + items.append(parse_list()) + else: + items.append(tok) + pos += 1 + if pos >= n: + raise DsnSyntaxError(f"unbalanced '(' opened at line {open_tok.line}: missing ')'") + pos += 1 # consume ')' + return SExp(head=head, items=items, line=open_tok.line) + + # Skip to the first '('. + while pos < n and tokens[pos].kind is not TokenKind.LPAREN: + pos += 1 + if pos >= n: + raise DsnSyntaxError("no S-expression found (expected a top-level '(')") + + top = parse_list() + + # Allow trailing whitespace-only tokens (there are none — tokenizer drops + # whitespace) but reject any further real tokens. + if pos != n: + extra = tokens[pos] + raise DsnSyntaxError( + f"unexpected token '{extra.text}' after top-level list at line {extra.line}" + ) + return top diff --git a/src/freeroute/dsn/shapes.py b/src/freeroute/dsn/shapes.py new file mode 100644 index 0000000..61171af --- /dev/null +++ b/src/freeroute/dsn/shapes.py @@ -0,0 +1,215 @@ +"""Specctra DSN shape primitives and their parsing. + +Mirrors FreeRouting's ``io/specctra/parser/Shape.java`` grammar: + +* ``(rect x1 y1 x2 y2)`` +* ``(circle diameter [center_x center_y])`` +* ``(polygon aperture_width x1 y1 x2 y2 ...)`` — a closed polygon +* ``(path width x1 y1 x2 y2 ...)`` — an open path / trace + +An *area* (``Shape.read_area_scope``) is an optional name, a border shape, zero +or more ``(window )`` holes, and an optional ``(clearance_class name)``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .sexp import SExp +from .tokenizer import Token, TokenKind + +__all__ = [ + "Shape", + "Rectangle", + "Circle", + "Polygon", + "Path", + "Area", + "read_shape", + "read_area", +] + + +@dataclass +class Shape: + """Base class for a DSN shape. ``layer`` is the layer name (or ``pcb`` / + ``signal`` for the special layer-spanning pseudo-layers).""" + + layer: str + + +@dataclass +class Rectangle(Shape): + """Axis-parallel rectangle. ``coords`` is ``[x1, y1, x2, y2]``.""" + + coords: list[float] = field(default_factory=list) + + +@dataclass +class Circle(Shape): + """Circle of the given ``diameter`` centred at ``(center_x, center_y)``.""" + + diameter: float = 0.0 + center_x: float = 0.0 + center_y: float = 0.0 + + +@dataclass +class Polygon(Shape): + """Closed polygon. ``aperture_width`` is the boundary aperture (usually 0). + ``coords`` is a flat ``[x1, y1, x2, y2, ...]`` list.""" + + aperture_width: float = 0.0 + coords: list[float] = field(default_factory=list) + + +@dataclass +class Path(Shape): + """Open poly-path of the given ``width``. ``coords`` is flat + ``[x1, y1, x2, y2, ...]`` — this is how traces (wires) are described.""" + + width: float = 0.0 + coords: list[float] = field(default_factory=list) + + +@dataclass +class Area: + """A border shape plus optional holes — the result of an area scope. + + ``name`` and ``clearance_class`` are optional (``None`` when absent), exactly + like FreeRouting's ``ReadAreaScopeResult``. + """ + + border: Shape | None + holes: list[Shape] = field(default_factory=list) + name: str | None = None + clearance_class: str | None = None + + @property + def layer(self) -> str | None: + return self.border.layer if self.border is not None else None + + +def _numbers(items: list[Token | SExp]) -> list[float]: + """Collect the numeric leaf tokens from a list, skipping nested scopes. + + FreeRouting's polygon/path readers skip unknown ``( ... )`` sub-scopes that + appear amid the coordinate list, so we drop nested ``SExp`` items here too. + """ + out: list[float] = [] + for it in items: + if isinstance(it, Token) and it.is_number: + out.append(float(it.text)) + return out + + +def _layer_name(tok: Token | SExp | None) -> str: + if isinstance(tok, Token): + return tok.text + return "signal" + + +def read_shape(node: SExp) -> Shape | None: + """Build a :class:`Shape` from a shape scope such as ``(rect ...)``. + + Returns ``None`` for an unrecognised head, matching FreeRouting's tolerant + ``Shape.read_scope`` (which skips non-shape scopes). + """ + head = node.head + items = node.items + if not items: + return None + layer = _layer_name(items[0]) + rest = items[1:] + + if head in ("rect", "rectangle"): + nums = _numbers(rest) + if len(nums) < 4: + return None + return Rectangle(layer=layer, coords=nums[:4]) + + if head in ("circle", "circ"): + nums = _numbers(rest) + if not nums: + return None + diameter = nums[0] + cx = nums[1] if len(nums) > 1 else 0.0 + cy = nums[2] if len(nums) > 2 else 0.0 + return Circle(layer=layer, diameter=diameter, center_x=cx, center_y=cy) + + if head in ("polygon", "poly"): + nums = _numbers(rest) + if not nums: + return None + return Polygon(layer=layer, aperture_width=nums[0], coords=nums[1:]) + + if head in ("path", "polygon_path", "polyline_path"): + nums = _numbers(rest) + if len(nums) < 5: # width + at least two points + return None + return Path(layer=layer, width=nums[0], coords=nums[1:]) + + return None + + +def read_area(node: SExp) -> Area | None: + """Build an :class:`Area` from a keepout/plane-style scope. + + ``node`` is e.g. ``(keepout NAME (rect ...) (window (circle ...)) + (clearance_class cls))``. The head has already been consumed by the caller; + ``node.items`` is the body. An optional leading string is the area name. + """ + items = node.items + idx = 0 + name: str | None = None + if items and isinstance(items[0], Token) and items[0].kind is TokenKind.STRING: + name = items[0].text or None + idx = 1 + elif ( + items + and isinstance(items[0], Token) + and items[0].kind is TokenKind.ATOM + and not items[0].is_number + ): + # An unquoted bareword name (FreeRouting accepts a String here too). + name = items[0].text or None + idx = 1 + + border: Shape | None = None + holes: list[Shape] = [] + clearance_class: str | None = None + + for it in items[idx:]: + if not isinstance(it, SExp): + continue + if it.head in ( + "rect", + "rectangle", + "circle", + "circ", + "polygon", + "poly", + "path", + "polygon_path", + "polyline_path", + ): + shape = read_shape(it) + if border is None: + border = shape + else: # a bare extra shape without a window wrapper + if shape is not None: + holes.append(shape) + elif it.head == "window": + for sub in it.items: + if isinstance(sub, SExp): + hole = read_shape(sub) + if hole is not None: + holes.append(hole) + elif it.head == "clearance_class": + vals = it.values() + if vals: + clearance_class = vals[0].text + + if border is None: + return None + return Area(border=border, holes=holes, name=name, clearance_class=clearance_class) diff --git a/src/freeroute/dsn/tokenizer.py b/src/freeroute/dsn/tokenizer.py new file mode 100644 index 0000000..1238a53 --- /dev/null +++ b/src/freeroute/dsn/tokenizer.py @@ -0,0 +1,191 @@ +"""Tokenizer for the Specctra DSN/SES S-expression format. + +Mirrors the lexical rules in FreeRouting's ``SpecctraFileDescription.flex`` +(the human-readable source of the JFlex-generated +``SpecctraDsnStreamReader``). We deliberately do not reproduce the generated +DFA — the flex rules are what define the language: + +* whitespace is ``\\r \\n \\f \\t`` and space, +* ``#`` starts an end-of-line comment, ``/* ... */`` a block comment, +* ``"`` and ``'`` quote strings; there is no escaping — a backslash inside a + quoted string is a literal backslash and the matching quote ends it, +* ``(`` and ``)`` are their own tokens, +* every other maximal run of characters is an atom. + +FreeRouting's lexer flips between lexical states (``NAME``, ``LAYER_NAME`` ...) +so that the *next* atom is read as a string even when it looks like a number +(a net literally named ``0``). We reconcile that stateful behaviour with a +stateless tokenizer by keeping both the raw text and a best-effort numeric +value on every unquoted atom (see :meth:`Token.as_int` / :meth:`Token.as_float`) +and letting the higher-level reader choose which to use. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum, auto + +__all__ = ["Token", "TokenKind", "tokenize", "DsnSyntaxError"] + +_WHITESPACE = " \t\r\n\f" +_QUOTES = "\"'" +_BRACKETS = "()" + + +class DsnSyntaxError(ValueError): + """Raised when the input cannot be tokenized (e.g. an unterminated string).""" + + +class TokenKind(Enum): + LPAREN = auto() + RPAREN = auto() + ATOM = auto() # unquoted; may be a number or a bareword + STRING = auto() # quoted; always a string + + +@dataclass(frozen=True) +class Token: + """A single lexical token. + + ``text`` is the literal characters (without surrounding quotes for a + :attr:`TokenKind.STRING`). ``line`` is 1-based for error messages. + """ + + kind: TokenKind + text: str + line: int + + @property + def is_value(self) -> bool: + """True for atoms and strings — anything that is not a bracket.""" + return self.kind in (TokenKind.ATOM, TokenKind.STRING) + + def as_int(self) -> int | None: + """Return the token as an int, or ``None`` if it is not an integer atom.""" + if self.kind is not TokenKind.ATOM: + return None + try: + return int(self.text) + except ValueError: + return None + + def as_float(self) -> float | None: + """Return the token as a float, or ``None`` if it is not a numeric atom. + + Accepts integer atoms too, matching FreeRouting's ``read_float_scope`` + which takes either an ``Integer`` or a ``Double``. + """ + if self.kind is not TokenKind.ATOM: + return None + try: + return float(self.text) + except ValueError: + return None + + @property + def is_number(self) -> bool: + return self.as_float() is not None + + +def tokenize(text: str, quote_chars: str = _QUOTES) -> list[Token]: + """Tokenize DSN/SES ``text`` into a flat list of :class:`Token`. + + ``quote_chars`` is the set of characters that open/close a quoted string. + It defaults to both ``"`` and ``'``; a reader that has seen a + ``(string_quote ...)`` directive can retokenize with just that character, + but in practice KiCad always uses ``"`` and both are safe defaults. + """ + tokens: list[Token] = [] + i = 0 + n = len(text) + line = 1 + # Mirrors FreeRouting's IGNORE_QUOTE lexical state: after the + # `string_quote` keyword the following value is read with quote characters + # treated as ordinary atom characters, so `(string_quote ")` yields the + # atom `"` rather than opening an unterminated string. + ignore_quote_next = False + + while i < n: + ch = text[i] + + if ch == "\n": + line += 1 + i += 1 + continue + if ch in _WHITESPACE: + i += 1 + continue + + # Comments ----------------------------------------------------------- + # `#` starts an end-of-line comment only when followed by whitespace + # (the conventional `# text` form). A `#` glued to a name — e.g. the + # net `#WLTXD` or pin `#22` — is a name character: FreeRouting reads + # those in its NAME lexical state where `#` is not a comment, and its + # `#` is also a valid identifier character (SpecCharASCII). + if ch == "#" and (i + 1 >= n or text[i + 1] in _WHITESPACE): + eol = text.find("\n", i) + i = n if eol == -1 else eol + continue + if ch == "/" and i + 1 < n and text[i + 1] == "*": # block comment + end = text.find("*/", i + 2) + if end == -1: + raise DsnSyntaxError(f"unterminated /* */ comment at line {line}") + line += text.count("\n", i, end) + i = end + 2 + continue + + # Brackets ----------------------------------------------------------- + if ch == "(": + tokens.append(Token(TokenKind.LPAREN, "(", line)) + i += 1 + continue + if ch == ")": + tokens.append(Token(TokenKind.RPAREN, ")", line)) + i += 1 + continue + + # Quoted string ------------------------------------------------------ + if ch in quote_chars and not ignore_quote_next: + start_line = line + j = i + 1 + buf: list[str] = [] + while j < n and text[j] != ch: + if text[j] == "\n": + line += 1 + buf.append(text[j]) + j += 1 + if j >= n: + raise DsnSyntaxError(f"unterminated string starting at line {start_line}") + tokens.append(Token(TokenKind.STRING, "".join(buf), start_line)) + i = j + 1 + continue + + # Bareword atom ------------------------------------------------------ + # A bareword runs to the next whitespace, bracket, or quote character. + # Stopping at quotes matters for `space_in_quoted_tokens` pin refs such + # as `"comp"-"pin with spaces"`: the second quoted segment must be read + # as its own string, otherwise a `#` inside it would start a spurious + # end-of-line comment and swallow the closing brackets. In + # ignore-quote mode (right after `string_quote`) quotes are ordinary + # characters, so `(string_quote ")` yields the atom `"`. + j = i + if ignore_quote_next: + while j < n and text[j] not in _WHITESPACE and text[j] not in _BRACKETS: + j += 1 + else: + while ( + j < n + and text[j] not in _WHITESPACE + and text[j] not in _BRACKETS + and text[j] not in quote_chars + ): + j += 1 + atom = text[i:j] + tokens.append(Token(TokenKind.ATOM, atom, line)) + i = j + if ignore_quote_next: + ignore_quote_next = False + elif atom.lower() == "string_quote": + ignore_quote_next = True + + return tokens diff --git a/tests/dsn/fixtures/README.md b/tests/dsn/fixtures/README.md new file mode 100644 index 0000000..076a646 --- /dev/null +++ b/tests/dsn/fixtures/README.md @@ -0,0 +1,19 @@ +# DSN test fixtures + +Hand-crafted minimal Specctra DSN files, written to exercise the parser. They +are modeled on the real fixtures shipped in the upstream FreeRouting repo +(`fixtures/empty_board.dsn` and `fixtures/SMD-routing-issue-demo.dsn`) so the +grammar and formatting match what `kicad-cli pcb export specctra-dsn` and +FreeRouting itself produce, but they were typed fresh here (no upstream file is +copied into this tree). + +- `empty_board.dsn` — the smallest valid board: parser, resolution, unit, and a + structure with two signal layers and a `pcb` boundary path. Modeled on + FreeRouting `fixtures/empty_board.dsn`. +- `smd_demo.dsn` — a fuller board exercising every scope the parser handles: + quoted board/padstack/image names, `via`, default `rule` (width + clearance + with a `(type smd_smd)` pair), a `keepout`, padstacks (rect and circle pads), + images with pins, placement with `front`/rotation, nets with `Comp-Pin` + references, and a `class` with `circuit`/`use_via` and a `rule`. Modeled on + FreeRouting `fixtures/SMD-routing-issue-demo.dsn`. + diff --git a/tests/dsn/fixtures/empty_board.dsn b/tests/dsn/fixtures/empty_board.dsn new file mode 100644 index 0000000..c14fd6a --- /dev/null +++ b/tests/dsn/fixtures/empty_board.dsn @@ -0,0 +1,26 @@ +(pcb freeroute-empty.dsn + (parser + (string_quote ") + (space_in_quoted_tokens on) + ) + (resolution um 10) + (unit um) + (structure + (layer F.Cu + (type signal) + (property + (index 0) + ) + ) + (layer B.Cu + (type signal) + (property + (index 1) + ) + ) + (boundary + (path pcb 0 186690 -107950 129540 -107950 129540 -57150 186690 -57150 + 186690 -107950) + ) + ) +) diff --git a/tests/dsn/fixtures/smd_demo.dsn b/tests/dsn/fixtures/smd_demo.dsn new file mode 100644 index 0000000..70953d5 --- /dev/null +++ b/tests/dsn/fixtures/smd_demo.dsn @@ -0,0 +1,84 @@ +(pcb "smd-demo.dsn" + (parser + (string_quote ") + (space_in_quoted_tokens on) + (host_cad "freeroute-test") + (host_version "1.0") + ) + (resolution um 10) + (unit um) + (structure + (layer Top + (type signal) + (property + (index 0) + ) + ) + (layer Bottom + (type signal) + (property + (index 1) + ) + ) + (boundary + (path pcb 0 0 0 150000 0 150000 -90000 0 -90000 0 0) + ) + (via "Via[0-1]_600:300_um") + (rule + (width 200) + (clearance 200) + (clearance 50 (type smd_smd)) + ) + (keepout "no_route_zone" + (rect Top 10000 -10000 20000 -20000) + ) + ) + (library + (padstack Rect[T]Pad_800x200_um + (shape (rect Top -400 -100 400 100)) + (attach off) + ) + (padstack "Via[0-1]_600:300_um" + (shape (circle Top 600)) + (shape (circle Bottom 600)) + (attach off) + ) + (image "MiniQFN-6" + (pin Rect[T]Pad_800x200_um 1 -3000 400) + (pin Rect[T]Pad_800x200_um 2 -3000 0) + (pin Rect[T]Pad_800x200_um 3 -3000 -400) + ) + (image "0603" + (pin Rect[T]Pad_800x200_um 1 -850 0) + (pin Rect[T]Pad_800x200_um 2 850 0) + ) + ) + (placement + (component "MiniQFN-6" + (place U1 75000 -45000 front 90.000000) + ) + (component "0603" + (place R1 20000 -35000 front 0.000000) + (place R2 130000 -35000 back 180.000000) + ) + ) + (network + (net NET_A + (pins U1-1 R2-2) + ) + (net NET_B + (pins U1-2 R1-1) + ) + (class default + (circuit + (use_via "Via[0-1]_600:300_um") + ) + (rule + (width 200) + (clearance 200) + ) + ) + ) + (wiring + ) +) diff --git a/tests/dsn/test_reader.py b/tests/dsn/test_reader.py new file mode 100644 index 0000000..e2f2b31 --- /dev/null +++ b/tests/dsn/test_reader.py @@ -0,0 +1,158 @@ +"""End-to-end tests for parse_dsn against fixture files and inline snippets.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from freeroute.dsn import parse_dsn +from freeroute.dsn.reader import DsnParseError +from freeroute.dsn.shapes import Circle, Rectangle +from freeroute.dsn.shapes import Path as DsnPath + +FIXTURES = Path(__file__).parent / "fixtures" + + +def load(name: str) -> str: + return (FIXTURES / name).read_text() + + +# --- empty_board.dsn --------------------------------------------------------- + + +def test_empty_board_header_and_resolution(): + board = parse_dsn(load("empty_board.dsn")) + assert board.name == "freeroute-empty.dsn" + assert board.parser.string_quote == '"' + assert board.resolution.unit == "um" + assert board.resolution.value == 10 + assert board.unit == "um" + + +def test_empty_board_layers(): + board = parse_dsn(load("empty_board.dsn")) + assert board.layer_names() == ["F.Cu", "B.Cu"] + assert all(layer.is_signal for layer in board.layers) + assert [layer.index for layer in board.layers] == [0, 1] + + +def test_empty_board_boundary_is_pcb_path(): + board = parse_dsn(load("empty_board.dsn")) + assert isinstance(board.boundary, DsnPath) + assert board.boundary.layer == "pcb" + # closed rectangle: 5 points (10 coords), width prefix stripped + assert len(board.boundary.coords) == 10 + assert board.outlines == [] + + +# --- smd_demo.dsn ------------------------------------------------------------ + + +@pytest.fixture +def smd(): + return parse_dsn(load("smd_demo.dsn")) + + +def test_smd_parser_scope(smd): + assert smd.name == "smd-demo.dsn" + assert smd.parser.host_cad == "freeroute-test" + assert smd.parser.host_version == "1.0" + + +def test_smd_structure_via_and_rules(smd): + assert smd.via_padstack_names == ["Via[0-1]_600:300_um"] + widths = [r.value for r in smd.structure_rules.width_rules] + assert widths == [200.0] + clearances = smd.structure_rules.clearance_rules + assert clearances[0].value == 200.0 + assert clearances[0].class_pairs == [] + assert clearances[1].value == 50.0 + assert clearances[1].class_pairs == ["smd_smd"] + + +def test_smd_keepout(smd): + assert len(smd.keepouts) == 1 + ko = smd.keepouts[0] + assert ko.kind == "keepout" + assert ko.area.name == "no_route_zone" + assert isinstance(ko.area.border, Rectangle) + + +def test_smd_padstacks(smd): + assert [p.name for p in smd.padstacks] == [ + "Rect[T]Pad_800x200_um", + "Via[0-1]_600:300_um", + ] + rect_pad = smd.padstack("Rect[T]Pad_800x200_um") + assert isinstance(rect_pad.shapes[0], Rectangle) + assert rect_pad.attach_allowed is False + + via_pad = smd.padstack("Via[0-1]_600:300_um") + assert len(via_pad.shapes) == 2 + assert all(isinstance(s, Circle) for s in via_pad.shapes) + assert [s.layer for s in via_pad.shapes] == ["Top", "Bottom"] + + +def test_smd_images_and_pins(smd): + assert [img.name for img in smd.images] == ["MiniQFN-6", "0603"] + qfn = smd.images[0] + assert qfn.is_front is True + assert len(qfn.pins) == 3 + pin1 = qfn.pins[0] + assert pin1.name == "1" + assert pin1.padstack_name == "Rect[T]Pad_800x200_um" + assert (pin1.x, pin1.y) == (-3000.0, 400.0) + + +def test_smd_placement(smd): + comps = {p.lib_name: p for p in smd.placements} + assert set(comps) == {"MiniQFN-6", "0603"} + u1 = comps["MiniQFN-6"].places[0] + assert u1.name == "U1" + assert (u1.x, u1.y) == (75000.0, -45000.0) + assert u1.is_front is True + assert u1.rotation == 90.0 + + r2 = comps["0603"].places[1] + assert r2.name == "R2" + assert r2.is_front is False + assert r2.rotation == 180.0 + + +def test_smd_nets_and_pin_refs(smd): + assert [n.name for n in smd.nets] == ["NET_A", "NET_B"] + net_a = smd.net("NET_A") + assert [(p.component, p.pin) for p in net_a.pins] == [("U1", "1"), ("R2", "2")] + + +def test_smd_net_class(smd): + assert len(smd.net_classes) == 1 + cls = smd.net_classes[0] + assert cls.name == "default" + assert cls.use_via == ["Via[0-1]_600:300_um"] + assert [r.value for r in cls.width_rules] == [200.0] + + +# --- error handling ---------------------------------------------------------- + + +def test_non_pcb_top_scope_raises(): + with pytest.raises(DsnParseError): + parse_dsn("(session foo)") + + +def test_pin_ref_splits_on_first_hyphen(): + dsn = "(pcb b (network (net N (pins A-B-C X-1))))" + board = parse_dsn(dsn) + pins = board.net("N").pins + assert (pins[0].component, pins[0].pin) == ("A", "B-C") + assert (pins[1].component, pins[1].pin) == ("X", "1") + + +def test_unplaced_component_has_no_coords(): + dsn = '(pcb b (placement (component "LIB" (place U9))))' + board = parse_dsn(dsn) + place = board.placements[0].places[0] + assert place.name == "U9" + assert place.x is None and place.y is None diff --git a/tests/dsn/test_sexp.py b/tests/dsn/test_sexp.py new file mode 100644 index 0000000..14214e4 --- /dev/null +++ b/tests/dsn/test_sexp.py @@ -0,0 +1,60 @@ +"""Tests for the S-expression tree parser.""" + +from __future__ import annotations + +import pytest + +from freeroute.dsn.sexp import SExp, parse +from freeroute.dsn.tokenizer import DsnSyntaxError, Token + + +def test_parse_simple_head_and_values(): + tree = parse("(resolution um 10)") + assert tree.head == "resolution" + vals = tree.values() + assert [v.text for v in vals] == ["um", "10"] + + +def test_nested_children(): + tree = parse("(structure (layer Top (type signal)) (layer Bot (type signal)))") + assert tree.head == "structure" + layers = tree.children("layer") + assert len(layers) == 2 + assert isinstance(layers[0], SExp) + assert layers[0].values()[0].text == "Top" + type_scope = layers[0].child("type") + assert type_scope is not None + assert type_scope.values()[0].text == "signal" + + +def test_child_missing_returns_none(): + tree = parse("(a (b 1))") + assert tree.child("nope") is None + + +def test_values_skips_nested_lists(): + tree = parse("(net NET_A (pins U1-1 R2-2))") + assert [v.text for v in tree.values()] == ["NET_A"] + pins = tree.child("pins") + assert pins is not None + assert [v.text for v in pins.values()] == ["U1-1", "R2-2"] + + +def test_leaf_items_are_tokens(): + tree = parse("(a 1 2)") + assert all(isinstance(it, Token) for it in tree.items) + + +def test_unbalanced_raises(): + with pytest.raises(DsnSyntaxError): + parse("(a (b )") + + +def test_trailing_token_raises(): + with pytest.raises(DsnSyntaxError): + parse("(a) (b)") + + +def test_no_toplevel_list_raises(): + with pytest.raises(DsnSyntaxError): + parse("just atoms no parens") diff --git a/tests/dsn/test_shapes.py b/tests/dsn/test_shapes.py new file mode 100644 index 0000000..3858de9 --- /dev/null +++ b/tests/dsn/test_shapes.py @@ -0,0 +1,74 @@ +"""Tests for DSN shape parsing.""" + +from __future__ import annotations + +from freeroute.dsn.sexp import parse +from freeroute.dsn.shapes import ( + Circle, + Path, + Polygon, + Rectangle, + read_area, + read_shape, +) + + +def shape_of(text: str): + return read_shape(parse(text)) + + +def test_rectangle(): + rect = shape_of("(rect Top -400 -100 400 100)") + assert isinstance(rect, Rectangle) + assert rect.layer == "Top" + assert rect.coords == [-400.0, -100.0, 400.0, 100.0] + + +def test_circle_with_center(): + circ = shape_of("(circle Bottom 600 10 20)") + assert isinstance(circ, Circle) + assert circ.diameter == 600.0 + assert (circ.center_x, circ.center_y) == (10.0, 20.0) + + +def test_circle_without_center_defaults_to_origin(): + circ = shape_of("(circle Top 600)") + assert isinstance(circ, Circle) + assert (circ.center_x, circ.center_y) == (0.0, 0.0) + + +def test_polygon(): + poly = shape_of("(polygon signal 0 0 0 100 0 100 100 0 100)") + assert isinstance(poly, Polygon) + assert poly.aperture_width == 0.0 + assert poly.coords == [0.0, 0.0, 100.0, 0.0, 100.0, 100.0, 0.0, 100.0] + + +def test_path_is_width_plus_points(): + path = shape_of("(path pcb 0 0 0 150000 0 150000 -90000)") + assert isinstance(path, Path) + assert path.width == 0.0 + assert path.coords == [0.0, 0.0, 150000.0, 0.0, 150000.0, -90000.0] + + +def test_unknown_shape_returns_none(): + assert shape_of("(mystery Top 1 2)") is None + + +def test_area_border_name_and_clearance(): + area = read_area(parse('(keepout "kz" (rect Top 0 0 10 10) (clearance_class special))')) + assert area is not None + assert area.name == "kz" + assert isinstance(area.border, Rectangle) + assert area.clearance_class == "special" + assert area.holes == [] + + +def test_area_with_window_hole(): + area = read_area( + parse("(keepout (polygon Top 0 0 0 0 100 100 100 100 0) (window (rect Top 10 10 20 20)))") + ) + assert area is not None + assert isinstance(area.border, Polygon) + assert len(area.holes) == 1 + assert isinstance(area.holes[0], Rectangle) diff --git a/tests/dsn/test_tokenizer.py b/tests/dsn/test_tokenizer.py new file mode 100644 index 0000000..f6bb8f4 --- /dev/null +++ b/tests/dsn/test_tokenizer.py @@ -0,0 +1,126 @@ +"""Tests for the DSN tokenizer.""" + +from __future__ import annotations + +import pytest + +from freeroute.dsn.tokenizer import DsnSyntaxError, TokenKind, tokenize + + +def kinds(text: str) -> list[TokenKind]: + return [t.kind for t in tokenize(text)] + + +def texts(text: str) -> list[str]: + return [t.text for t in tokenize(text)] + + +def test_brackets_and_atoms(): + toks = tokenize("(pcb board)") + assert [t.kind for t in toks] == [ + TokenKind.LPAREN, + TokenKind.ATOM, + TokenKind.ATOM, + TokenKind.RPAREN, + ] + assert texts("(pcb board)") == ["(", "pcb", "board", ")"] + + +def test_numbers_stay_atoms_but_expose_values(): + toks = tokenize("12 -3 4.5 -0.25 1e3") + assert all(t.kind is TokenKind.ATOM for t in toks) + assert toks[0].as_int() == 12 + assert toks[1].as_int() == -3 + assert toks[2].as_int() is None + assert toks[2].as_float() == 4.5 + assert toks[3].as_float() == -0.25 + assert toks[4].as_float() == 1000.0 + + +def test_double_quoted_string(): + toks = tokenize('(host_cad "KiCad EDA")') + assert toks[2].kind is TokenKind.STRING + assert toks[2].text == "KiCad EDA" + + +def test_single_quoted_string(): + toks = tokenize("(x 'a b c')") + assert toks[2].kind is TokenKind.STRING + assert toks[2].text == "a b c" + + +def test_string_quote_directive_reads_quote_as_atom(): + # `(string_quote ")` — FreeRouting's IGNORE_QUOTE state reads the lone quote + # char after `string_quote` as a literal atom, not as an (unterminated) + # string opener. + toks = tokenize('(string_quote ")') + assert [t.kind for t in toks] == [ + TokenKind.LPAREN, + TokenKind.ATOM, + TokenKind.ATOM, + TokenKind.RPAREN, + ] + assert toks[2].text == '"' + + +def test_quotes_still_open_strings_normally(): + toks = tokenize('(host_cad "KiCad")') + assert toks[2].kind is TokenKind.STRING + assert toks[2].text == "KiCad" + + +def test_atom_may_contain_special_chars(): + toks = tokenize("Via[0-1]_600:300_um") + assert len(toks) == 1 + assert toks[0].text == "Via[0-1]_600:300_um" + + +def test_quote_stops_a_bareword_and_opens_a_string(): + # `space_in_quoted_tokens` pin ref: `"J3"-"GND"` is three tokens. + toks = tokenize('"J3"-"GND"') + assert [t.kind for t in toks] == [ + TokenKind.STRING, + TokenKind.ATOM, + TokenKind.STRING, + ] + assert [t.text for t in toks] == ["J3", "-", "GND"] + + +def test_hash_inside_quoted_string_is_not_a_comment(): + toks = tokenize('"_VBUS #22"') + assert toks[0].kind is TokenKind.STRING + assert toks[0].text == "_VBUS #22" + + +def test_line_comment_ignored(): + assert texts("# a comment\n(a)") == ["(", "a", ")"] + + +def test_block_comment_ignored(): + assert texts("(a /* skip me */ b)") == ["(", "a", "b", ")"] + + +def test_hash_glued_to_name_is_an_atom_not_a_comment(): + # `#WLTXD` is a net name, not a comment (FreeRouting reads it in NAME state). + assert texts("(net #WLTXD)") == ["(", "net", "#WLTXD", ")"] + assert texts("#22") == ["#22"] + + +def test_line_tracking(): + toks = tokenize("(a\n b\n c)") + lines = {t.text: t.line for t in toks if t.kind is TokenKind.ATOM} + assert lines == {"a": 1, "b": 2, "c": 3} + + +def test_unterminated_string_raises(): + with pytest.raises(DsnSyntaxError): + tokenize('(a "no end') + + +def test_unterminated_block_comment_raises(): + with pytest.raises(DsnSyntaxError): + tokenize("(a /* no end") + + +def test_hyphenated_pin_ref_is_single_atom(): + assert texts("U1-1 R2-2") == ["U1-1", "R2-2"]