diff --git a/src/freeroute/ses/writer.py b/src/freeroute/ses/writer.py index 63138fe..8c601d0 100644 --- a/src/freeroute/ses/writer.py +++ b/src/freeroute/ses/writer.py @@ -44,6 +44,30 @@ def _iround(value: float) -> int: return math.floor(value + 0.5) +# Millimetres per Specctra unit name. +_UNIT_MM = {"inch": 25.4, "mil": 0.0254, "mm": 1.0, "cm": 10.0, "um": 0.001} + + +def ses_scale(board: DsnBoard) -> float: + """Factor from DSN coordinates to SES coordinates. + + DSN coordinates are in the DSN's ``(unit ...)``; SES coordinates are in + resolution units, 1/value of the resolution unit, as FreeRouting's + SesWriter writes them. For kicad-cli's ``(resolution um 10) (unit um)`` + that is x10: a pad at 6500 um in the DSN is 65000 in the SES. + """ + res = board.resolution + try: + return res.value * _UNIT_MM[board.unit] / _UNIT_MM[res.unit] + except KeyError as exc: + raise ValueError(f"unknown Specctra unit {exc.args[0]!r}") from None + + +def _len(value: float, k: float) -> int: + """A DSN length or coordinate as an SES integer.""" + return _iround(value * k) + + def write_ses( board: DsnBoard, result: RoutingResult | None = None, @@ -100,9 +124,10 @@ def _write_session_scope( out.write("(base_design ") ident.write(design_name, out) out.write(")") - _write_placement(out, ident, board) + k = ses_scale(board) + _write_placement(out, ident, board, k) _write_was_is(out) - _write_routes(out, ident, board, result) + _write_routes(out, ident, board, result, k) out.end_scope() @@ -111,7 +136,7 @@ def _write_resolution(out: IndentWriter, board: DsnBoard) -> None: out.write(f"(resolution {board.resolution.unit} {board.resolution.value})") -def _write_placement(out: IndentWriter, ident: Identifier, board: DsnBoard) -> None: +def _write_placement(out: IndentWriter, ident: Identifier, board: DsnBoard, k: float) -> None: out.start_scope() out.write("placement") _write_resolution(out, board) @@ -126,7 +151,7 @@ def _write_placement(out: IndentWriter, ident: Identifier, board: DsnBoard) -> N out.new_line() out.write("(place ") ident.write(place.name, out) - out.write(f" {_iround(place.x)} {_iround(place.y)}") + out.write(f" {_len(place.x, k)} {_len(place.y, k)}") out.write(" front " if place.is_front else " back ") out.write(str(_iround(place.rotation))) if place.position_fixed: @@ -145,14 +170,14 @@ def _write_was_is(out: IndentWriter) -> None: def _write_routes( - out: IndentWriter, ident: Identifier, board: DsnBoard, result: RoutingResult + out: IndentWriter, ident: Identifier, board: DsnBoard, result: RoutingResult, k: float ) -> None: out.start_scope() out.write("routes ") _write_resolution(out, board) _write_parser(out, ident, board) - _write_library_out(out, ident, board) - _write_network_out(out, ident, result) + _write_library_out(out, ident, board, k) + _write_network_out(out, ident, result, k) out.end_scope() @@ -174,24 +199,24 @@ def _write_parser(out: IndentWriter, ident: Identifier, board: DsnBoard) -> None out.end_scope() -def _write_library_out(out: IndentWriter, ident: Identifier, board: DsnBoard) -> None: +def _write_library_out(out: IndentWriter, ident: Identifier, board: DsnBoard, k: float) -> None: out.start_scope() out.write("library_out ") for name in board.via_padstack_names: padstack = board.padstack(name) if padstack is not None: - _write_padstack(out, ident, padstack) + _write_padstack(out, ident, padstack, k) out.end_scope() -def _write_padstack(out: IndentWriter, ident: Identifier, padstack) -> None: +def _write_padstack(out: IndentWriter, ident: Identifier, padstack, k: float) -> None: out.start_scope() out.write("padstack ") ident.write(padstack.name, out) for shape in padstack.shapes: out.start_scope() out.write("shape") - _write_shape_int(out, ident, shape) + _write_shape_int(out, ident, shape, k) out.end_scope() if not padstack.attach_allowed: out.new_line() @@ -199,61 +224,63 @@ def _write_padstack(out: IndentWriter, ident: Identifier, padstack) -> None: out.end_scope() -def _write_shape_int(out: IndentWriter, ident: Identifier, shape: Shape) -> None: +def _write_shape_int(out: IndentWriter, ident: Identifier, shape: Shape, k: float) -> None: """Write a padstack shape with integer coordinates (mirrors write_scope_int).""" out.start_scope() if isinstance(shape, Rectangle): out.write("rect ") ident.write(shape.layer, out) for c in shape.coords[:4]: - out.write(f" {_iround(c)}") + out.write(f" {_len(c, k)}") elif isinstance(shape, Circle): out.write("circle ") ident.write(shape.layer, out) - out.write(f" {_iround(shape.diameter)}") + out.write(f" {_len(shape.diameter, k)}") if shape.center_x or shape.center_y: - out.write(f" {_iround(shape.center_x)} {_iround(shape.center_y)}") + out.write(f" {_len(shape.center_x, k)} {_len(shape.center_y, k)}") elif isinstance(shape, Polygon): out.write("polygon ") ident.write(shape.layer, out) - out.write(f" {_iround(shape.aperture_width)}") + out.write(f" {_len(shape.aperture_width, k)}") for c in shape.coords: - out.write(f" {_iround(c)}") + out.write(f" {_len(c, k)}") elif isinstance(shape, Path): out.write("path ") ident.write(shape.layer, out) - out.write(f" {_iround(shape.width)}") + out.write(f" {_len(shape.width, k)}") for c in shape.coords: - out.write(f" {_iround(c)}") + out.write(f" {_len(c, k)}") else: # pragma: no cover - defensive: unknown shape type out.write("rect ") ident.write(shape.layer, out) out.end_scope() -def _write_network_out(out: IndentWriter, ident: Identifier, result: RoutingResult) -> None: +def _write_network_out( + out: IndentWriter, ident: Identifier, result: RoutingResult, k: float +) -> None: out.start_scope() out.write("network_out ") for net in result.routed_nets(): - _write_net(out, ident, net) + _write_net(out, ident, net, k) out.end_scope() -def _write_net(out: IndentWriter, ident: Identifier, net: RoutedNet) -> None: +def _write_net(out: IndentWriter, ident: Identifier, net: RoutedNet, k: float) -> None: out.start_scope() out.write("net ") ident.write(net.name, out) for wire in net.wires: - _write_wire(out, ident, wire) + _write_wire(out, ident, wire, k) for via in net.vias: - _write_via(out, ident, via) + _write_via(out, ident, via, k) out.end_scope() -def _write_wire(out: IndentWriter, ident: Identifier, wire: RoutedWire) -> None: +def _write_wire(out: IndentWriter, ident: Identifier, wire: RoutedWire, k: float) -> None: out.start_scope() out.write("wire") - _write_path(out, ident, wire.layer, wire.width, wire.coords) + _write_path(out, ident, wire.layer, wire.width, wire.coords, k) if wire.fixed: out.new_line() out.write("(type fix)") @@ -266,26 +293,27 @@ def _write_path( layer: str, width: float, coords: list[float], + k: float, ) -> None: out.start_scope() out.write("path ") ident.write(layer, out) out.write(" ") - out.write(str(_iround(width))) + out.write(str(_len(width, k))) corner_count = len(coords) // 2 for i in range(corner_count): out.new_line() - out.write(str(_iround(coords[2 * i]))) + out.write(str(_len(coords[2 * i], k))) out.write(" ") - out.write(str(_iround(coords[2 * i + 1]))) + out.write(str(_len(coords[2 * i + 1], k))) out.end_scope() -def _write_via(out: IndentWriter, ident: Identifier, via: RoutedVia) -> None: +def _write_via(out: IndentWriter, ident: Identifier, via: RoutedVia, k: float) -> None: out.start_scope() out.write("via ") ident.write(via.padstack, out) - out.write(f" {_iround(via.x)} {_iround(via.y)}") + out.write(f" {_len(via.x, k)} {_len(via.y, k)}") if via.fixed: out.new_line() out.write("(type fix)") diff --git a/tests/route/test_router.py b/tests/route/test_router.py index 856da00..90803a8 100644 --- a/tests/route/test_router.py +++ b/tests/route/test_router.py @@ -20,6 +20,7 @@ from freeroute.board import build_board from freeroute.dsn import parse_dsn from freeroute.dsn.sexp import parse from freeroute.route import route, route_dsn_board +from freeroute.ses.writer import ses_scale sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) @@ -89,9 +90,14 @@ def test_pipeline_emits_valid_ses_with_both_nets(): def test_simple_trace_endpoints_sit_on_the_net_pads(): + # Pad centres in DSN units, scaled to the SES's resolution units. + k = ses_scale(parse_dsn(SIMPLE.read_text())) pads = { - "NET_A": {(20000.0, -20000.0), (180000.0, -20000.0)}, - "NET_B": {(20000.0, -60000.0), (180000.0, -60000.0)}, + net: {(x * k, y * k) for x, y in centres} + for net, centres in { + "NET_A": {(20000.0, -20000.0), (180000.0, -20000.0)}, + "NET_B": {(20000.0, -60000.0), (180000.0, -60000.0)}, + }.items() } for net_name, scope in _net_scopes(route(SIMPLE.read_text())).items(): endpoints = set() @@ -231,13 +237,14 @@ def test_no_two_nets_cross_on_the_same_layer(): def test_traces_stay_within_the_board_outline(): for path in (SIMPLE, CROSSING): + k = ses_scale(parse_dsn(path.read_text())) ses = route(path.read_text()) for scope in _net_scopes(ses).values(): for wire in scope["wires"]: _, coords = _wire_layer_and_coords(wire) for x, y in coords: - assert 0 <= x <= 200000 - assert -80000 <= y <= 0 + assert 0 <= x <= 200000 * k + assert -80000 * k <= y <= 0 def test_traces_stay_on_valid_layers(): diff --git a/tests/ses/test_writer.py b/tests/ses/test_writer.py index 81d2d32..68727c9 100644 --- a/tests/ses/test_writer.py +++ b/tests/ses/test_writer.py @@ -68,10 +68,10 @@ def test_placement_echoes_components(board): comps = placement.children("component") names = [c.values()[0].text for c in comps] assert names == ["MiniQFN-6", "0603"] - # U1 place: coords rounded to int, side, rotation + # U1 place: coords in resolution units (um x10), side, rotation (unscaled) u1 = comps[0].child("place") vals = [v.text for v in u1.values()] - assert vals == ["U1", "75000", "-45000", "front", "90"] + assert vals == ["U1", "750000", "-450000", "front", "90"] def test_library_out_echoes_via_padstack(board): @@ -107,21 +107,22 @@ def test_routed_wire_path_and_via(board, routed): wire = child(net_a, "wire") path = child(wire, "path") pvals = [v.text for v in path.values()] - # (path x1 y1 x2 y2) - assert pvals == ["Top", "200", "0", "0", "1000", "-500"] + # (path x1 y1 x2 y2), DSN um scaled to resolution units (x10) + assert pvals == ["Top", "2000", "0", "0", "10000", "-5000"] via = child(net_a, "via") vvals = [v.text for v in via.values()] - assert vvals == ["Via[0-1]_600:300_um", "1000", "-500"] + assert vvals == ["Via[0-1]_600:300_um", "10000", "-5000"] def test_coordinates_are_integers(board): result = RoutingResult() - result.add_wire("NET_A", RoutedWire(layer="Top", width=199.6, coords=[0.4, -0.4, 2.5, 3.5])) + result.add_wire("NET_A", RoutedWire(layer="Top", width=19.96, coords=[0.04, -0.04, 0.25, 0.35])) routes = child(parse(write_ses(board, result)), "routes") path = child(child(child(routes, "network_out").children("net")[0], "wire"), "path") pvals = [v.text for v in path.values()] - # width 199.6 -> 200; coords 0.4->0, -0.4->0, 2.5->3, 3.5->4 (round half up) + # scaled x10 first: width 199.6 -> 200; coords 0.4->0, -0.4->0, 2.5->3, 3.5->4 + # (round half up) assert pvals == ["Top", "200", "0", "0", "3", "4"] assert all("." not in v for v in pvals) @@ -137,3 +138,25 @@ def test_round_trip_from_empty_board_fixture(): assert top.values()[0].text == "freeroute-empty.ses" # empty board has no placements -> placement scope has no components assert child(top, "placement").children("component") == [] + + +# --- SES coordinate units --------------------------------------------------- +# FreeRouting's SesWriter writes coordinates in resolution units, and KiCad's +# importer and mckicad's ses_apply read them that way. On 2026-09-26 freeroute +# was found writing DSN units instead, so every routed board came back 10x small. + + +@pytest.mark.parametrize( + ("resolution", "unit", "expected"), + [ + ("(resolution um 10)", "(unit um)", 10.0), # kicad-cli / pcbnew DSN + ("(resolution mil 1000)", "(unit mil)", 1000.0), + ("(resolution um 10)", "(unit mm)", 10000.0), # unit differs from resolution unit + ], +) +def test_ses_scale(resolution, unit, expected): + from freeroute.dsn import parse_dsn + from freeroute.ses.writer import ses_scale + + dsn = parse_dsn(f"(pcb t (parser) {resolution} {unit} (structure) (placement) (library) (network))") + assert ses_scale(dsn) == pytest.approx(expected)