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,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
|
||||
Reference in New Issue
Block a user