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:
2026-07-11 15:21:18 -06:00
parent dc44abc7f7
commit 3bf4f6bed2
13 changed files with 1847 additions and 0 deletions
+19
View File
@@ -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>
+26
View File
@@ -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)
)
)
)
+84
View File
@@ -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
)
)
+158
View File
@@ -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
+60
View File
@@ -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")
+74
View File
@@ -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)
+126
View File
@@ -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"]