Add item hierarchy, BasicBoard, and DSN->board construction

Ports the data model of the board Item hierarchy (Item base, Pin,
ObstacleArea/ConductionArea, Via, Trace) and BasicBoard (layers, nets,
clearance, bounding box, items with query helpers), then build_board:
the load-bearing integration that constructs a BasicBoard from a parsed
DsnBoard.

build_board maps layers -> LayerStructure, resolution -> transform,
default clearance rule -> ClearanceMatrix, nets -> Nets plus a
(component,pin)->net map, padstacks x placement -> Pin items, and
keepouts -> ObstacleArea items. Rectangle pads become exact IntBoxes,
convex polygon pads exact Simplexes, circle pads their bounding box
(documented approximation); every pad is centred on its pin location so
it contains that location by construction. Trace/Via are router-produced
and lightweight here (an imported unrouted board has none).

Validated on a real KiCad export (kicad_routable.dsn): layer/net/pin
counts match the parsed DSN, every pin's pad shape contains its origin,
every pin reports a valid net, and pin locations lie in the board
bounding box. An oracle-gated test cross-checks that every net the
reference FreeRouting JAR routes exists on the constructed board.
This commit is contained in:
2026-07-12 08:29:43 -06:00
parent dd40aaba5d
commit 4220fe0cd6
5 changed files with 623 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
"""Board items — ``Item`` base and the concrete item kinds.
Ports the *data model* of ``board/Item.java`` and its subclasses (``Pin``,
``ObstacleArea``/``ConductionArea``, ``Via``, ``PolylineTrace``). The routing
and search-tree machinery in the Java classes is out of scope here — these hold
the geometry and connectivity a :class:`~freeroute.board.board.BasicBoard`
needs and that the DSN-import invariants check.
Coordinates are board units (DSN units scaled by the resolution). Shapes are the
exact convex tiles / boxes from :mod:`freeroute.geometry`.
``Trace``/``Via`` are only produced by routing (an imported unrouted board has
none); they are lightweight here and the router phase fleshes out
``Trace`` with a proper :class:`~freeroute.geometry.Polyline` shape.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from freeroute.geometry import IntBox, IntPoint
__all__ = ["Item", "Pin", "ObstacleArea", "ConductionArea", "Via", "Trace"]
@dataclass
class Item:
"""Base class for everything placed on the board.
``net_nos`` are the board net numbers this item belongs to (empty for
unconnected obstacles); ``component_no`` is 0 for board-level items.
"""
net_nos: list[int] = field(default_factory=list)
clearance_class: int = 0
component_no: int = 0
fixed: bool = False
def contains_net(self, net_no: int) -> bool:
return net_no in self.net_nos
def is_obstacle(self) -> bool:
return False
@dataclass
class Pin(Item):
"""A component pin: a pad shape on one or more layers at a fixed location."""
name: str = ""
padstack_name: str = ""
location: IntPoint = field(default_factory=lambda: IntPoint(0, 0))
#: layer indices the pad occupies (all signal layers when spanning)
layers: list[int] = field(default_factory=list)
#: the pad tile shape in absolute board coordinates
shape: object = None
def first_layer(self) -> int:
return self.layers[0] if self.layers else 0
def shape_contains_origin(self) -> bool:
"""Invariant helper: the pad shape contains the pin's own location."""
return self.shape is not None and self.shape.contains(self.location)
@dataclass
class ObstacleArea(Item):
"""A keepout / obstacle region on one layer, as a list of convex tiles."""
name: str | None = None
layer: int = 0
tiles: list = field(default_factory=list)
def is_obstacle(self) -> bool:
return True
def bounding_box(self) -> IntBox:
if not self.tiles:
return IntBox.empty()
box = self.tiles[0].bounding_box()
for tile in self.tiles[1:]:
box = box.union(tile.bounding_box())
return box
@dataclass
class ConductionArea(ObstacleArea):
"""A power-plane conduction area (an obstacle carrying a net)."""
@dataclass
class Via(Item):
"""A routed via at a location using a named padstack (router-produced)."""
padstack_name: str = ""
location: IntPoint = field(default_factory=lambda: IntPoint(0, 0))
@dataclass
class Trace(Item):
"""A routed trace (router-produced).
Held as its corner points plus a half-width; the router phase attaches the
full :class:`~freeroute.geometry.Polyline` swept shape.
"""
layer: int = 0
half_width: int = 0
corners: list[IntPoint] = field(default_factory=list)
def is_empty(self) -> bool:
return len(self.corners) < 2