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
+218
View File
@@ -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 <unit> <value>)`` 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 <padstack> [ (rotate d) ] <name> 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 <refdes> 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 <libname> (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 <name> [subnet] (pins ...))`` entry."""
name: str
subnet: int = 1
pins: list[NetPin] = field(default_factory=list)
@dataclass
class NetClass:
"""A ``(class <name> 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