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