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:
parent
dc44abc7f7
commit
3bf4f6bed2
69
src/freeroute/dsn/__init__.py
Normal file
69
src/freeroute/dsn/__init__.py
Normal file
@ -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",
|
||||||
|
]
|
||||||
218
src/freeroute/dsn/model.py
Normal file
218
src/freeroute/dsn/model.py
Normal 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
|
||||||
506
src/freeroute/dsn/reader.py
Normal file
506
src/freeroute/dsn/reader.py
Normal file
@ -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 <padstack> [ (rotate d) ] <name> 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 <refdes> 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
|
||||||
101
src/freeroute/dsn/sexp.py
Normal file
101
src/freeroute/dsn/sexp.py
Normal file
@ -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
|
||||||
215
src/freeroute/dsn/shapes.py
Normal file
215
src/freeroute/dsn/shapes.py
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
"""Specctra DSN shape primitives and their parsing.
|
||||||
|
|
||||||
|
Mirrors FreeRouting's ``io/specctra/parser/Shape.java`` grammar:
|
||||||
|
|
||||||
|
* ``(rect <layer> x1 y1 x2 y2)``
|
||||||
|
* ``(circle <layer> diameter [center_x center_y])``
|
||||||
|
* ``(polygon <layer> aperture_width x1 y1 x2 y2 ...)`` — a closed polygon
|
||||||
|
* ``(path <layer> 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 <shape>)`` 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)
|
||||||
191
src/freeroute/dsn/tokenizer.py
Normal file
191
src/freeroute/dsn/tokenizer.py
Normal file
@ -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
|
||||||
19
tests/dsn/fixtures/README.md
Normal file
19
tests/dsn/fixtures/README.md
Normal 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
tests/dsn/fixtures/empty_board.dsn
Normal file
26
tests/dsn/fixtures/empty_board.dsn
Normal 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
tests/dsn/fixtures/smd_demo.dsn
Normal file
84
tests/dsn/fixtures/smd_demo.dsn
Normal 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
tests/dsn/test_reader.py
Normal file
158
tests/dsn/test_reader.py
Normal 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
tests/dsn/test_sexp.py
Normal file
60
tests/dsn/test_sexp.py
Normal 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
tests/dsn/test_shapes.py
Normal file
74
tests/dsn/test_shapes.py
Normal 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
tests/dsn/test_tokenizer.py
Normal file
126
tests/dsn/test_tokenizer.py
Normal 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"]
|
||||||
Loading…
x
Reference in New Issue
Block a user