Problem
_gap_channel_base (src/nf_metro/layout/routing/normalize.py:4865) still centres a gap channel bundle against the coordinate origin when a column has sections elsewhere on the grid but none in the specific row being routed. PR #1876 fixed the sibling case - a column absent from the whole grid - via off_grid_gap_bundle_midpoint (src/nf_metro/layout/routing/common.py:498), but this row-local variant of the same defect remains.
Symptom
examples/topologies/fan_bypass_shared_band.mmd (reproducer below) violates translation-rigidity by D/2 for this reason: its "gap2" channel answers a whole-map translation with only half the expected shift - the same signature #1876 fixed for the grid-absent case, just triggered by an empty row rather than an empty column.
Why the obvious fix doesn't work
Anchoring this case the same way (a fixed clearance off the one row-local edge, mirroring off_grid_gap_bundle_midpoint) was tried and reverted during the #1789/#1876 work: it moves this fixture's gap2 channel from x=353.5 to x=691, which routes a bypass at y=318 straight through the lower_blocker section (bbox x 397-627, y 220-320), tripping _guard_no_route_through_section (src/nf_metro/layout/phases/guards.py:3678) and breaking tests/test_fan_bypass_shared_band.py.
The regression's root cause is separate from the channel-anchoring bug itself: the row-1/row-2 gap in this fixture is 50px, but the inter-row corridor needs INTER_ROW_EDGE_CLEARANCE (src/nf_metro/layout/constants.py:463) + INTER_ROW_HEADER_CLEARANCE (src/nf_metro/layout/constants.py:493) = 78px. _center_inter_row_channel (src/nf_metro/layout/routing/common.py:3100) biases to hi = 370 - 52 = 318, i.e. 2px inside row 1's boxes. Today this only avoids visibly breaking because the buggy origin-centred channel x happens to land left of the blocker - a coincidence, not correctness.
So a correct fix likely needs the row gap to grow to accommodate the corridor's real clearance requirement, before or as part of anchoring the channel - plan-time row-gap growth, not just a channel-anchoring change in isolation.
Reproducer
examples/topologies/fan_bypass_shared_band.mmd:
%%metro title: Junction fan sharing a bypass band
%%metro line: local | Local branch | #2db572
%%metro line: long | Bypass branch | #a855f7
%%metro line: obstacle | Obstacle | #f59e0b
%%metro grid: source | 0,0
%%metro grid: local | 1,0
%%metro grid: blocker | 2,0
%%metro grid: lower_blocker | 2,1
%%metro grid: distant | 3,2
graph LR
subgraph source [Source]
%%metro exit: right | local, long
source_step[Source step]
end
subgraph local [Local branch]
%%metro entry: left | local
local_step[Local step]
end
subgraph blocker [Intervening section]
blocker_start[Blocker start]
blocker_end[Blocker end]
blocker_start -->|obstacle| blocker_end
end
subgraph distant [Distant branch]
%%metro entry: left | long
distant_step[Distant step]
end
subgraph lower_blocker [Lower intervening section]
lower_start[Lower blocker start]
lower_end[Lower blocker end]
lower_start -->|obstacle| lower_end
end
source_step -->|local| local_step
source_step -->|long| distant_step
Scope
Layout/routing: _gap_channel_base (or a row-local counterpart) and row-gap sizing (wherever row gaps get their enforced minimum, e.g. _enforce_min_row_gaps). Diagnosed during the #1789/#1876 work; explicitly out of that PR's scope since the correct fix requires plan-time row-gap growth rather than a channel-anchoring change alone.
Problem
_gap_channel_base(src/nf_metro/layout/routing/normalize.py:4865) still centres a gap channel bundle against the coordinate origin when a column has sections elsewhere on the grid but none in the specific row being routed. PR #1876 fixed the sibling case - a column absent from the whole grid - viaoff_grid_gap_bundle_midpoint(src/nf_metro/layout/routing/common.py:498), but this row-local variant of the same defect remains.Symptom
examples/topologies/fan_bypass_shared_band.mmd(reproducer below) violates translation-rigidity by D/2 for this reason: its "gap2" channel answers a whole-map translation with only half the expected shift - the same signature #1876 fixed for the grid-absent case, just triggered by an empty row rather than an empty column.Why the obvious fix doesn't work
Anchoring this case the same way (a fixed clearance off the one row-local edge, mirroring
off_grid_gap_bundle_midpoint) was tried and reverted during the #1789/#1876 work: it moves this fixture's gap2 channel from x=353.5 to x=691, which routes a bypass at y=318 straight through thelower_blockersection (bbox x 397-627, y 220-320), tripping_guard_no_route_through_section(src/nf_metro/layout/phases/guards.py:3678) and breakingtests/test_fan_bypass_shared_band.py.The regression's root cause is separate from the channel-anchoring bug itself: the row-1/row-2 gap in this fixture is 50px, but the inter-row corridor needs
INTER_ROW_EDGE_CLEARANCE(src/nf_metro/layout/constants.py:463) +INTER_ROW_HEADER_CLEARANCE(src/nf_metro/layout/constants.py:493) = 78px._center_inter_row_channel(src/nf_metro/layout/routing/common.py:3100) biases tohi = 370 - 52 = 318, i.e. 2px inside row 1's boxes. Today this only avoids visibly breaking because the buggy origin-centred channel x happens to land left of the blocker - a coincidence, not correctness.So a correct fix likely needs the row gap to grow to accommodate the corridor's real clearance requirement, before or as part of anchoring the channel - plan-time row-gap growth, not just a channel-anchoring change in isolation.
Reproducer
examples/topologies/fan_bypass_shared_band.mmd:Scope
Layout/routing:
_gap_channel_base(or a row-local counterpart) and row-gap sizing (wherever row gaps get their enforced minimum, e.g._enforce_min_row_gaps). Diagnosed during the #1789/#1876 work; explicitly out of that PR's scope since the correct fix requires plan-time row-gap growth rather than a channel-anchoring change alone.