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.
537 lines
18 KiB
Python
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
|