Implement Specctra DSN parser with typed board model
Ports the read path of FreeRouting's io/specctra/parser package to a Java-free Python implementation: - tokenizer: S-expression lexer mirroring SpecctraFileDescription.flex (comments, quoted strings, the string_quote IGNORE_QUOTE directive, case-insensitive keywords, and hash-prefixed names) - sexp: nested S-expression tree builder - shapes: rect/circle/polygon/path plus area-with-holes scopes - model: typed dataclasses for layers, padstacks, images, placements, nets, net classes, rules, keepouts - reader: recursive-descent scope readers producing a DsnBoard, one _read_* function per FreeRouting read_scope method 46 pytest cases cover the tokenizer, tree, shapes, and end-to-end parsing against hand-crafted fixtures modeled on FreeRouting's own test DSN files. Parses 90 of 91 upstream fixtures (the one failure is a binary OLE file, not text DSN).
This commit is contained in:
@@ -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`.
|
||||
</content>
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user