Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
8b2a3278aThis file is pinned to the selected release. Download the original to open it in your design tool.
"""
Layer swap optimization for PCB routing.
This module handles the upfront layer swap optimization that happens before routing:
- Diff pair source and target layer swaps
- Single-ended source and target layer swaps
- Solo switches and retry loops
The goal is to minimize the number of vias needed by swapping stubs to compatible layers.
"""
from __future__ import annotations
from typing import List, Dict, Set, Tuple, Optional
from dataclasses import dataclass
from kicad_parser import PCBData
from routing_config import GridRouteConfig, DiffPairNet
from connectivity import is_edge_stub
from stub_layer_switching import (
get_stub_info, apply_stub_layer_switch, apply_bare_pad_target_via,
collect_stubs_by_layer,
collect_stub_endpoints_by_layer, validate_swap, validate_single_swap,
collect_single_ended_stubs_by_layer, revert_stub_layer_switch,
needs_pad_via_for_switch, connected_stub_segments_on_layer,
STUB_OVERLAP_Y_TOLERANCE, STUB_POSITION_TOLERANCE
)
from diff_pair_routing import get_diff_pair_endpoints
from geometry_utils import point_to_segment_distance
from connectivity import get_net_endpoints, get_multipoint_net_pads
_DRC_CLEARANCE_MARGIN = 0.05
def _bare_pad_pair_vias_fit(pcb_data, new_vias, config) -> Tuple[bool, str]:
"""Issue #241: the bare-pad target swap drops a through-via on EACH of a diff
pair's P and N pads to fan them onto an inner layer. At a tight connector pad
pitch (e.g. SYZYGY/DDR headers at 0.5mm) two 0.45mm via bodies inherently
collide (VIA-VIA) and graze the neighbouring cap/connector pads (PAD-VIA) -
the existing via_barrel_clear_of_foreign_copper guard only catches a via
PUNCHING THROUGH foreign copper (a short), not a sub-clearance graze.
Re-validate the two new vias the way check_drc would - body clearance AND
drill hole-to-hole - against each other, the existing foreign vias, and the
foreign pads. Pads/vias on the pair's OWN nets are excluded (the via
legitimately sits on its own pad, and a tight P/N via-via collision is the
very thing we test for here via the new_vias[i+1:] pass). Mirrors
diff_pair_multipoint._fans_fit, which already guards the multipoint relocation.
Returns (fit, reason).
"""
from check_drc import (check_via_via_overlap, check_via_drill_overlap,
check_pad_via_overlap, check_pad_drill_via_overlap)
clearance = config.clearance
h2h = config.hole_to_hole_clearance
margin = _DRC_CLEARANCE_MARGIN
routing_layers = [l for l in config.layers if l.endswith('.Cu')]
new_ids = {id(v) for v in new_vias}
# The pair's own nets (P and N of a coupled diff pair). Used ONLY by the
# foreign-SEGMENT check below: a coupled pair's partner stub converges toward
# the shared fan point AT the coupling gap, which is intended coupling, not a
# foreign graze -- so the segment check must exclude both, mirroring the
# _partners exclude set the sibling stub-clearance checks already use. The
# pad/via checks deliberately DO include the partner (a P via grazing the
# adjacent N PAD is a real short).
pair_nets = {v.net_id for v in new_vias}
pads_by_net = getattr(pcb_data, 'pads_by_net', None) or {}
for i, v in enumerate(new_vias):
# vs the partner net's new pad via (the P/N via-via at the pad pitch).
for w in new_vias[i + 1:]:
if check_via_via_overlap(v, w, clearance, margin)[0]:
return False, "P/N pad vias collide (via-via)"
if check_via_drill_overlap(v, w, h2h, margin)[0]:
return False, "P/N pad via drills collide (hole-to-hole)"
# vs existing vias: bodies only for FOREIGN nets (same-net copper may
# touch), but drills for EVERY net -- hole-to-hole is a fab rule, not
# an electrical one (#282: a new pad via 0.125mm from the pair's own
# fanout via-in-pad has physically overlapping drills).
for ev in pcb_data.vias:
if id(ev) in new_ids:
continue
if ev.net_id != v.net_id and check_via_via_overlap(v, ev, clearance, margin)[0]:
return False, "pad via grazes a foreign via (via-via)"
if check_via_drill_overlap(v, ev, h2h, margin)[0]:
return False, "pad via drill grazes a via drill (hole-to-hole)"
# vs foreign AND partner pads. Exclude only the via's OWN-net pad (the one
# it legitimately sits on); the PARTNER net's pad IS checked, since a P-pad
# via grazing the adjacent N pad (a 0.5mm-pitch connector) is a real PAD-VIA
# short - the /SYZYGY1.C2P_CLK_P via-vs-C2P_CLK_N(J4.36) case.
for pad_net, pads in pads_by_net.items():
for pad in pads:
# Per-pad clearance override (#326/#513 item 2) wins where larger:
# a swap via validated at a relaxed/fine clearance can still graze
# a pad carrying its own (clearance ...) override, which KiCad
# grades at max(pair) -- pocat_comms LQFP/QFN 0.1524mm imports.
# No grading-margin slack when the override governs: the post-route
# via-nudge cannot fix a via boxed between two long override pads
# (moving off one worsens the other), so a margin-graze ships.
pad_clr = max(clearance, getattr(pad, 'local_clearance', 0.0) or 0.0)
pad_margin = margin if pad_clr == clearance else 0.0
if pad_net != v.net_id and check_pad_via_overlap(
pad, v, pad_clr, routing_layers, pad_margin)[0]:
return False, "pad via grazes a foreign pad (pad-via)"
# drills: net-independent (same-net THT pad drill still conflicts)
if check_pad_drill_via_overlap(pad, v, h2h, margin)[0]:
return False, "pad via drill grazes a pad drill (hole-to-hole)"
# vs foreign SEGMENTS (#336): a through-via's barrel spans every layer,
# so any foreign track within (via_r + w/2 + clearance) is a VIA-SEGMENT
# graze -- butterstick's S16 swap via landed 0.10mm from S19's In3
# fanout stub because nothing here looked at segments.
vr = v.size / 2.0
for sg in pcb_data.segments:
if sg.net_id in pair_nets:
continue # own net OR the coupled partner's converging stub
# Same tolerance as the other checks in this function (via-via,
# via-drill, ...): a swap via grazing foreign copper by up to the
# grading margin is accepted here and cleaned by the post-route
# via-nudge (nudge_grazing_vias moves it to full clearance without
# disconnecting). Demanding near-full clearance at placement instead
# only rejected swaps the nudge would have fixed -- and the earlier
# tightening was misattributed to cynthion MEZZANINE6, which is an
# UNBLOCK via handled by the #339 refit, not a swap via.
need = vr + sg.width / 2.0 + clearance - margin
# cheap bbox reject before the exact distance
if (v.x < min(sg.start_x, sg.end_x) - need or
v.x > max(sg.start_x, sg.end_x) + need or
v.y < min(sg.start_y, sg.end_y) - need or
v.y > max(sg.start_y, sg.end_y) + need):
continue
if point_to_segment_distance(v.x, v.y, sg.start_x, sg.start_y,
sg.end_x, sg.end_y) < need:
return False, "pad via grazes a foreign track (via-segment)"
return True, ""
def _swap_vias_fit_or_shrink(pcb_data, new_vias, config) -> bool:
"""A layer swap drops a through-via on EACH of the pair's two source (or target)
pads. At a tight pad pitch (e.g. a 0.5mm connector -- lumenpnp USB_D) two
full-size via bodies/drills collide (VIA-VIA / hole-to-hole) or graze a foreign
via/pad, and nothing on the solo-switch path validated them (only the bare-pad
TARGET swap did, via #241). The swap itself is fine (the pair genuinely needs
that layer), so SHRINK the new vias toward the deepest escalation rung until
they fit; only if even that via still overlaps does the caller revert the swap.
Returns True if the vias fit (possibly after shrinking, mutating size/drill in
place so the writer emits the shrunk via); False if they can't be made to fit
without going below the floor (originals restored). #277."""
if not new_vias:
return True
if _bare_pad_pair_vias_fit(pcb_data, new_vias, config)[0]:
return True
from fab_tiers import escalation_rungs, warn_fab_escalation, note_narrowing
copper = sum(1 for l in config.layers if l.endswith('.Cu'))
# escalation_rungs, never the physical fab floor: empty under --escalation
# off (the vias stay as asked and the caller reverts the swap), bounded by
# a hard tier, raised to the board's own minimums under board (#857) and to
# the stricter of the two nets' rule minimums (#530).
net_floors = {}
for v in new_vias:
for k, f in config.rule_floors(v.net_id).items():
net_floors[k] = max(net_floors.get(k, 0.0), f)
ladder = escalation_rungs(copper, extra_floors=net_floors)
if not ladder:
return False
via_floor = ladder[-1]['via_diameter']
drill_floor = ladder[-1]['via_drill']
orig = [(v.size, v.drill) for v in new_vias]
ANNULAR = 0.1 # keep body - drill >= this (2x ring) as we shrink
size = config.via_size
while size > via_floor + 1e-9:
size = max(round(size - 0.05, 3), via_floor)
drill = max(min(config.via_drill, round(size - ANNULAR, 3)), drill_floor)
if drill >= size: # floor drill wider than the shrunk body: stop
break
for v in new_vias:
v.size, v.drill = size, drill
if _bare_pad_pair_vias_fit(pcb_data, new_vias, config)[0]:
if size < ladder[0]['via_diameter'] - 1e-9:
warn_fab_escalation("layer-swap pad vias")
for v in new_vias:
note_narrowing(v.net_id, 'via_diameter', config.via_size, size,
'layer-swap pad via')
return True
for v, (s, d) in zip(new_vias, orig):
v.size, v.drill = s, d
return False
def _find_blocking_single_ended_nets(
stub_p, stub_n, dest_layer: str, pcb_data: PCBData, diff_pair_net_ids: Set[int]
) -> List[int]:
"""
Find single-ended net IDs that block the given stubs from moving to dest_layer.
Returns list of net IDs that:
1. Have segments on dest_layer that overlap with stub bounding box
2. Are NOT part of any diff pair
"""
our_segments = stub_p.segments + stub_n.segments
our_net_ids = {stub_p.net_id, stub_n.net_id}
blocking_nets = set()
# Use same tolerance as validate_stub_no_overlap
y_tolerance = STUB_OVERLAP_Y_TOLERANCE
for seg in pcb_data.segments:
if seg.layer != dest_layer:
continue
if seg.net_id in our_net_ids:
continue
if seg.net_id in diff_pair_net_ids:
continue
# Check if segment bounding box overlaps with any of our segments
for our_seg in our_segments:
# Segment objects have start_x, start_y, end_x, end_y attributes
our_x_min = min(our_seg.start_x, our_seg.end_x)
our_x_max = max(our_seg.start_x, our_seg.end_x)
our_y_min = min(our_seg.start_y, our_seg.end_y)
our_y_max = max(our_seg.start_y, our_seg.end_y)
seg_x_min = min(seg.start_x, seg.end_x)
seg_x_max = max(seg.start_x, seg.end_x)
seg_y_min = min(seg.start_y, seg.end_y)
seg_y_max = max(seg.start_y, seg.end_y)
# Check if bounding boxes overlap (with y tolerance)
x_overlap = our_x_min <= seg_x_max and seg_x_min <= our_x_max
y_overlap = (our_y_min - y_tolerance) <= seg_y_max and seg_y_min <= (our_y_max + y_tolerance)
if x_overlap and y_overlap:
blocking_nets.add(seg.net_id)
break
return list(blocking_nets)
def _get_single_ended_stub_on_layer(
pcb_data: PCBData, net_id: int, layer: str, config: GridRouteConfig
):
"""Get stub info for a single-ended net on the given layer."""
sources, targets, error = get_net_endpoints(pcb_data, net_id, config)
if error or not sources or not targets:
return None
src_layer = config.layers[sources[0][2]]
tgt_layer = config.layers[targets[0][2]]
# Check if this net has a stub on the requested layer
if src_layer == layer:
return get_stub_info(pcb_data, net_id, sources[0][3], sources[0][4], layer)
elif tgt_layer == layer:
return get_stub_info(pcb_data, net_id, targets[0][3], targets[0][4], layer)
return None
def _validate_single_ended_swap(
stub, dest_layer: str, pcb_data: PCBData, config: GridRouteConfig,
exclude_net_ids: Set[int] = None
) -> bool:
"""Validate that a single-ended stub can move to dest_layer without conflicts."""
from stub_layer_switching import segments_intersect_2d
if exclude_net_ids is None:
exclude_net_ids = set()
# Check for segment intersections on destination layer
our_segments = stub.segments
our_net_ids = {stub.net_id} | exclude_net_ids
for our_seg in our_segments:
for other in pcb_data.segments:
if other.layer != dest_layer:
continue
if other.net_id in our_net_ids:
continue
# Check if segments actually intersect
if segments_intersect_2d(
(our_seg.start_x, our_seg.start_y), (our_seg.end_x, our_seg.end_y),
(other.start_x, other.start_y), (other.end_x, other.end_y)
):
return False
return True
def apply_diff_pair_layer_swaps(
pcb_data: PCBData,
config: GridRouteConfig,
diff_pair_ids_to_route_set: List[Tuple[str, DiffPairNet]],
diff_pairs: Dict[str, DiffPairNet],
can_swap_to_top_layer: bool,
all_segment_modifications: List,
all_swap_vias: List,
verbose: bool = False,
all_swap_segments: Optional[List] = None,
probe_obstacles=None,
bare_pad_swaps: Optional[Dict] = None
) -> Tuple[int, Dict, Dict]:
"""
Apply upfront layer swap optimization for diff pairs.
Args:
pcb_data: PCB data structure (modified in place)
config: Routing configuration
diff_pair_ids_to_route_set: List of (pair_name, pair) tuples to route
diff_pairs: Dict of all diff pairs
can_swap_to_top_layer: Whether stubs can be swapped to F.Cu
all_segment_modifications: List to append layer modifications (modified in place)
all_swap_vias: List to append vias from swapping (modified in place)
verbose: Whether to print verbose output
Returns:
(total_layer_swaps, all_stubs_by_layer, stub_endpoints_by_layer)
"""
if all_swap_segments is None:
all_swap_segments = []
print(f"\nAnalyzing layer swaps for {len(diff_pair_ids_to_route_set)} diff pair(s)...")
# Collect layer info for pairs we're routing
pair_layer_info = {} # pair_name -> (src_layer, tgt_layer, sources, targets, pair)
for pair_name, pair in diff_pair_ids_to_route_set:
sources, targets, error = get_diff_pair_endpoints(pcb_data, pair.p_net_id, pair.n_net_id, config)
if error or not sources or not targets:
continue
src_layer = config.layers[sources[0][4]]
tgt_layer = config.layers[targets[0][4]]
pair_layer_info[pair_name] = (src_layer, tgt_layer, sources, targets, pair)
# Build layer info for ALL diff pairs (for finding swap partners)
all_pair_layer_info = {} # pair_name -> (src_layer, tgt_layer, sources, targets, pair)
for pair_name, pair in diff_pairs.items():
sources, targets, error = get_diff_pair_endpoints(pcb_data, pair.p_net_id, pair.n_net_id, config)
if error or not sources or not targets:
continue
src_layer = config.layers[sources[0][4]]
tgt_layer = config.layers[targets[0][4]]
all_pair_layer_info[pair_name] = (src_layer, tgt_layer, sources, targets, pair)
# Pre-collect all stub segments by layer for validation
all_stubs_by_layer = collect_stubs_by_layer(pcb_data, all_pair_layer_info, config)
# Pre-collect all stub endpoints by layer for proximity checking
stub_endpoints_by_layer = collect_stub_endpoints_by_layer(pcb_data, all_pair_layer_info, config)
# Find pairs that need layer switches (src != tgt layer)
pairs_needing_via = [(name, info) for name, info in pair_layer_info.items()
if info[0] != info[1]]
# Try to find swap partners for pairs needing via
applied_swaps = set()
swap_count = 0
total_layer_swaps = 0
# Phase 1: Source segment overlap swaps
for pair_name, (src_layer, tgt_layer, sources, targets, pair) in pairs_needing_via:
if pair_name in applied_swaps:
continue
# Get our source stub info
src_p_stub = get_stub_info(pcb_data, pair.p_net_id,
sources[0][5], sources[0][6], src_layer)
src_n_stub = get_stub_info(pcb_data, pair.n_net_id,
sources[0][7], sources[0][8], src_layer)
if not src_p_stub or not src_n_stub:
continue
swap_partner = None
swap_partner_stubs = None
# Find which nets on target layer actually overlap with our stub segments
overlapping_nets = set()
our_stubs = src_p_stub.segments + src_n_stub.segments
for stub_seg in our_stubs:
stub_y_min = min(stub_seg.start_y, stub_seg.end_y) - STUB_OVERLAP_Y_TOLERANCE
stub_y_max = max(stub_seg.start_y, stub_seg.end_y) + STUB_OVERLAP_Y_TOLERANCE
stub_x_min = min(stub_seg.start_x, stub_seg.end_x)
stub_x_max = max(stub_seg.start_x, stub_seg.end_x)
for seg in pcb_data.segments:
if seg.layer != tgt_layer:
continue
seg_y_min = min(seg.start_y, seg.end_y)
seg_y_max = max(seg.start_y, seg.end_y)
seg_x_min = min(seg.start_x, seg.end_x)
seg_x_max = max(seg.start_x, seg.end_x)
# Check Y and X overlap
if seg_y_max >= stub_y_min and seg_y_min <= stub_y_max:
if seg_x_max >= stub_x_min and seg_x_min <= stub_x_max:
overlapping_nets.add(seg.net_id)
# Find which diff pair the overlapping nets belong to
for other_name, other_info in all_pair_layer_info.items():
if other_name == pair_name:
continue
other_src_layer, other_tgt_layer, other_sources, other_targets, other_pair = other_info
# Check if their source is on our target layer and overlaps
if other_src_layer != tgt_layer:
continue
if other_pair.p_net_id not in overlapping_nets and other_pair.n_net_id not in overlapping_nets:
continue
# IMPORTANT: Don't break a pair that was already OK!
# After swap, partner's source will be on our src_layer.
# Partner is OK if: their new source (src_layer) == their target (other_tgt_layer)
# OR if they already needed a via (can't make it worse)
partner_already_needs_via = (other_src_layer != other_tgt_layer)
partner_would_be_ok_after = (src_layer == other_tgt_layer)
if not partner_already_needs_via and not partner_would_be_ok_after:
# Partner was OK but swap would break them - skip
continue
# Get their source stub info
other_src_p_stub = get_stub_info(pcb_data, other_pair.p_net_id,
other_sources[0][5], other_sources[0][6], other_src_layer)
other_src_n_stub = get_stub_info(pcb_data, other_pair.n_net_id,
other_sources[0][7], other_sources[0][8], other_src_layer)
if other_src_p_stub and other_src_n_stub:
swap_partner = other_name
swap_partner_stubs = (other_src_p_stub, other_src_n_stub, other_src_layer)
break
if swap_partner and swap_partner_stubs:
# Found a swap partner! Swap source layers
other_src_p_stub, other_src_n_stub, other_src_layer = swap_partner_stubs
_, _, _, _, other_pair = all_pair_layer_info[swap_partner]
# Validate swap before applying
our_valid, our_reason = validate_swap(
src_p_stub, src_n_stub, tgt_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=swap_partner,
swap_partner_net_ids={other_pair.p_net_id, other_pair.n_net_id},
stub_endpoints_by_layer=stub_endpoints_by_layer
)
partner_valid, partner_reason = validate_swap(
other_src_p_stub, other_src_n_stub, src_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=pair_name,
swap_partner_net_ids={pair.p_net_id, pair.n_net_id},
stub_endpoints_by_layer=stub_endpoints_by_layer
)
if not our_valid or not partner_valid:
reason = our_reason if not our_valid else partner_reason
print(f" Source swap validation failed for {pair_name}: {reason}")
continue # Try target swap later
# Check if swap would move stubs to F.Cu (top layer)
# Skip if can_swap_to_top_layer is False and either destination is F.Cu
# Exception: allow edge stubs (on BGA boundary) to swap to F.Cu
if not can_swap_to_top_layer and (tgt_layer == 'F.Cu' or src_layer == 'F.Cu'):
# Check if stubs moving to F.Cu are edge stubs
allow_swap = True
if tgt_layer == 'F.Cu':
# Our stubs would move to F.Cu - check if they're edge stubs
if not (is_edge_stub(src_p_stub.pad_x, src_p_stub.pad_y, config.bga_exclusion_zones) or
is_edge_stub(src_n_stub.pad_x, src_n_stub.pad_y, config.bga_exclusion_zones)):
allow_swap = False
if src_layer == 'F.Cu' and allow_swap:
# Their stubs would move to F.Cu - check if they're edge stubs
if not (is_edge_stub(other_src_p_stub.pad_x, other_src_p_stub.pad_y, config.bga_exclusion_zones) or
is_edge_stub(other_src_n_stub.pad_x, other_src_n_stub.pad_y, config.bga_exclusion_zones)):
allow_swap = False
if not allow_swap:
continue
# Our source: src_layer -> tgt_layer
# Their source: other_src_layer (=tgt_layer) -> src_layer
vias1, mods1 = apply_stub_layer_switch(pcb_data, src_p_stub, tgt_layer, config, debug=False)
vias2, mods2 = apply_stub_layer_switch(pcb_data, src_n_stub, tgt_layer, config, debug=False)
vias3, mods3 = apply_stub_layer_switch(pcb_data, other_src_p_stub, src_layer, config, debug=False)
vias4, mods4 = apply_stub_layer_switch(pcb_data, other_src_n_stub, src_layer, config, debug=False)
all_vias = vias1 + vias2 + vias3 + vias4
# #277/#299 audit: the four pad vias can collide at a tight pad
# pitch; shrink to the fab floor or revert the whole paired swap.
if not _swap_vias_fit_or_shrink(pcb_data, all_vias, config):
revert_stub_layer_switch(pcb_data, mods1 + mods2 + mods3 + mods4, all_vias)
print(f" Paired source swap skipped for {pair_name}: pad vias overlap, can't shrink to fab floor")
continue
all_segment_modifications.extend(mods1 + mods2 + mods3 + mods4)
all_swap_vias.extend(all_vias)
# Update all_stubs_by_layer to reflect the layer changes
# pair_name: src_layer -> tgt_layer
if src_layer in all_stubs_by_layer:
all_stubs_by_layer[src_layer] = [
s for s in all_stubs_by_layer[src_layer] if s[0] != pair_name
]
if tgt_layer not in all_stubs_by_layer:
all_stubs_by_layer[tgt_layer] = []
all_stubs_by_layer[tgt_layer].append(
(pair_name, src_p_stub.segments + src_n_stub.segments)
)
# swap_partner: other_src_layer -> src_layer
if other_src_layer in all_stubs_by_layer:
all_stubs_by_layer[other_src_layer] = [
s for s in all_stubs_by_layer[other_src_layer] if s[0] != swap_partner
]
if src_layer not in all_stubs_by_layer:
all_stubs_by_layer[src_layer] = []
all_stubs_by_layer[src_layer].append(
(swap_partner, other_src_p_stub.segments + other_src_n_stub.segments)
)
# Update stub_endpoints_by_layer to reflect the layer changes
if src_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[src_layer] = [
e for e in stub_endpoints_by_layer[src_layer] if e[0] != pair_name
]
if tgt_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[tgt_layer] = []
stub_endpoints_by_layer[tgt_layer].append(
(pair_name, [(src_p_stub.x, src_p_stub.y), (src_n_stub.x, src_n_stub.y)])
)
if other_src_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[other_src_layer] = [
e for e in stub_endpoints_by_layer[other_src_layer] if e[0] != swap_partner
]
if src_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[src_layer] = []
stub_endpoints_by_layer[src_layer].append(
(swap_partner, [(other_src_p_stub.x, other_src_p_stub.y), (other_src_n_stub.x, other_src_n_stub.y)])
)
applied_swaps.add(pair_name)
applied_swaps.add(swap_partner)
swap_count += 1
total_layer_swaps += 1
via_msg = f", added {len(all_vias)} pad via(s)" if all_vias else ""
print(f" Source swap: {pair_name} ({src_layer}->{tgt_layer}) <-> {swap_partner} ({other_src_layer}->{src_layer}){via_msg}")
if swap_count > 0:
print(f"Applied {swap_count} source layer swap(s)")
# Phase 2: Solo source layer switches (no partner needed)
solo_src_count = 0
for pair_name, (src_layer, tgt_layer, sources, targets, pair) in pairs_needing_via:
if pair_name in applied_swaps:
continue
# Check if we can move source stubs to target layer without a partner
src_p_stub = get_stub_info(pcb_data, pair.p_net_id,
sources[0][5], sources[0][6], src_layer)
src_n_stub = get_stub_info(pcb_data, pair.n_net_id,
sources[0][7], sources[0][8], src_layer)
if not src_p_stub or not src_n_stub:
continue
# Check if swap would move stubs to F.Cu (top layer)
# Exception: allow edge stubs to swap to F.Cu
if not can_swap_to_top_layer and tgt_layer == 'F.Cu':
if not (is_edge_stub(src_p_stub.pad_x, src_p_stub.pad_y, config.bga_exclusion_zones) or
is_edge_stub(src_n_stub.pad_x, src_n_stub.pad_y, config.bga_exclusion_zones)):
continue
# Validate solo switch: source stubs move to target layer
valid, reason = validate_swap(
src_p_stub, src_n_stub, tgt_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=None,
swap_partner_net_ids=set(),
stub_endpoints_by_layer=stub_endpoints_by_layer
)
if valid:
# Apply solo source switch
vias1, mods1 = apply_stub_layer_switch(pcb_data, src_p_stub, tgt_layer, config, debug=False)
vias2, mods2 = apply_stub_layer_switch(pcb_data, src_n_stub, tgt_layer, config, debug=False)
all_vias = vias1 + vias2
# #277: the two pad vias can collide at a tight pad pitch. Shrink them to
# the fab via floor to fit; if even the floor via overlaps, revert the
# swap (the pair routes to the pads instead) rather than emit a via short.
if not _swap_vias_fit_or_shrink(pcb_data, all_vias, config):
revert_stub_layer_switch(pcb_data, mods1 + mods2, all_vias)
print(f" Solo source switch skipped for {pair_name}: pad vias overlap, can't shrink to fab floor")
continue
all_segment_modifications.extend(mods1 + mods2)
all_swap_vias.extend(all_vias)
# Update all_stubs_by_layer to reflect the layer change
# Structure is (pair_name, segments) tuples
# Remove from old layer and add to new layer
if src_layer in all_stubs_by_layer:
all_stubs_by_layer[src_layer] = [
s for s in all_stubs_by_layer[src_layer]
if s[0] != pair_name # s[0] is pair_name
]
if tgt_layer not in all_stubs_by_layer:
all_stubs_by_layer[tgt_layer] = []
# Add combined segments for this pair on new layer
combined_segments = src_p_stub.segments + src_n_stub.segments
all_stubs_by_layer[tgt_layer].append((pair_name, combined_segments))
# Update stub_endpoints_by_layer
if src_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[src_layer] = [
e for e in stub_endpoints_by_layer[src_layer] if e[0] != pair_name
]
if tgt_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[tgt_layer] = []
stub_endpoints_by_layer[tgt_layer].append(
(pair_name, [(src_p_stub.x, src_p_stub.y), (src_n_stub.x, src_n_stub.y)])
)
applied_swaps.add(pair_name)
solo_src_count += 1
total_layer_swaps += 1
via_msg = f", added {len(all_vias)} pad via(s)" if all_vias else ""
print(f" Solo source switch: {pair_name} ({src_layer}->{tgt_layer}){via_msg}")
else:
print(f" Solo source switch validation failed for {pair_name}: {reason}")
if solo_src_count > 0:
print(f"Applied {solo_src_count} solo source layer switch(es)")
# Phase 3: Target-side segment overlap swaps
target_swap_count = 0
for pair_name, (src_layer, tgt_layer, sources, targets, pair) in pairs_needing_via:
if pair_name in applied_swaps:
continue
# Get our target stub info
tgt_p_stub = get_stub_info(pcb_data, pair.p_net_id,
targets[0][5], targets[0][6], tgt_layer)
tgt_n_stub = get_stub_info(pcb_data, pair.n_net_id,
targets[0][7], targets[0][8], tgt_layer)
if not tgt_p_stub or not tgt_n_stub:
continue
swap_partner = None
swap_partner_stubs = None
# Find which nets on source layer actually overlap with our target stub segments
overlapping_nets = set()
our_stubs = tgt_p_stub.segments + tgt_n_stub.segments
for stub_seg in our_stubs:
stub_y_min = min(stub_seg.start_y, stub_seg.end_y) - STUB_OVERLAP_Y_TOLERANCE
stub_y_max = max(stub_seg.start_y, stub_seg.end_y) + STUB_OVERLAP_Y_TOLERANCE
stub_x_min = min(stub_seg.start_x, stub_seg.end_x)
stub_x_max = max(stub_seg.start_x, stub_seg.end_x)
for seg in pcb_data.segments:
if seg.layer != src_layer:
continue
seg_y_min = min(seg.start_y, seg.end_y)
seg_y_max = max(seg.start_y, seg.end_y)
seg_x_min = min(seg.start_x, seg.end_x)
seg_x_max = max(seg.start_x, seg.end_x)
# Check Y and X overlap
if seg_y_max >= stub_y_min and seg_y_min <= stub_y_max:
if seg_x_max >= stub_x_min and seg_x_min <= stub_x_max:
overlapping_nets.add(seg.net_id)
# Find which diff pair the overlapping nets belong to
for other_name, other_info in all_pair_layer_info.items():
if other_name == pair_name:
continue
other_src_layer, other_tgt_layer, other_sources, other_targets, other_pair = other_info
# Check if their target is on our source layer and overlaps
if other_tgt_layer != src_layer:
continue
if other_pair.p_net_id not in overlapping_nets and other_pair.n_net_id not in overlapping_nets:
continue
# IMPORTANT: Don't break a pair that was already OK!
# After swap, partner's target will be on our tgt_layer.
# Partner is OK if: their source (other_src_layer) == their new target (tgt_layer)
# OR if they already needed a via (can't make it worse)
partner_already_needs_via = (other_src_layer != other_tgt_layer)
partner_would_be_ok_after = (other_src_layer == tgt_layer)
if not partner_already_needs_via and not partner_would_be_ok_after:
# Partner was OK but swap would break them - skip
continue
# Get their target stub info
other_tgt_p_stub = get_stub_info(pcb_data, other_pair.p_net_id,
other_targets[0][5], other_targets[0][6], other_tgt_layer)
other_tgt_n_stub = get_stub_info(pcb_data, other_pair.n_net_id,
other_targets[0][7], other_targets[0][8], other_tgt_layer)
if other_tgt_p_stub and other_tgt_n_stub:
swap_partner = other_name
swap_partner_stubs = (other_tgt_p_stub, other_tgt_n_stub, other_tgt_layer)
break
if swap_partner and swap_partner_stubs:
# Found a swap partner! Swap target layers
other_tgt_p_stub, other_tgt_n_stub, other_tgt_layer = swap_partner_stubs
_, _, _, _, other_pair = all_pair_layer_info[swap_partner]
# Validate swap before applying
our_valid, our_reason = validate_swap(
tgt_p_stub, tgt_n_stub, src_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=swap_partner,
swap_partner_net_ids={other_pair.p_net_id, other_pair.n_net_id},
stub_endpoints_by_layer=stub_endpoints_by_layer
)
partner_valid, partner_reason = validate_swap(
other_tgt_p_stub, other_tgt_n_stub, tgt_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=pair_name,
swap_partner_net_ids={pair.p_net_id, pair.n_net_id},
stub_endpoints_by_layer=stub_endpoints_by_layer
)
if not our_valid or not partner_valid:
reason = our_reason if not our_valid else partner_reason
print(f" Target swap validation failed for {pair_name}: {reason}")
continue
# Check if swap would move stubs to F.Cu (top layer)
# Skip if can_swap_to_top_layer is False and either destination is F.Cu
# Exception: allow edge stubs to swap to F.Cu
if not can_swap_to_top_layer and (src_layer == 'F.Cu' or tgt_layer == 'F.Cu'):
# Check if stubs moving to F.Cu are edge stubs
allow_swap = True
if src_layer == 'F.Cu':
# Our stubs would move to F.Cu - check if they're edge stubs
if not (is_edge_stub(tgt_p_stub.pad_x, tgt_p_stub.pad_y, config.bga_exclusion_zones) or
is_edge_stub(tgt_n_stub.pad_x, tgt_n_stub.pad_y, config.bga_exclusion_zones)):
allow_swap = False
if tgt_layer == 'F.Cu' and allow_swap:
# Their stubs would move to F.Cu - check if they're edge stubs
if not (is_edge_stub(other_tgt_p_stub.pad_x, other_tgt_p_stub.pad_y, config.bga_exclusion_zones) or
is_edge_stub(other_tgt_n_stub.pad_x, other_tgt_n_stub.pad_y, config.bga_exclusion_zones)):
allow_swap = False
if not allow_swap:
continue
# Our target: tgt_layer -> src_layer
# Their target: other_tgt_layer (=src_layer) -> tgt_layer
vias1, mods1 = apply_stub_layer_switch(pcb_data, tgt_p_stub, src_layer, config, debug=False)
vias2, mods2 = apply_stub_layer_switch(pcb_data, tgt_n_stub, src_layer, config, debug=False)
vias3, mods3 = apply_stub_layer_switch(pcb_data, other_tgt_p_stub, tgt_layer, config, debug=False)
vias4, mods4 = apply_stub_layer_switch(pcb_data, other_tgt_n_stub, tgt_layer, config, debug=False)
all_vias = vias1 + vias2 + vias3 + vias4
# #277/#299 audit: shrink colliding pad vias or revert the paired swap.
if not _swap_vias_fit_or_shrink(pcb_data, all_vias, config):
revert_stub_layer_switch(pcb_data, mods1 + mods2 + mods3 + mods4, all_vias)
print(f" Paired target swap skipped for {pair_name}: pad vias overlap, can't shrink to fab floor")
continue
all_segment_modifications.extend(mods1 + mods2 + mods3 + mods4)
all_swap_vias.extend(all_vias)
# Update stub_endpoints_by_layer for both pairs
# Our targets move from tgt_layer to src_layer
if tgt_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[tgt_layer] = [
e for e in stub_endpoints_by_layer[tgt_layer] if e[0] != pair_name
]
if src_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[src_layer] = []
stub_endpoints_by_layer[src_layer].append(
(pair_name, [(tgt_p_stub.x, tgt_p_stub.y), (tgt_n_stub.x, tgt_n_stub.y)])
)
# Their targets move from other_tgt_layer to tgt_layer
if other_tgt_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[other_tgt_layer] = [
e for e in stub_endpoints_by_layer[other_tgt_layer] if e[0] != swap_partner
]
if tgt_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[tgt_layer] = []
stub_endpoints_by_layer[tgt_layer].append(
(swap_partner, [(other_tgt_p_stub.x, other_tgt_p_stub.y), (other_tgt_n_stub.x, other_tgt_n_stub.y)])
)
applied_swaps.add(pair_name)
applied_swaps.add(swap_partner)
target_swap_count += 1
total_layer_swaps += 1
via_msg = f", added {len(all_vias)} pad via(s)" if all_vias else ""
print(f" Target swap: {pair_name} ({tgt_layer}->{src_layer}) <-> {swap_partner} ({other_tgt_layer}->{tgt_layer}){via_msg}")
if target_swap_count > 0:
print(f"Applied {target_swap_count} target layer swap(s)")
# Phase 4: Solo target layer switches (no partner needed)
solo_switch_count = 0
for pair_name, (src_layer, tgt_layer, sources, targets, pair) in pairs_needing_via:
if pair_name in applied_swaps:
continue
# Check if we can move target stubs to source layer without a partner
tgt_p_stub = get_stub_info(pcb_data, pair.p_net_id,
targets[0][5], targets[0][6], tgt_layer)
tgt_n_stub = get_stub_info(pcb_data, pair.n_net_id,
targets[0][7], targets[0][8], tgt_layer)
if not tgt_p_stub or not tgt_n_stub:
# No stub at the target. If the target is a bare outer-layer pad (e.g.
# a connector pin on F.Cu or B.Cu) and the pair's source is on a
# different layer, fan the pad out onto the source layer: drop a
# through-via on each pad and grow a short stub on src_layer, turning
# the bare pad into a stub the router can land on (the through-via
# carries the connection back to the pad's outer layer). This is the
# bare-pad target layer swap.
if (tgt_layer in ('F.Cu', 'B.Cu') and src_layer != tgt_layer):
p_tgt_x, p_tgt_y = targets[0][5], targets[0][6]
n_tgt_x, n_tgt_y = targets[0][7], targets[0][8]
# Aim the stubs toward the pair's source so the free ends open the
# right way for the launcher.
src_cx = (sources[0][5] + sources[0][7]) / 2
src_cy = (sources[0][6] + sources[0][8]) / 2
# Fan onto the most-open layer around the target (non-plane first)
# rather than blindly src_layer, which can be fully blocked by a
# connector pad wall while inner layers are open (issue #121).
fan_layer = src_layer
if probe_obstacles is not None:
from layer_swap_fallback import rank_fallback_layers
tgt_cx = (p_tgt_x + n_tgt_x) / 2
tgt_cy = (p_tgt_y + n_tgt_y) / 2
ranked = rank_fallback_layers(
config, pcb_data, probe_obstacles, tgt_cx, tgt_cy, tgt_layer)
if ranked:
fan_layer = ranked[0]
if fan_layer != src_layer:
print(f" Bare-pad target launch layer: {fan_layer} "
f"(most open; src is {src_layer})")
# The fan-out drops a through-via on each pad; don't let either
# barrel punch through another net's under-pad copper (issue #123).
# Check EACH via against the PARTNER pair-net too: a through-via
# for one half can land inside the other half's pad copper (a big
# connector pad on a different net) -- a real P-to-N short.
# Excluding the partner (the old {p,n}) defeated the pad check and
# let castor_pollux /MCU/CONN_D drop the N via inside D+'s 4x1.5mm
# J11 pad (#90/#130). via_barrel_clear_of_foreign_copper adds the
# via's own net to the exclude internally, so pass empty: the P
# via is checked vs N + all others, the N via vs P + all others.
from stub_layer_switching import via_barrel_clear_of_foreign_copper
p_clear, p_reason = via_barrel_clear_of_foreign_copper(
p_tgt_x, p_tgt_y, pair.p_net_id, pcb_data, config, set())
n_clear, n_reason = via_barrel_clear_of_foreign_copper(
n_tgt_x, n_tgt_y, pair.n_net_id, pcb_data, config, set())
if not p_clear or not n_clear:
print(f" Bare-pad target swap skipped for {pair_name}: "
f"{p_reason if not p_clear else n_reason}")
continue
# #581: the fan drops a via ON each pad -- forbidden while an
# active (> 0) same-net pad via clearance is in force.
if getattr(config, 'same_net_pad_clearance', -1.0) > 0:
print(f" Bare-pad target swap skipped for {pair_name}: "
f"pad vias forbidden by same-net pad clearance (#581)")
continue
# Candidate stub directions (issue #357): aiming BOTH stubs at
# the pair's source center converges them -- from fine-pitch
# pads the two connectors end up sub-gap to each other and one
# sweeps the PARTNER's pad via (ulx5m ETH1_N 0.001mm from
# ETH1_P's via). Try the aimed geometry first, then a parallel
# escape perpendicular to the pad-pair axis (toward the source
# half-plane), which keeps the stubs a full pad pitch apart.
import math as _math
_axx, _axy = n_tgt_x - p_tgt_x, n_tgt_y - p_tgt_y
_axn = _math.hypot(_axx, _axy) or 1.0
_px, _py = -_axy / _axn, _axx / _axn
_midx, _midy = (p_tgt_x + n_tgt_x) / 2, (p_tgt_y + n_tgt_y) / 2
if _px * (src_cx - _midx) + _py * (src_cy - _midy) < 0:
_px, _py = -_px, -_py
candidates = [
((src_cx, src_cy), (src_cx, src_cy)),
((p_tgt_x + _px, p_tgt_y + _py), (n_tgt_x + _px, n_tgt_y + _py)),
]
fit, why = False, ""
via_p = via_n = stub_p = stub_n = None
new_pad_vias = []
for toward_p, toward_n in candidates:
via_p, stub_p = apply_bare_pad_target_via(
pcb_data, pair.p_net_id, p_tgt_x, p_tgt_y, fan_layer,
toward_p[0], toward_p[1], config)
via_n, stub_n = apply_bare_pad_target_via(
pcb_data, pair.n_net_id, n_tgt_x, n_tgt_y, fan_layer,
toward_n[0], toward_n[1], config)
# via_p/via_n are None when an existing same-net via-in-pad
# was reused (#282): no new hole, nothing to validate or
# undo for it.
new_pad_vias = [v for v in (via_p, via_n) if v is not None]
# Issue #241: the two pad vias can't fit at a tight connector
# pad pitch (0.5mm) - they collide with each other / graze
# neighbouring pads below clearance. Validate at check_drc's
# clearance and, if they don't fit, undo the swap so the pair
# routes to the bare pads instead (the right treatment for a
# dense connector fan-out).
fit, why = _bare_pad_pair_vias_fit(
pcb_data, new_pad_vias, config)
# The synthesized stubs are geometric copper: validate them
# against foreign pads AND routed tracks/vias on the fan layer
# (#282: a stub anchored at a reused ball via runs straight at
# the neighbouring BGA ball's pad). Same validators the solo
# switch path uses.
if fit:
from stub_layer_switching import (stub_clear_of_foreign_pads,
stub_clear_of_foreign_tracks)
_partners = {pair.p_net_id, pair.n_net_id}
for _stub, _snid in ((stub_p, pair.p_net_id),
(stub_n, pair.n_net_id)):
ok_p, why_p = stub_clear_of_foreign_pads(
[_stub], fan_layer, _snid, pcb_data, config, _partners)
ok_t, why_t = (True, "") if not ok_p else stub_clear_of_foreign_tracks(
[_stub], fan_layer, _snid, pcb_data, config, _partners)
if not (ok_p and ok_t):
fit, why = False, (why_p if not ok_p else why_t)
break
if fit:
# INTRA-PAIR clearance (issue #357): the foreign checks
# above exclude both pair nets, so the two synthesized
# stubs -- and each stub vs the PARTNER's anchor via --
# were never validated against each other. Grade at the
# intra-pair floor min(clearance, diff_pair_gap), the
# same floor the clearance ledger grades the pair at.
from geometry_utils import segment_to_segment_distance
intra = min(config.clearance, config.diff_pair_gap)
wp = stub_p.width / 2
wn = stub_n.width / 2
d_ss = segment_to_segment_distance(
stub_p.start_x, stub_p.start_y, stub_p.end_x, stub_p.end_y,
stub_n.start_x, stub_n.start_y, stub_n.end_x, stub_n.end_y)
if d_ss - wp - wn < intra - 1e-6:
fit, why = False, (f"synthesized P/N stubs pinch "
f"(gap {d_ss - wp - wn:.3f}mm)")
if fit:
# each stub vs the partner's anchor via (stub start
# IS the anchor; a reused via sits there too)
vp_r = (via_p.size if via_p else config.via_size) / 2
vn_r = (via_n.size if via_n else config.via_size) / 2
d_pv = point_to_segment_distance(
stub_p.start_x, stub_p.start_y,
stub_n.start_x, stub_n.start_y, stub_n.end_x, stub_n.end_y)
d_nv = point_to_segment_distance(
stub_n.start_x, stub_n.start_y,
stub_p.start_x, stub_p.start_y, stub_p.end_x, stub_p.end_y)
if d_pv - vp_r - wn < intra - 1e-6:
fit, why = False, (f"N stub grazes P pad via "
f"(gap {d_pv - vp_r - wn:.3f}mm)")
elif d_nv - vn_r - wp < intra - 1e-6:
fit, why = False, (f"P stub grazes N pad via "
f"(gap {d_nv - vn_r - wp:.3f}mm)")
if fit:
break
# undo this candidate before trying the next
for _v in new_pad_vias:
if _v in pcb_data.vias:
pcb_data.vias.remove(_v)
for _s in (stub_p, stub_n):
if _s in pcb_data.segments:
pcb_data.segments.remove(_s)
if not fit:
print(f" Bare-pad target swap skipped for {pair_name}: {why} "
f"(pair routes to the bare pads instead)")
continue
if len(new_pad_vias) < 2:
print(f" Bare-pad target swap: reused "
f"{2 - len(new_pad_vias)} existing via(s) at the pad "
f"(no new hole, #282)")
all_swap_vias.extend(new_pad_vias)
all_swap_segments.extend([stub_p, stub_n])
# Register the new stubs (on fan_layer) so later swap validation sees them.
if fan_layer not in all_stubs_by_layer:
all_stubs_by_layer[fan_layer] = []
all_stubs_by_layer[fan_layer].append((pair_name, [stub_p, stub_n]))
if fan_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[fan_layer] = []
stub_endpoints_by_layer[fan_layer].append(
(pair_name, [(stub_p.end_x, stub_p.end_y), (stub_n.end_x, stub_n.end_y)])
)
applied_swaps.add(pair_name)
solo_switch_count += 1
total_layer_swaps += 1
# Record the synthesized copper so the caller can UNDO this swap
# if the pair later fails to route: the bare-pad target stub can
# pin the pair into a forced P/N crossing, yet the same pair
# routes cleanly to the bare pads with a plain via (issue #142).
if bare_pad_swaps is not None:
# Only NEW vias are undo-removable; a reused fanout via
# pre-exists this swap and must never be dropped (#282).
bare_pad_swaps[pair_name] = {
'vias': new_pad_vias,
'stubs': [stub_p, stub_n],
}
print(f" Bare-pad target swap: {pair_name} ({tgt_layer} pad -> {fan_layer} stub), "
f"added {len(new_pad_vias)} pad via(s)")
continue
missing = []
if not tgt_p_stub:
missing.append("P")
if not tgt_n_stub:
missing.append("N")
print(f" Solo target switch skipped for {pair_name}: can't find {'+'.join(missing)} stub at target ({targets[0][5]:.2f}, {targets[0][6]:.2f}) on {tgt_layer}")
continue
# Check if swap would move stubs to F.Cu (top layer)
# Exception: allow edge stubs to swap to F.Cu
if not can_swap_to_top_layer and src_layer == 'F.Cu':
if not (is_edge_stub(tgt_p_stub.pad_x, tgt_p_stub.pad_y, config.bga_exclusion_zones) or
is_edge_stub(tgt_n_stub.pad_x, tgt_n_stub.pad_y, config.bga_exclusion_zones)):
print(f" Solo target switch skipped for {pair_name}: would move to F.Cu (top layer)")
continue
# Validate solo switch: target stubs move to source layer
valid, reason = validate_swap(
tgt_p_stub, tgt_n_stub, src_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=None,
swap_partner_net_ids=set(),
stub_endpoints_by_layer=stub_endpoints_by_layer
)
if valid:
# Apply solo target switch
vias1, mods1 = apply_stub_layer_switch(pcb_data, tgt_p_stub, src_layer, config, debug=False)
vias2, mods2 = apply_stub_layer_switch(pcb_data, tgt_n_stub, src_layer, config, debug=False)
all_vias = vias1 + vias2
# #277: shrink the pad vias to fit a tight pad pitch; revert if even the
# fab-floor via still overlaps (via short otherwise).
if not _swap_vias_fit_or_shrink(pcb_data, all_vias, config):
revert_stub_layer_switch(pcb_data, mods1 + mods2, all_vias)
print(f" Solo target switch skipped for {pair_name}: pad vias overlap, can't shrink to fab floor")
continue
all_segment_modifications.extend(mods1 + mods2)
all_swap_vias.extend(all_vias)
# Update all_stubs_by_layer to reflect the layer change
# Structure is (pair_name, segments) tuples
if tgt_layer in all_stubs_by_layer:
all_stubs_by_layer[tgt_layer] = [
s for s in all_stubs_by_layer[tgt_layer]
if s[0] != pair_name
]
if src_layer not in all_stubs_by_layer:
all_stubs_by_layer[src_layer] = []
combined_segments = tgt_p_stub.segments + tgt_n_stub.segments
all_stubs_by_layer[src_layer].append((pair_name, combined_segments))
# Update stub_endpoints_by_layer
if tgt_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[tgt_layer] = [
e for e in stub_endpoints_by_layer[tgt_layer] if e[0] != pair_name
]
if src_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[src_layer] = []
stub_endpoints_by_layer[src_layer].append(
(pair_name, [(tgt_p_stub.x, tgt_p_stub.y), (tgt_n_stub.x, tgt_n_stub.y)])
)
applied_swaps.add(pair_name)
solo_switch_count += 1
total_layer_swaps += 1
via_msg = f", added {len(all_vias)} pad via(s)" if all_vias else ""
print(f" Solo target switch: {pair_name} ({tgt_layer}->{src_layer}){via_msg}")
else:
print(f" Solo target switch validation failed for {pair_name}: {reason}")
if solo_switch_count > 0:
print(f"Applied {solo_switch_count} solo target layer switch(es)")
# Phase 5: Retry solo switches if any progress was made (newly freed layers may allow more switches)
if solo_src_count > 0 or solo_switch_count > 0:
retry_round = 1
while True:
retry_round += 1
retry_src_count = 0
retry_tgt_count = 0
# Retry solo source switches
for pair_name, (src_layer, tgt_layer, sources, targets, pair) in pairs_needing_via:
if pair_name in applied_swaps:
continue
src_p_stub = get_stub_info(pcb_data, pair.p_net_id,
sources[0][5], sources[0][6], src_layer)
src_n_stub = get_stub_info(pcb_data, pair.n_net_id,
sources[0][7], sources[0][8], src_layer)
if not src_p_stub or not src_n_stub:
continue
if not can_swap_to_top_layer and tgt_layer == 'F.Cu':
if not (is_edge_stub(src_p_stub.pad_x, src_p_stub.pad_y, config.bga_exclusion_zones) or
is_edge_stub(src_n_stub.pad_x, src_n_stub.pad_y, config.bga_exclusion_zones)):
continue
valid, reason = validate_swap(
src_p_stub, src_n_stub, tgt_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=None,
swap_partner_net_ids=set(),
stub_endpoints_by_layer=stub_endpoints_by_layer
)
if valid:
vias1, mods1 = apply_stub_layer_switch(pcb_data, src_p_stub, tgt_layer, config, debug=False)
vias2, mods2 = apply_stub_layer_switch(pcb_data, src_n_stub, tgt_layer, config, debug=False)
all_vias = vias1 + vias2
# #277/#299 audit: same guard as the first-round solo switch
# (the retry round had omitted it).
if not _swap_vias_fit_or_shrink(pcb_data, all_vias, config):
revert_stub_layer_switch(pcb_data, mods1 + mods2, all_vias)
print(f" Solo source switch (round {retry_round}) skipped for {pair_name}: pad vias overlap")
continue
all_segment_modifications.extend(mods1 + mods2)
all_swap_vias.extend(all_vias)
if src_layer in all_stubs_by_layer:
all_stubs_by_layer[src_layer] = [s for s in all_stubs_by_layer[src_layer] if s[0] != pair_name]
if tgt_layer not in all_stubs_by_layer:
all_stubs_by_layer[tgt_layer] = []
all_stubs_by_layer[tgt_layer].append((pair_name, src_p_stub.segments + src_n_stub.segments))
if src_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[src_layer] = [e for e in stub_endpoints_by_layer[src_layer] if e[0] != pair_name]
if tgt_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[tgt_layer] = []
stub_endpoints_by_layer[tgt_layer].append((pair_name, [(src_p_stub.x, src_p_stub.y), (src_n_stub.x, src_n_stub.y)]))
applied_swaps.add(pair_name)
retry_src_count += 1
total_layer_swaps += 1
via_msg = f", added {len(all_vias)} pad via(s)" if all_vias else ""
print(f" Solo source switch (round {retry_round}): {pair_name} ({src_layer}->{tgt_layer}){via_msg}")
# Retry solo target switches
for pair_name, (src_layer, tgt_layer, sources, targets, pair) in pairs_needing_via:
if pair_name in applied_swaps:
continue
tgt_p_stub = get_stub_info(pcb_data, pair.p_net_id,
targets[0][5], targets[0][6], tgt_layer)
tgt_n_stub = get_stub_info(pcb_data, pair.n_net_id,
targets[0][7], targets[0][8], tgt_layer)
if not tgt_p_stub or not tgt_n_stub:
continue
if not can_swap_to_top_layer and src_layer == 'F.Cu':
if not (is_edge_stub(tgt_p_stub.pad_x, tgt_p_stub.pad_y, config.bga_exclusion_zones) or
is_edge_stub(tgt_n_stub.pad_x, tgt_n_stub.pad_y, config.bga_exclusion_zones)):
continue
valid, reason = validate_swap(
tgt_p_stub, tgt_n_stub, src_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=None,
swap_partner_net_ids=set(),
stub_endpoints_by_layer=stub_endpoints_by_layer
)
if valid:
vias1, mods1 = apply_stub_layer_switch(pcb_data, tgt_p_stub, src_layer, config, debug=False)
vias2, mods2 = apply_stub_layer_switch(pcb_data, tgt_n_stub, src_layer, config, debug=False)
all_vias = vias1 + vias2
# #277/#299 audit: same guard as the first-round solo switch.
if not _swap_vias_fit_or_shrink(pcb_data, all_vias, config):
revert_stub_layer_switch(pcb_data, mods1 + mods2, all_vias)
print(f" Solo target switch (round {retry_round}) skipped for {pair_name}: pad vias overlap")
continue
all_segment_modifications.extend(mods1 + mods2)
all_swap_vias.extend(all_vias)
if tgt_layer in all_stubs_by_layer:
all_stubs_by_layer[tgt_layer] = [s for s in all_stubs_by_layer[tgt_layer] if s[0] != pair_name]
if src_layer not in all_stubs_by_layer:
all_stubs_by_layer[src_layer] = []
all_stubs_by_layer[src_layer].append((pair_name, tgt_p_stub.segments + tgt_n_stub.segments))
if tgt_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[tgt_layer] = [e for e in stub_endpoints_by_layer[tgt_layer] if e[0] != pair_name]
if src_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[src_layer] = []
stub_endpoints_by_layer[src_layer].append((pair_name, [(tgt_p_stub.x, tgt_p_stub.y), (tgt_n_stub.x, tgt_n_stub.y)]))
applied_swaps.add(pair_name)
retry_tgt_count += 1
total_layer_swaps += 1
via_msg = f", added {len(all_vias)} pad via(s)" if all_vias else ""
print(f" Solo target switch (round {retry_round}): {pair_name} ({tgt_layer}->{src_layer}){via_msg}")
if retry_src_count == 0 and retry_tgt_count == 0:
break # No more progress
print(f"Applied {retry_src_count + retry_tgt_count} additional solo switch(es) in round {retry_round}")
# Phase 6: Two-pair swaps for remaining pairs that weren't handled
for pair_name, (src_layer, tgt_layer, sources, targets, pair) in pairs_needing_via:
if pair_name in applied_swaps:
continue
# Look for another pair that also needs a via to swap with
# Option 1: Swap sources - we want our source to become tgt_layer
# Option 2: Swap targets - we want our target to become src_layer
for other_name, other_info in pairs_needing_via:
if other_name in applied_swaps or other_name == pair_name:
continue
other_src, other_tgt, other_sources, other_targets, other_pair = other_info
swap_type = None
our_stubs = None
their_stubs = None
our_new_layer = None
their_new_layer = None
# Option 1: Swap sources
# Their source is on our target layer, our source is on their target layer
if other_src == tgt_layer and src_layer == other_tgt:
swap_type = "source"
our_new_layer = tgt_layer
their_new_layer = src_layer
# Our source stubs
p_stub = get_stub_info(pcb_data, pair.p_net_id,
sources[0][5], sources[0][6], src_layer)
n_stub = get_stub_info(pcb_data, pair.n_net_id,
sources[0][7], sources[0][8], src_layer)
# Their source stubs
other_p_stub = get_stub_info(pcb_data, other_pair.p_net_id,
other_sources[0][5], other_sources[0][6], other_src)
other_n_stub = get_stub_info(pcb_data, other_pair.n_net_id,
other_sources[0][7], other_sources[0][8], other_src)
our_stubs = (p_stub, n_stub)
their_stubs = (other_p_stub, other_n_stub)
# Option 2: Swap targets
# Their target is on our source layer, our target is on their source layer
elif other_tgt == src_layer and tgt_layer == other_src:
swap_type = "target"
our_new_layer = src_layer
their_new_layer = tgt_layer
# Our target stubs
p_stub = get_stub_info(pcb_data, pair.p_net_id,
targets[0][5], targets[0][6], tgt_layer)
n_stub = get_stub_info(pcb_data, pair.n_net_id,
targets[0][7], targets[0][8], tgt_layer)
# Their target stubs
other_p_stub = get_stub_info(pcb_data, other_pair.p_net_id,
other_targets[0][5], other_targets[0][6], other_tgt)
other_n_stub = get_stub_info(pcb_data, other_pair.n_net_id,
other_targets[0][7], other_targets[0][8], other_tgt)
our_stubs = (p_stub, n_stub)
their_stubs = (other_p_stub, other_n_stub)
# Try swap if we have valid stubs, otherwise try the other side
if swap_type and our_stubs[0] and our_stubs[1] and their_stubs[0] and their_stubs[1]:
# Check if swap would move stubs to F.Cu (top layer)
# Exception: allow edge stubs to swap to F.Cu
allow_swap = True
if not can_swap_to_top_layer and (our_new_layer == 'F.Cu' or their_new_layer == 'F.Cu'):
if our_new_layer == 'F.Cu':
if not (is_edge_stub(our_stubs[0].pad_x, our_stubs[0].pad_y, config.bga_exclusion_zones) or
is_edge_stub(our_stubs[1].pad_x, our_stubs[1].pad_y, config.bga_exclusion_zones)):
allow_swap = False
if their_new_layer == 'F.Cu' and allow_swap:
if not (is_edge_stub(their_stubs[0].pad_x, their_stubs[0].pad_y, config.bga_exclusion_zones) or
is_edge_stub(their_stubs[1].pad_x, their_stubs[1].pad_y, config.bga_exclusion_zones)):
allow_swap = False
if not allow_swap:
pass # Skip this swap - would move non-edge stubs to top layer
else:
# Validate swap before applying
our_valid, our_reason = validate_swap(
our_stubs[0], our_stubs[1], our_new_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=other_name,
swap_partner_net_ids={other_pair.p_net_id, other_pair.n_net_id},
stub_endpoints_by_layer=stub_endpoints_by_layer
)
their_valid, their_reason = validate_swap(
their_stubs[0], their_stubs[1], their_new_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=pair_name,
swap_partner_net_ids={pair.p_net_id, pair.n_net_id},
stub_endpoints_by_layer=stub_endpoints_by_layer
)
if not our_valid or not their_valid:
reason = our_reason if not our_valid else their_reason
print(f" Two-pair {swap_type} swap validation failed for {pair_name}: {reason}")
# Don't break - continue to try fallback target swap
else:
# Apply swaps. Vias were previously DISCARDED here (never
# fit-checked or added to all_swap_vias even though apply
# puts them on the board) -- #299 audit.
vias1, mods1 = apply_stub_layer_switch(pcb_data, our_stubs[0], our_new_layer, config, debug=False)
vias2, mods2 = apply_stub_layer_switch(pcb_data, our_stubs[1], our_new_layer, config, debug=False)
vias3, mods3 = apply_stub_layer_switch(pcb_data, their_stubs[0], their_new_layer, config, debug=False)
vias4, mods4 = apply_stub_layer_switch(pcb_data, their_stubs[1], their_new_layer, config, debug=False)
all_vias = vias1 + vias2 + vias3 + vias4
if not _swap_vias_fit_or_shrink(pcb_data, all_vias, config):
revert_stub_layer_switch(pcb_data, mods1 + mods2 + mods3 + mods4, all_vias)
print(f" Two-pair {swap_type} swap skipped for {pair_name}: pad vias overlap, can't shrink to fab floor")
continue # next partner; later solo/retry phases can still swap this pair
all_swap_vias.extend(all_vias)
all_segment_modifications.extend(mods1 + mods2 + mods3 + mods4)
# Update stub_endpoints_by_layer for both pairs
# Our pair moves to our_new_layer
our_orig_layer = src_layer if swap_type == "source" else tgt_layer
if our_orig_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[our_orig_layer] = [
e for e in stub_endpoints_by_layer[our_orig_layer] if e[0] != pair_name
]
if our_new_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[our_new_layer] = []
stub_endpoints_by_layer[our_new_layer].append(
(pair_name, [(our_stubs[0].x, our_stubs[0].y), (our_stubs[1].x, our_stubs[1].y)])
)
# Their pair moves to their_new_layer
their_orig_layer = other_src if swap_type == "source" else other_tgt
if their_orig_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[their_orig_layer] = [
e for e in stub_endpoints_by_layer[their_orig_layer] if e[0] != other_name
]
if their_new_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[their_new_layer] = []
stub_endpoints_by_layer[their_new_layer].append(
(other_name, [(their_stubs[0].x, their_stubs[0].y), (their_stubs[1].x, their_stubs[1].y)])
)
applied_swaps.add(pair_name)
applied_swaps.add(other_name)
swap_count += 1
total_layer_swaps += 1
print(f" Swap {swap_type}s: {pair_name} <-> {other_name}")
break
if swap_type == "source":
# Source swap failed, try target swap with same pair
if other_tgt == src_layer and tgt_layer == other_src:
# Our target stubs
p_stub = get_stub_info(pcb_data, pair.p_net_id,
targets[0][5], targets[0][6], tgt_layer)
n_stub = get_stub_info(pcb_data, pair.n_net_id,
targets[0][7], targets[0][8], tgt_layer)
# Their target stubs
other_p_stub = get_stub_info(pcb_data, other_pair.p_net_id,
other_targets[0][5], other_targets[0][6], other_tgt)
other_n_stub = get_stub_info(pcb_data, other_pair.n_net_id,
other_targets[0][7], other_targets[0][8], other_tgt)
if p_stub and n_stub and other_p_stub and other_n_stub:
# Check if swap would move stubs to F.Cu (top layer)
# Exception: allow edge stubs to swap to F.Cu
allow_swap = True
if not can_swap_to_top_layer and (src_layer == 'F.Cu' or tgt_layer == 'F.Cu'):
if src_layer == 'F.Cu':
if not (is_edge_stub(p_stub.pad_x, p_stub.pad_y, config.bga_exclusion_zones) or
is_edge_stub(n_stub.pad_x, n_stub.pad_y, config.bga_exclusion_zones)):
allow_swap = False
if tgt_layer == 'F.Cu' and allow_swap:
if not (is_edge_stub(other_p_stub.pad_x, other_p_stub.pad_y, config.bga_exclusion_zones) or
is_edge_stub(other_n_stub.pad_x, other_n_stub.pad_y, config.bga_exclusion_zones)):
allow_swap = False
if not allow_swap:
pass # Skip this swap - would move non-edge stubs to top layer
else:
# Validate fallback target swap before applying
our_valid, our_reason = validate_swap(
p_stub, n_stub, src_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=other_name,
swap_partner_net_ids={other_pair.p_net_id, other_pair.n_net_id},
stub_endpoints_by_layer=stub_endpoints_by_layer
)
their_valid, their_reason = validate_swap(
other_p_stub, other_n_stub, tgt_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=pair_name,
swap_partner_net_ids={pair.p_net_id, pair.n_net_id},
stub_endpoints_by_layer=stub_endpoints_by_layer
)
if not our_valid or not their_valid:
reason = our_reason if not our_valid else their_reason
print(f" Fallback target swap validation failed for {pair_name}: {reason}")
else:
# Vias were previously discarded/untracked here (#299 audit).
vias1, mods1 = apply_stub_layer_switch(pcb_data, p_stub, src_layer, config, debug=False)
vias2, mods2 = apply_stub_layer_switch(pcb_data, n_stub, src_layer, config, debug=False)
vias3, mods3 = apply_stub_layer_switch(pcb_data, other_p_stub, tgt_layer, config, debug=False)
vias4, mods4 = apply_stub_layer_switch(pcb_data, other_n_stub, tgt_layer, config, debug=False)
all_vias = vias1 + vias2 + vias3 + vias4
if not _swap_vias_fit_or_shrink(pcb_data, all_vias, config):
revert_stub_layer_switch(pcb_data, mods1 + mods2 + mods3 + mods4, all_vias)
print(f" Fallback target swap skipped for {pair_name}: pad vias overlap, can't shrink to fab floor")
continue # next partner; later phases can still swap this pair
all_swap_vias.extend(all_vias)
all_segment_modifications.extend(mods1 + mods2 + mods3 + mods4)
# Update stub_endpoints_by_layer for both pairs
# Our targets move from tgt_layer to src_layer
if tgt_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[tgt_layer] = [
e for e in stub_endpoints_by_layer[tgt_layer] if e[0] != pair_name
]
if src_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[src_layer] = []
stub_endpoints_by_layer[src_layer].append(
(pair_name, [(p_stub.x, p_stub.y), (n_stub.x, n_stub.y)])
)
# Their targets move from other_tgt to tgt_layer
if other_tgt in stub_endpoints_by_layer:
stub_endpoints_by_layer[other_tgt] = [
e for e in stub_endpoints_by_layer[other_tgt] if e[0] != other_name
]
if tgt_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[tgt_layer] = []
stub_endpoints_by_layer[tgt_layer].append(
(other_name, [(other_p_stub.x, other_p_stub.y), (other_n_stub.x, other_n_stub.y)])
)
applied_swaps.add(pair_name)
applied_swaps.add(other_name)
swap_count += 1
total_layer_swaps += 1
print(f" Swap targets: {pair_name} <-> {other_name}")
break
if swap_count > 0:
print(f"Applied {swap_count} layer swap(s)")
# Phase 7: Try single-ended swaps to make room for diff pairs that still need vias
# Build set of all diff pair net IDs for exclusion
diff_pair_net_ids = set()
for pair_name, pair in diff_pairs.items():
diff_pair_net_ids.add(pair.p_net_id)
diff_pair_net_ids.add(pair.n_net_id)
single_ended_swap_count = 0
for pair_name, (src_layer, tgt_layer, sources, targets, pair) in pairs_needing_via:
if pair_name in applied_swaps:
continue
# Try source-side swap: move source stubs to target layer
src_p_stub = get_stub_info(pcb_data, pair.p_net_id,
sources[0][5], sources[0][6], src_layer)
src_n_stub = get_stub_info(pcb_data, pair.n_net_id,
sources[0][7], sources[0][8], src_layer)
if src_p_stub and src_n_stub:
# Find blocking single-ended nets on target layer
blocking_nets = _find_blocking_single_ended_nets(
src_p_stub, src_n_stub, tgt_layer, pcb_data, diff_pair_net_ids
)
for blocking_net_id in blocking_nets:
# Try to find an alternative layer for this single-ended net
se_stub = _get_single_ended_stub_on_layer(
pcb_data, blocking_net_id, tgt_layer, config
)
if not se_stub:
continue
# Try each available layer except source and target
for alt_layer in config.layers:
if alt_layer == tgt_layer or alt_layer == src_layer:
continue
# Don't swap to F.Cu unless it's an edge stub
if not can_swap_to_top_layer and alt_layer == 'F.Cu':
if not is_edge_stub(se_stub.pad_x, se_stub.pad_y, config.bga_exclusion_zones):
continue
# Check if single-ended stub can move to alt_layer without conflicts
se_valid = _validate_single_ended_swap(
se_stub, alt_layer, pcb_data, config, {pair.p_net_id, pair.n_net_id}
)
if not se_valid:
continue
# Apply the single-ended swap (mutates pcb_data so the
# diff-pair validate_swap below sees the moved blocker). Do
# NOT record its via/segment mods in the output aggregates
# yet -- only commit them once the diff-pair swap it enables
# is confirmed valid. Recording them up-front leaked the via
# of every reverted attempt into the output (the revert only
# undoes pcb_data, not all_swap_vias), producing duplicate
# stacked vias and the bulk of issue #221's VIA-DRILL-HOLE.
se_vias, se_mods = apply_stub_layer_switch(
pcb_data, se_stub, alt_layer, config, debug=False
)
# Now retry the diff pair source switch
valid, reason = validate_swap(
src_p_stub, src_n_stub, tgt_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=None,
swap_partner_net_ids=set(),
stub_endpoints_by_layer=stub_endpoints_by_layer
)
if valid:
# Apply diff pair source switch
vias1, mods1 = apply_stub_layer_switch(pcb_data, src_p_stub, tgt_layer, config, debug=False)
vias2, mods2 = apply_stub_layer_switch(pcb_data, src_n_stub, tgt_layer, config, debug=False)
# #299 audit: fit-check the moved blocker's via and the
# pair's pad vias together; on failure undo BOTH swaps.
if not _swap_vias_fit_or_shrink(pcb_data, se_vias + vias1 + vias2, config):
revert_stub_layer_switch(pcb_data, mods1 + mods2, vias1 + vias2)
revert_stub_layer_switch(pcb_data, se_mods, se_vias)
print(f" Solo source switch (blocker move) skipped for {pair_name}: pad vias overlap")
continue
# Commit the single-ended blocker move now that it stuck
all_segment_modifications.extend(se_mods)
all_swap_vias.extend(se_vias)
all_segment_modifications.extend(mods1 + mods2)
all_swap_vias.extend(vias1 + vias2)
# Update tracking structures
if src_layer in all_stubs_by_layer:
all_stubs_by_layer[src_layer] = [s for s in all_stubs_by_layer[src_layer] if s[0] != pair_name]
if tgt_layer not in all_stubs_by_layer:
all_stubs_by_layer[tgt_layer] = []
all_stubs_by_layer[tgt_layer].append((pair_name, src_p_stub.segments + src_n_stub.segments))
if src_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[src_layer] = [e for e in stub_endpoints_by_layer[src_layer] if e[0] != pair_name]
if tgt_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[tgt_layer] = []
stub_endpoints_by_layer[tgt_layer].append((pair_name, [(src_p_stub.x, src_p_stub.y), (src_n_stub.x, src_n_stub.y)]))
applied_swaps.add(pair_name)
single_ended_swap_count += 1
total_layer_swaps += 1
net = pcb_data.nets.get(blocking_net_id)
blocking_name = net.name if net else f"net {blocking_net_id}"
via_msg = f", added {len(vias1) + len(vias2)} pad via(s)" if vias1 or vias2 else ""
print(f" Solo source switch: {pair_name} ({src_layer}->{tgt_layer}) after moving {blocking_name} to {alt_layer}{via_msg}")
break
else:
# Undo the single-ended swap since diff pair still can't swap
revert_stub_layer_switch(pcb_data, se_mods, se_vias)
if pair_name in applied_swaps:
break
# If source side didn't work, try target side
if pair_name not in applied_swaps:
tgt_p_stub = get_stub_info(pcb_data, pair.p_net_id,
targets[0][5], targets[0][6], tgt_layer)
tgt_n_stub = get_stub_info(pcb_data, pair.n_net_id,
targets[0][7], targets[0][8], tgt_layer)
if tgt_p_stub and tgt_n_stub:
# Find blocking single-ended nets on source layer
blocking_nets = _find_blocking_single_ended_nets(
tgt_p_stub, tgt_n_stub, src_layer, pcb_data, diff_pair_net_ids
)
for blocking_net_id in blocking_nets:
se_stub = _get_single_ended_stub_on_layer(
pcb_data, blocking_net_id, src_layer, config
)
if not se_stub:
continue
for alt_layer in config.layers:
if alt_layer == src_layer or alt_layer == tgt_layer:
continue
if not can_swap_to_top_layer and alt_layer == 'F.Cu':
if not is_edge_stub(se_stub.pad_x, se_stub.pad_y, config.bga_exclusion_zones):
continue
se_valid = _validate_single_ended_swap(
se_stub, alt_layer, pcb_data, config, {pair.p_net_id, pair.n_net_id}
)
if not se_valid:
continue
# See the source-side note above: defer recording the
# single-ended blocker's via/segment mods until the
# diff-pair swap is confirmed, so a reverted attempt
# cannot leak a duplicate via into the output (#221).
se_vias, se_mods = apply_stub_layer_switch(
pcb_data, se_stub, alt_layer, config, debug=False
)
valid, reason = validate_swap(
tgt_p_stub, tgt_n_stub, src_layer, all_stubs_by_layer,
pcb_data, config, swap_partner_name=None,
swap_partner_net_ids=set(),
stub_endpoints_by_layer=stub_endpoints_by_layer
)
if valid:
vias1, mods1 = apply_stub_layer_switch(pcb_data, tgt_p_stub, src_layer, config, debug=False)
vias2, mods2 = apply_stub_layer_switch(pcb_data, tgt_n_stub, src_layer, config, debug=False)
# #299 audit: fit-check blocker + pair vias together.
if not _swap_vias_fit_or_shrink(pcb_data, se_vias + vias1 + vias2, config):
revert_stub_layer_switch(pcb_data, mods1 + mods2, vias1 + vias2)
revert_stub_layer_switch(pcb_data, se_mods, se_vias)
print(f" Solo target switch (blocker move) skipped for {pair_name}: pad vias overlap")
continue
all_segment_modifications.extend(se_mods)
all_swap_vias.extend(se_vias)
all_segment_modifications.extend(mods1 + mods2)
all_swap_vias.extend(vias1 + vias2)
if tgt_layer in all_stubs_by_layer:
all_stubs_by_layer[tgt_layer] = [s for s in all_stubs_by_layer[tgt_layer] if s[0] != pair_name]
if src_layer not in all_stubs_by_layer:
all_stubs_by_layer[src_layer] = []
all_stubs_by_layer[src_layer].append((pair_name, tgt_p_stub.segments + tgt_n_stub.segments))
if tgt_layer in stub_endpoints_by_layer:
stub_endpoints_by_layer[tgt_layer] = [e for e in stub_endpoints_by_layer[tgt_layer] if e[0] != pair_name]
if src_layer not in stub_endpoints_by_layer:
stub_endpoints_by_layer[src_layer] = []
stub_endpoints_by_layer[src_layer].append((pair_name, [(tgt_p_stub.x, tgt_p_stub.y), (tgt_n_stub.x, tgt_n_stub.y)]))
applied_swaps.add(pair_name)
single_ended_swap_count += 1
total_layer_swaps += 1
net = pcb_data.nets.get(blocking_net_id)
blocking_name = net.name if net else f"net {blocking_net_id}"
via_msg = f", added {len(vias1) + len(vias2)} pad via(s)" if vias1 or vias2 else ""
print(f" Solo target switch: {pair_name} ({tgt_layer}->{src_layer}) after moving {blocking_name} to {alt_layer}{via_msg}")
break
else:
revert_stub_layer_switch(pcb_data, se_mods, se_vias)
if pair_name in applied_swaps:
break
if single_ended_swap_count > 0:
print(f"Applied {single_ended_swap_count} single-ended swap(s) to enable diff pair switches")
# Report pairs that need vias but couldn't be swapped
for pair_name, (src_layer, tgt_layer, sources, targets, pair) in pairs_needing_via:
if pair_name not in applied_swaps:
print(f" No swap found: {pair_name} ({src_layer}->{tgt_layer}) - will need via")
return total_layer_swaps, all_stubs_by_layer, stub_endpoints_by_layer
def _net_connected_pad_locs(pcb_data: PCBData, net_id: int, tolerance: float) -> set:
"""Pad (x, y) locations of net_id that already share a connected component with
another pad of the same net -- i.e. committed pad-to-pad copper.
Via/zone-aware: reuses check_net_connectivity's union-find (the same model
check_connected and the #263 cycle prune use), so a connection that bridges
pads through a VIA to another layer is caught, not just same-layer copper."""
from check_connected import check_net_connectivity
segs = [s for s in pcb_data.segments if s.net_id == net_id]
if not segs:
return set()
vias = [v for v in pcb_data.vias if v.net_id == net_id]
pads = pcb_data.pads_by_net.get(net_id, [])
zones = [z for z in (getattr(pcb_data, 'zones', None) or []) if getattr(z, 'net_id', None) == net_id]
res = check_net_connectivity(net_id, segs, vias, pads, zones, tolerance=tolerance)
# pad_components keys are (x, y, layer, component_ref): a THROUGH-HOLE pad
# spans EVERY copper layer, so its per-layer expansion produces one entry per
# layer, all sharing the same union-find root but at the SAME (x, y). Collapse
# a pad's own multi-layer expansion to ONE anchor per position (issue #429) so
# a lone THT pad (a connector reaching all layers, connected to nothing else)
# is not mistaken for >=2 mutually-connected pads -- which would wrongly pin a
# dangling fanout stub off it as immovable. A root only counts as committed
# pad-to-pad copper when it spans >=2 DISTINCT pad positions.
by_root = {}
for loc, root in (res.get('pad_components') or {}).items():
by_root.setdefault(root, set()).add((loc[0], loc[1]))
connected = set()
for locs in by_root.values():
if len(locs) >= 2:
connected.update(locs)
return connected
def _stub_free_end_is_open(pcb_data: PCBData, stub, tolerance: float,
connected_pads: set = None) -> bool:
"""A stub is safe to relayer only if it is a DANGLING fanout stub, not part of
an already-routed connection.
If the stub launches from a pad that is ALREADY connected (via/zone-aware) to
another pad of its net, the stub is committed pad-to-pad copper (fpga_sdram
/CLK+: the 'source stub' was the whole coupled U1.B1<->J3 F.Cu run) -- relayering
it would move that copper and sever the connection. A genuine fanout stub off a
still-unconnected pad returns True. `connected_pads` is _net_connected_pad_locs
for the stub's net (computed by the caller and memoized across its stubs)."""
if stub is None or not stub.segments:
return False
if connected_pads is None:
connected_pads = _net_connected_pad_locs(pcb_data, stub.net_id, tolerance)
for px, py in connected_pads:
if abs(px - stub.pad_x) < tolerance and abs(py - stub.pad_y) < tolerance:
return False
return True
def _classify_multipoint_endpoints(
pcb_data: PCBData,
config: GridRouteConfig,
net_id: int,
endpoints: List[Tuple],
connected_pads: set,
) -> Optional[List[Dict]]:
"""Classify a multi-point net's endpoints for layer-swap planning (#265).
Shared by the common-layer collapse and the per-cluster fallback. For each
endpoint returns a dict:
'ok' : None -> compatible with EVERY layer without moving (bare
through-hole / via'd pad); or a set of layer names the endpoint
already lives on with no move (a stub's current layer, or a bare
SMD pad's copper layers).
'stub' : a StubInfo when the endpoint is a genuinely dangling, fully
walked fanout stub that a validated switch may relayer; else None.
'cur' : the endpoint's current layer name.
Returns None when any endpoint's layer index is out of range (caller aborts).
`endpoints` is get_multipoint_net_pads() output:
[(gx, gy, layer_idx, orig_x, orig_y, endpoint_obj), ...].
"""
from net_queries import expand_pad_layers
from kicad_parser import pad_is_plated_through
tol = STUB_POSITION_TOLERANCE
def _has_via_at(px, py):
return any(v.net_id == net_id and abs(v.x - px) < tol and abs(v.y - py) < tol
for v in pcb_data.vias)
entries = []
for gx, gy, layer_idx, ox, oy, obj in endpoints:
if layer_idx >= len(config.layers):
return None
layer_name = config.layers[layer_idx]
stub = get_stub_info(pcb_data, net_id, ox, oy, layer_name)
if stub is not None and stub.segments:
movable = _stub_free_end_is_open(pcb_data, stub, tol, connected_pads)
if movable:
# apply_stub_layer_switch moves the walked chain (plus
# pad-touching segments); if the free-end walk did not cover
# the group's whole same-layer component (T-branch, tolerance
# jump), moving the subset would sever the group mid-trace.
comp = connected_stub_segments_on_layer(
pcb_data, net_id, stub.layer, stub.segments)
if len(comp) != len(stub.segments):
movable = False
entries.append({'ok': {layer_name},
'stub': stub if movable else None,
'cur': layer_name})
else:
# Bare endpoint: a pad (or free-end position) with no copper here.
if pad_is_plated_through(obj) or _has_via_at(ox, oy):
entries.append({'ok': None, 'stub': None, 'cur': layer_name}) # all layers
else:
fixed = set(expand_pad_layers(getattr(obj, 'layers', None) or [],
config.layers)) or {layer_name}
entries.append({'ok': fixed, 'stub': None, 'cur': layer_name})
return entries
def _collapse_multipoint_net_to_common_layer(
pcb_data: PCBData,
config: GridRouteConfig,
net_name: str,
net_id: int,
endpoints: List[Tuple],
can_swap_to_top_layer: bool,
combined_stubs_by_layer: Dict,
all_segment_modifications: List,
all_swap_vias: List,
all_swap_segments: Optional[List],
connected_pads: set,
verbose: bool = False
) -> int:
"""Move every stub of a multi-point net onto one common layer (issue #265).
Multi-point nets (3+ unconnected endpoints) are routed incrementally
(closest pair, then taps), so the two-point (source, target) swap model
doesn't apply. Conservative first cut: find a single layer every endpoint
can live on — bare through-hole / via'd pads reach all layers, bare SMD
pads are fixed to their copper layers, stubs count as movable only when
their free end is a genuinely dangling fanout stub — and swap all
off-layer movable stubs onto it, so the incremental routing needs no
layer-change vias. When no single common layer is feasible/validated this
returns -1 and the caller falls back to a per-cluster assignment
(_assign_multipoint_net_per_cluster) that minimises the number of distinct
layers instead of forcing one.
`endpoints` is get_multipoint_net_pads() output:
[(gx, gy, layer_idx, orig_x, orig_y, endpoint_obj), ...].
Returns: number of stubs moved (> 0 = applied), 0 = a common layer already
exists with nothing to move, -1 = no feasible/validated common layer.
"""
entries = _classify_multipoint_endpoints(
pcb_data, config, net_id, endpoints, connected_pads)
if entries is None:
return -1
# Rank candidate destination layers: fewest new pad vias, then fewest
# moved stubs, then stack order. A zero-move candidate means some layer
# already satisfies every endpoint — nothing to do.
candidates = []
for L in config.layers:
to_move = []
feasible = True
for e in entries:
if e['ok'] is None or L in e['ok']:
continue
if e['stub'] is not None:
to_move.append(e['stub'])
else:
feasible = False
break
if not feasible:
continue
if not to_move:
return 0
if L == 'F.Cu' and not can_swap_to_top_layer:
continue
new_via_count = sum(1 for s in to_move if needs_pad_via_for_switch(s))
candidates.append((new_via_count, len(to_move), config.layers.index(L), L, to_move))
candidates.sort(key=lambda c: (c[0], c[1], c[2]))
for _, _, _, dest_layer, to_move in candidates:
ok = True
for stub in to_move:
valid, reason = validate_single_swap(
stub, dest_layer, combined_stubs_by_layer, pcb_data, config)
if not valid:
if verbose:
print(f" Multi-point swap rejected: {net_name} -> {dest_layer}: {reason}")
ok = False
break
if not ok:
continue
# Apply all of the net's swaps, then via-fit the new pad vias
# COLLECTIVELY (they were validated independently and against the
# pre-swap board, so two new same-net pad vias were never checked
# against each other — same funnel as the #299 swap-pair audit).
net_vias, net_mods = [], []
net_segments = [] if all_swap_segments is not None else None
for stub in to_move:
vias, mods = apply_stub_layer_switch(
pcb_data, stub, dest_layer, config, debug=False,
new_segments_out=net_segments)
net_vias.extend(vias)
net_mods.extend(mods)
if net_vias and not _swap_vias_fit_or_shrink(pcb_data, net_vias, config):
revert_stub_layer_switch(pcb_data, net_mods, net_vias)
if verbose:
print(f" Multi-point swap REJECTED (pad vias do not fit): "
f"{net_name} -> {dest_layer}")
continue
all_swap_vias.extend(net_vias)
all_segment_modifications.extend(net_mods)
if all_swap_segments is not None and net_segments:
all_swap_segments.extend(net_segments)
print(f" Multi-point collapse: {net_name} -> {dest_layer} "
f"({len(to_move)} stub(s) moved)")
return len(to_move)
return -1
def _assign_multipoint_net_per_cluster(
pcb_data: PCBData,
config: GridRouteConfig,
net_name: str,
net_id: int,
endpoints: List[Tuple],
can_swap_to_top_layer: bool,
combined_stubs_by_layer: Dict,
all_segment_modifications: List,
all_swap_vias: List,
all_swap_segments: Optional[List],
connected_pads: set,
verbose: bool = False
) -> int:
"""Per-cluster layer assignment fallback for a multi-point net (issue #265).
Runs only after _collapse_multipoint_net_to_common_layer returns -1 (no
single layer serves every endpoint). Instead of forcing one common layer,
partition the endpoints onto the FEWEST distinct layers (a greedy set
cover), so the incremental multi-point router needs as few layer-change
vias as possible, then relayer only the stubs whose assigned layer differs
from their current one — each through apply_stub_layer_switch and the same
validators the common-layer path uses. A stub whose validated switch fails
is left on its current layer (its cluster just stays split; never forced).
Returns: number of stubs moved (> 0 = applied), 0 = a valid multi-layer
assignment needs no moves (net naturally spans layers), -1 = nothing done
(no moves were feasible/validated, board unchanged).
"""
entries = _classify_multipoint_endpoints(
pcb_data, config, net_id, endpoints, connected_pads)
if entries is None:
return -1
n = len(entries)
if n == 0:
return -1
def _placement(entry, L):
"""'free' (already there / no move), 'move' (needs validated switch),
or None (endpoint cannot live on L)."""
if entry['ok'] is None or L in entry['ok']:
return 'free'
if entry['stub'] is not None:
if L == 'F.Cu' and not can_swap_to_top_layer:
return None
return 'move'
return None
# --- Greedy set cover: pick the fewest layers that cover every endpoint.
# At each step take the layer feasible for the most still-uncovered
# endpoints; deterministic tie-break = lowest layer index (config order).
uncovered = set(range(n))
chosen: List[str] = []
while uncovered:
best_L, best_count = None, 0
for L in config.layers:
cnt = sum(1 for i in uncovered if _placement(entries[i], L) is not None)
if cnt > best_count:
best_count, best_L = cnt, L
if best_L is None or best_count == 0:
# Some endpoint is feasible on no layer at all -- cannot happen
# (every endpoint is at least 'free' on its own current layer),
# but guard defensively rather than loop forever.
return -1
chosen.append(best_L)
uncovered = {i for i in uncovered if _placement(entries[i], best_L) is None}
if len(chosen) <= 1:
# A single layer covered everything -- the common-layer path already
# tried (and failed) this; nothing new to attempt here.
return -1
chosen_by_index = [L for L in config.layers if L in chosen]
# --- Assignment: keep every endpoint on a chosen layer it already lives on
# (prefer its current layer), moving a stub only when none of the chosen
# layers is free for it. Immovable endpoints forced a layer into `chosen`,
# so they always land 'free'.
assign: List[Optional[str]] = [None] * n
for i, e in enumerate(entries):
if e['cur'] in chosen and _placement(e, e['cur']) == 'free':
assign[i] = e['cur']
for i, e in enumerate(entries):
if assign[i] is not None:
continue
free_here = [L for L in chosen_by_index if _placement(e, L) == 'free']
if free_here:
assign[i] = free_here[0]
else:
move_here = [L for L in chosen_by_index if _placement(e, L) == 'move']
# move_here is non-empty: set cover guarantees e is coverable by a
# chosen layer, and it wasn't free on any of them.
assign[i] = move_here[0]
# --- Spatial sanity guard: don't scatter a spatially tight pair across
# layers when a single chosen layer is FREE for both. Only co-locates onto
# a shared free-in-`chosen` layer (never introduces a move or a new layer),
# so it is safe and deterministic. Single pass over sorted pairs.
coords = [(endpoints[i][3], endpoints[i][4]) for i in range(n)]
dists = []
for i in range(n):
for j in range(i + 1, n):
dx = coords[i][0] - coords[j][0]
dy = coords[i][1] - coords[j][1]
dists.append((dx * dx + dy * dy) ** 0.5)
if dists:
srt = sorted(dists)
median = srt[len(srt) // 2]
thresh = 2.0 * median
for i in range(n):
for j in range(i + 1, n):
if assign[i] == assign[j]:
continue
dx = coords[i][0] - coords[j][0]
dy = coords[i][1] - coords[j][1]
if (dx * dx + dy * dy) ** 0.5 > thresh:
continue
shared = [L for L in chosen_by_index
if _placement(entries[i], L) == 'free'
and _placement(entries[j], L) == 'free']
if shared:
assign[i] = assign[j] = shared[0]
# --- Collect the stubs that actually need to move (assigned != current).
to_move = [] # (stub, dest_layer)
for i, e in enumerate(entries):
if e['stub'] is not None and assign[i] != e['cur']:
to_move.append((e['stub'], assign[i]))
if not to_move:
# A valid multi-layer split needs no swaps -- the net naturally spans
# `chosen` layers. Nothing to change; report as handled.
if verbose:
print(f" Multi-point per-cluster: {net_name} spans "
f"{len(chosen)} layer(s) natively -- no swap needed")
return 0
# Apply each validated move; skip (leave on current layer) any that fail
# validation. Then via-fit the accumulated new pad vias COLLECTIVELY (two
# same-net pad vias were each validated against the pre-move board, never
# against each other -- same funnel as the common-layer path / #299).
net_vias, net_mods = [], []
net_segments = [] if all_swap_segments is not None else None
moved = 0
for stub, dest_layer in to_move:
valid, reason = validate_single_swap(
stub, dest_layer, combined_stubs_by_layer, pcb_data, config)
if not valid:
if verbose:
print(f" Per-cluster swap rejected: {net_name} -> {dest_layer}: {reason}")
continue
vias, mods = apply_stub_layer_switch(
pcb_data, stub, dest_layer, config, debug=False,
new_segments_out=net_segments)
net_vias.extend(vias)
net_mods.extend(mods)
moved += 1
if moved == 0:
return -1
if net_vias and not _swap_vias_fit_or_shrink(pcb_data, net_vias, config):
revert_stub_layer_switch(pcb_data, net_mods, net_vias)
if verbose:
print(f" Multi-point per-cluster REJECTED (pad vias do not fit): {net_name}")
return -1
all_swap_vias.extend(net_vias)
all_segment_modifications.extend(net_mods)
if all_swap_segments is not None and net_segments:
all_swap_segments.extend(net_segments)
print(f" Multi-point per-cluster: {net_name} -> {len(chosen)} layer(s) "
f"({moved} stub(s) moved)")
return moved
def apply_single_ended_layer_swaps(
pcb_data: PCBData,
config: GridRouteConfig,
single_ended_net_ids: List[Tuple[str, int]],
can_swap_to_top_layer: bool,
all_segment_modifications: List,
all_swap_vias: List,
all_stubs_by_layer: Optional[Dict] = None,
verbose: bool = False,
all_swap_segments: Optional[List] = None
) -> int:
"""
Apply upfront layer swap optimization for single-ended nets.
Args:
pcb_data: PCB data structure (modified in place)
config: Routing configuration
single_ended_net_ids: List of (net_name, net_id) tuples to route
can_swap_to_top_layer: Whether stubs can be swapped to F.Cu
all_segment_modifications: List to append layer modifications (modified in place)
all_swap_vias: List to append vias from swapping (modified in place)
all_stubs_by_layer: Optional dict from diff pair layer swaps for validation
verbose: Whether to print verbose output
Returns:
total_layer_swaps: Number of layer swaps applied
"""
# Collect layer info for single-ended nets
single_net_layer_info = {} # net_name -> (src_layer, tgt_layer, sources, targets, net_id)
multipoint_candidates = [] # (net_name, net_id, endpoints) spanning >1 layer (#265)
for net_name, net_id in single_ended_net_ids:
# Multi-point nets get the common-layer collapse in PHASE 3 below
multipoint_pads = get_multipoint_net_pads(pcb_data, net_id, config)
if multipoint_pads:
layers_used = {config.layers[ep[2]] for ep in multipoint_pads}
if len(layers_used) > 1:
multipoint_candidates.append((net_name, net_id, multipoint_pads))
continue
sources, targets, error = get_net_endpoints(pcb_data, net_id, config)
if error or not sources or not targets:
continue
# "Needs a via" is a question about the terminal SETS, not about their
# first elements. get_net_endpoints returns EVERY terminal -- pads, and
# (since via terminals) each layer an existing via already reaches -- so
# a net whose two ends both reach some common layer needs no via and
# wants no upfront swap.
#
# Comparing sources[0] to targets[0] asked whichever question the list
# order happened to encode. Measured on the #534 escape-via board: with
# F.Cu/B.Cu appended to config.layers as forbidden obstacles, sources[0]
# was the F.Cu PAD while targets[0] was an In1.Cu VIA terminal, so the
# net was declared to need a via -- though the two sets were IDENTICAL
# ({F.Cu, B.Cu, In1.Cu, In2.Cu}) and both escape vias already reached
# every routing layer. The swap then moved the source stub off F.Cu and
# minted a via IN the pad, orphaning the designer's own escape via,
# which the #622 stub-debris trim then correctly collected as dangling.
# The other end was untouched purely because targets[0] sorted to a via
# terminal rather than a pad.
# ROUTABLE layers only. Full-stack mode appends the board's unrequested
# copper layers with FORBIDDEN cost (-1) so their copper blocks vias;
# terminals land on those too, and a shared FORBIDDEN layer is not a
# place the two ends can meet -- counting it would skip the swap for a
# net that genuinely needs one.
_costs = getattr(config, 'layer_costs', None) or []
def _routable(idx):
return idx >= len(_costs) or _costs[idx] >= 0
src_layers = {config.layers[e[2]] for e in sources if _routable(e[2])}
tgt_layers = {config.layers[e[2]] for e in targets if _routable(e[2])}
if src_layers & tgt_layers:
continue
src_layer = config.layers[sources[0][2]]
tgt_layer = config.layers[targets[0][2]]
single_net_layer_info[net_name] = (src_layer, tgt_layer, sources, targets, net_id)
if not single_net_layer_info and not multipoint_candidates:
return 0
if single_net_layer_info:
print(f"\nAnalyzing layer swaps for {len(single_net_layer_info)} single-ended net(s) needing via...")
# Pre-collect single-ended stubs by layer
single_stubs_by_layer = collect_single_ended_stubs_by_layer(pcb_data, single_net_layer_info, config)
# Combine with diff pair stubs if they exist
if all_stubs_by_layer:
combined_stubs_by_layer = {layer: list(stubs) for layer, stubs in all_stubs_by_layer.items()}
else:
combined_stubs_by_layer = {}
for layer, stubs in single_stubs_by_layer.items():
combined_stubs_by_layer.setdefault(layer, []).extend(stubs)
applied_single_swaps = set()
swap_pair_count = 0
solo_switch_count = 0
total_layer_swaps = 0
# Don't relayer a stub that is already committed pad-to-pad copper (would move
# routed copper and sever it -- fpga_sdram /CLK+ after the coupled pass). Memoize
# each net's already-connected pad locations across its source/target stubs.
_conn_memo = {}
def _conn_pads(nid):
if nid not in _conn_memo:
_conn_memo[nid] = _net_connected_pad_locs(pcb_data, nid, STUB_POSITION_TOLERANCE)
return _conn_memo[nid]
# PHASE 1: Try swap pairs first
# For swap pairs (Net1: src=A,tgt=B and Net2: src=B,tgt=A), we can:
# Option 1: Both move to layer B (Net1 src A→B, Net2 tgt A→B)
# Option 2: Both move to layer A (Net1 tgt B→A, Net2 src B→A)
for net1_name, (src1, tgt1, sources1, targets1, net1_id) in single_net_layer_info.items():
if net1_name in applied_single_swaps:
continue
for net2_name, (src2, tgt2, sources2, targets2, net2_id) in single_net_layer_info.items():
if net2_name in applied_single_swaps or net1_name == net2_name:
continue
# Check if they can help each other: src1==tgt2 and tgt1==src2
if src1 == tgt2 and tgt1 == src2:
# Get stubs for both source AND target endpoints
src1_stub = get_stub_info(pcb_data, net1_id, sources1[0][3], sources1[0][4], src1)
tgt1_stub = get_stub_info(pcb_data, net1_id, targets1[0][3], targets1[0][4], tgt1)
src2_stub = get_stub_info(pcb_data, net2_id, sources2[0][3], sources2[0][4], src2)
tgt2_stub = get_stub_info(pcb_data, net2_id, targets2[0][3], targets2[0][4], tgt2)
# Don't relayer an already-routed stub (its pad already connects to
# another pad -- committed copper).
cp1, cp2 = _conn_pads(net1_id), _conn_pads(net2_id)
src1_stub = src1_stub if _stub_free_end_is_open(pcb_data, src1_stub, STUB_POSITION_TOLERANCE, cp1) else None
tgt1_stub = tgt1_stub if _stub_free_end_is_open(pcb_data, tgt1_stub, STUB_POSITION_TOLERANCE, cp1) else None
src2_stub = src2_stub if _stub_free_end_is_open(pcb_data, src2_stub, STUB_POSITION_TOLERANCE, cp2) else None
tgt2_stub = tgt2_stub if _stub_free_end_is_open(pcb_data, tgt2_stub, STUB_POSITION_TOLERANCE, cp2) else None
# Try different combinations to find one that works
# Each net needs to end up with both endpoints on same layer
swap_options = []
if src1_stub and src2_stub:
# Option A: Net1 src→tgt1, Net2 src→tgt2 (both go to their tgt layers)
swap_options.append(('src', 'src', src1_stub, tgt1, src2_stub, tgt2))
if tgt1_stub and tgt2_stub:
# Option B: Net1 tgt→src1, Net2 tgt→src2 (both go to their src layers)
swap_options.append(('tgt', 'tgt', tgt1_stub, src1, tgt2_stub, src2))
if src1_stub and tgt2_stub:
# Option C: Net1 src→tgt1, Net2 tgt→src2
swap_options.append(('src', 'tgt', src1_stub, tgt1, tgt2_stub, src2))
if tgt1_stub and src2_stub:
# Option D: Net1 tgt→src1, Net2 src→tgt2
swap_options.append(('tgt', 'src', tgt1_stub, src1, src2_stub, tgt2))
for opt_name1, opt_name2, stub1, dest1, stub2, dest2 in swap_options:
valid1, reason1 = validate_single_swap(
stub1, dest1, combined_stubs_by_layer, pcb_data, config,
swap_partner_name=net2_name, swap_partner_net_ids={net2_id}
)
valid2, reason2 = validate_single_swap(
stub2, dest2, combined_stubs_by_layer, pcb_data, config,
swap_partner_name=net1_name, swap_partner_net_ids={net1_id}
)
if valid1 and valid2:
# Apply both swaps
vias1, mods1 = apply_stub_layer_switch(pcb_data, stub1, dest1, config, debug=False)
vias2, mods2 = apply_stub_layer_switch(pcb_data, stub2, dest2, config, debug=False)
# #299 audit: each net's validation excludes its swap
# partner and runs before the partner's via exists, so
# the two new pad vias were never checked against EACH
# OTHER (adjacent connector pins collide).
if not _swap_vias_fit_or_shrink(pcb_data, vias1 + vias2, config):
revert_stub_layer_switch(pcb_data, mods1 + mods2, vias1 + vias2)
print(f" Swap pair skipped for {net1_name} <-> {net2_name}: pad vias overlap")
continue # try the next swap option
all_swap_vias.extend(vias1 + vias2)
all_segment_modifications.extend(mods1 + mods2)
applied_single_swaps.add(net1_name)
applied_single_swaps.add(net2_name)
swap_pair_count += 1
total_layer_swaps += 2
print(f" Swap pair ({opt_name1}/{opt_name2}): {net1_name} <-> {net2_name}")
break
else:
continue # No valid option found, try next partner
break # Found a valid option, exit inner loop
# PHASE 2: Try remaining solo switches (swap pair candidates that failed)
for net_name, (src_layer, tgt_layer, sources, targets, net_id) in single_net_layer_info.items():
if net_name in applied_single_swaps:
continue
# Try source -> target layer switch first
src_stub = get_stub_info(pcb_data, net_id, sources[0][3], sources[0][4], src_layer)
# Check can_swap_to_top_layer restriction. Don't relayer an already-routed
# stub whose free end lands on other copper (would move committed copper).
if (src_stub and _stub_free_end_is_open(pcb_data, src_stub, STUB_POSITION_TOLERANCE, _conn_pads(net_id))
and (can_swap_to_top_layer or tgt_layer != 'F.Cu')):
valid, reason = validate_single_swap(
src_stub, tgt_layer, combined_stubs_by_layer, pcb_data, config
)
if valid:
vias, mods = apply_stub_layer_switch(pcb_data, src_stub, tgt_layer, config, debug=False,
new_segments_out=all_swap_segments)
# via-fit funnel at EVERY apply site (#315/#340) -- twin of the
# solo target switch below.
if vias and not _swap_vias_fit_or_shrink(pcb_data, vias, config):
revert_stub_layer_switch(pcb_data, mods, vias)
if verbose:
print(f" Solo source switch REJECTED (via does not fit): {net_name}")
else:
all_swap_vias.extend(vias)
all_segment_modifications.extend(mods)
applied_single_swaps.add(net_name)
solo_switch_count += 1
total_layer_swaps += 1
print(f" Solo source switch: {net_name} ({src_layer}->{tgt_layer})")
continue
# Try target -> source layer switch as fallback
tgt_stub = get_stub_info(pcb_data, net_id, targets[0][3], targets[0][4], tgt_layer)
if (tgt_stub and _stub_free_end_is_open(pcb_data, tgt_stub, STUB_POSITION_TOLERANCE, _conn_pads(net_id))
and (can_swap_to_top_layer or src_layer != 'F.Cu')):
valid, reason = validate_single_swap(
tgt_stub, src_layer, combined_stubs_by_layer, pcb_data, config
)
if valid:
vias, mods = apply_stub_layer_switch(pcb_data, tgt_stub, src_layer, config, debug=False,
new_segments_out=all_swap_segments)
# via-fit funnel at EVERY apply_stub_layer_switch site (#315):
# validate_single_swap checks stub geometry only, so this solo
# path shipped a swap via whose DRILL overlapped the same net's
# plane via by 27um (cparti SPIm_SCK/MOSI, #340). Reject-and-
# revert keeps the net on its original layer -- honest.
if vias and not _swap_vias_fit_or_shrink(pcb_data, vias, config):
revert_stub_layer_switch(pcb_data, mods, vias)
if verbose:
print(f" Solo target switch REJECTED (via does not fit): {net_name}")
else:
all_swap_vias.extend(vias)
all_segment_modifications.extend(mods)
applied_single_swaps.add(net_name)
solo_switch_count += 1
total_layer_swaps += 1
print(f" Solo target switch: {net_name} ({tgt_layer}->{src_layer})")
continue
# Report if no swap found
if verbose:
print(f" No swap found: {net_name} ({src_layer}->{tgt_layer}) - will need via")
# PHASE 3 (#265): multi-point nets whose endpoints span several layers.
# Runs AFTER the two-point phases so validation (which reads live
# pcb_data copper) sees their moves as committed; conservative
# common-layer collapse only — see _collapse_multipoint_net_to_common_layer.
multipoint_collapse_count = 0
multipoint_cluster_count = 0
multipoint_unswapped = []
if multipoint_candidates:
print(f"\nAnalyzing layer swaps for {len(multipoint_candidates)} multi-point net(s) spanning layers...")
for net_name, net_id, endpoints in multipoint_candidates:
moved = _collapse_multipoint_net_to_common_layer(
pcb_data, config, net_name, net_id, endpoints,
can_swap_to_top_layer, combined_stubs_by_layer,
all_segment_modifications, all_swap_vias, all_swap_segments,
_conn_pads(net_id), verbose=verbose)
if moved > 0:
multipoint_collapse_count += 1
total_layer_swaps += moved
elif moved < 0:
# No single common layer: fall back to a per-cluster assignment that
# minimises the number of distinct layers (issue #265 follow-up).
cluster_moved = _assign_multipoint_net_per_cluster(
pcb_data, config, net_name, net_id, endpoints,
can_swap_to_top_layer, combined_stubs_by_layer,
all_segment_modifications, all_swap_vias, all_swap_segments,
_conn_pads(net_id), verbose=verbose)
if cluster_moved > 0:
multipoint_cluster_count += 1
total_layer_swaps += cluster_moved
elif cluster_moved < 0:
multipoint_unswapped.append(net_name)
# cluster_moved == 0: net natively spans >1 layer, no swap needed
# moved == 0: some layer already satisfies every endpoint - no swap needed
if multipoint_unswapped:
print(f" No common layer found for {len(multipoint_unswapped)} multi-point net(s) (left as-is)")
if verbose:
for name in multipoint_unswapped:
print(f" - {name}")
if swap_pair_count > 0:
print(f"Applied {swap_pair_count} single-ended swap pair(s) ({swap_pair_count * 2} nets)")
if solo_switch_count > 0:
print(f"Applied {solo_switch_count} single-ended solo switch(es)")
if multipoint_collapse_count > 0:
print(f"Applied {multipoint_collapse_count} multi-point common-layer collapse(s)")
if multipoint_cluster_count > 0:
print(f"Applied {multipoint_cluster_count} multi-point per-cluster assignment(s)")
return total_layer_swaps