"""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 ): 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 [ (rotate d) ] 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 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 (polygon 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