Files
rpmroute/src/freeroute/dsn/reader.py
T
rsp2k 354c89a8d5 Route with copper zones, and let vias into no-tracks areas
freeroute ignored the DSN's planes (KiCad's zones), so it never knew that
pads in a pour are joined by it. On the rc4 power stage those pads sit
inside "keep router off" areas as well, and every connection to them was
dropped without a word.

- Read (plane <net> <shape>) and put each zone on the board as a
  ConductionArea of its net. Zones are not obstacles: KiCad refills them
  around other nets' copper.
- The grid groups each net's pads and zone pieces that copper already
  joins (a pad in a zone, two zones sharing a pad) and routes only between
  groups. A connection may end on a zone by a via inside its copper, or by
  a track where tracks are allowed.
- wire_keepout now blocks traces only, as KiCad's "no tracks" rule area
  does; via_keepout only vias; keepout both, in the grid and in the exact
  verifier.

rc4 power stage, exact engine, resolution 2: 2 unconnected, down from 8
(FreeRouting JAR: 12), no DRC errors.
2026-09-27 11:54:04 -06:00

537 lines
18 KiB
Python

"""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 == "wire_keepout":
# KiCad writes a "no tracks" rule area this way (vias allowed).
area = read_area(it)
if area is not None:
board.keepouts.append(Keepout(area=area, kind="wire_keepout"))
elif head == "plane":
# (plane <net> <shape>): KiCad exports each zone this way
plane = _read_plane(it)
if plane is not None:
board.planes.append(plane)
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 == "wire_keepout":
area = read_area(it)
if area is not None:
image.wire_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
def _read_plane(node: SExp):
"""``(plane <net> (polygon <layer> <width> x y ...))`` -> :class:`Plane`."""
from .model import Plane
items = node.items
if not items or not isinstance(items[0], Token):
return None
net = items[0].text
for sub in items[1:]:
if isinstance(sub, SExp):
shape = read_shape(sub)
if shape is not None:
return Plane(net=net, shape=shape)
return None