diff --git a/examples/topologies/serpentine_rl_bundle.mmd b/examples/topologies/serpentine_rl_bundle.mmd new file mode 100644 index 000000000..4dbef183d --- /dev/null +++ b/examples/topologies/serpentine_rl_bundle.mmd @@ -0,0 +1,60 @@ +%%metro title: Serpentine RL Bundle +%%metro style: dark +%%metro line: dna | DNA | #4a90d9 +%%metro line: rna | RNA | #e63946 +%%metro grid: preprocessing | 0,0 +%%metro grid: gatk | 1,0 +%%metro grid: variant_calling | 2,0 +%%metro grid: normalization | 2,1 +%%metro grid: consensus | 1,1 +%%metro grid: realignment | 0,1 + +graph LR + subgraph preprocessing [Pre-processing] + %%metro exit: right | dna, rna + pp1[Trim] + pp2[Align] + pp1 -->|dna,rna| pp2 + end + subgraph gatk [GATK] + %%metro entry: left | dna, rna + %%metro exit: right | dna, rna + gk1[BQSR] + gk2[Apply] + gk1 -->|dna,rna| gk2 + end + subgraph variant_calling [Variant Calling] + %%metro entry: left | dna, rna + %%metro exit: bottom | dna, rna + vc1[Call] + vc2[Filter] + vc1 -->|dna,rna| vc2 + end + subgraph normalization [Normalization] + %%metro direction: RL + %%metro entry: top | dna, rna + %%metro exit: left | dna, rna + nz1[Decompose] + nz2[Norm] + nz1 -->|dna,rna| nz2 + end + subgraph consensus [Consensus] + %%metro direction: RL + %%metro entry: right | dna, rna + %%metro exit: left | dna, rna + cs1[Merge] + cs2[Vote] + cs1 -->|dna,rna| cs2 + end + subgraph realignment [Realignment] + %%metro direction: RL + %%metro entry: right | dna, rna + rl1[Realign] + rl2[Recall] + rl1 -->|dna,rna| rl2 + end + pp2 -->|dna,rna| gk1 + gk2 -->|dna,rna| vc1 + vc2 -->|dna,rna| nz1 + nz2 -->|dna,rna| cs1 + cs2 -->|dna,rna| rl1 diff --git a/scripts/gallery.yaml b/scripts/gallery.yaml index 216e97f87..b630cdfae 100644 --- a/scripts/gallery.yaml +++ b/scripts/gallery.yaml @@ -614,6 +614,15 @@ gallery: "A two-line bundle folds through a 2x3 section grid, wrapping twice. Each wrap returns through its own inter-row gap, so the two stacked return runs stay on distinct channels rather than collapsing onto one." + - id: serpentine_rl_bundle + source_dir: examples/topologies + category: Serpentine Layout + description: + "A true boustrophedon fold of a two-line bundle: the top row flows left to + right, drops straight down the right edge, and the return row flows right to + left via direction: RL. The bundle stays on the outside of the fold the whole + way round (top, right, then bottom) with no crossover, and the descent column + aligns under the exit so the drop is a clean U." - id: branch_fold_forward source_dir: examples/topologies category: Serpentine Layout diff --git a/src/nf_metro/layout/phases/ports.py b/src/nf_metro/layout/phases/ports.py index 15ec8d421..e456e5350 100644 --- a/src/nf_metro/layout/phases/ports.py +++ b/src/nf_metro/layout/phases/ports.py @@ -1005,6 +1005,7 @@ def _align_perpendicular_exit_port( _set_port_y(graph, port_id, exit_y) _align_drop_target_trunk(graph, port_id, exit_x) + _align_horizontal_drop_target(graph, port_id, exit_x) def _align_drop_target_trunk(graph: MetroGraph, port_id: str, exit_x: float) -> None: @@ -1032,29 +1033,85 @@ def _align_drop_target_trunk(graph: MetroGraph, port_id: str, exit_x: float) -> graph._cross_column_perp_bridges.add(tgt_section.id) continue delta = exit_x - trunk_x - if abs(delta) < SAME_COORD_TOLERANCE: + if abs(delta) >= SAME_COORD_TOLERANCE: + _shift_section_trunk(graph, tgt_section, delta, exit_x) + + +def _align_horizontal_drop_target( + graph: MetroGraph, port_id: str, exit_x: float +) -> None: + """Shift a horizontal-flow drop target's trunk so its perp entry aligns to X. + + A perpendicular exit dropping into a horizontal-flow (LR/RL) section's + TOP/BOTTOM entry needs the descent to run straight: the entry port must sit + under the exit X. The whole trunk shifts by the same delta, so the entry port + lands on the exit X while the first station keeps its station-elbow offset and + the drop turns into it below. Unlike a TB target (``_align_drop_target_trunk``) + the first station cannot sit on the drop column -- a perpendicular port sharing + a horizontal-flow station's X is a station-as-elbow violation -- so the + alignment reference is the entry port, not the trunk's first station. + """ + for tgt_section, entry_port in _horizontal_drop_targets(graph, port_id): + if not _drop_x_within_section(tgt_section, exit_x): + graph._cross_column_perp_bridges.add(tgt_section.id) continue - bbox_right = tgt_section.bbox_x + tgt_section.bbox_w - for sid in tgt_section.station_ids: - port = graph.ports.get(sid) - if port is not None and port.side in (PortSide.LEFT, PortSide.RIGHT): - continue - st = graph.stations.get(sid) - if st: - st.x += delta - if port: - port.x += delta - # Grow the box only if the shifted trunk would otherwise overrun it. - overrun = (exit_x + CURVE_RADIUS) - bbox_right - if overrun > 0: - tgt_section.bbox_w += overrun + delta = exit_x - entry_port.x + if abs(delta) >= SAME_COORD_TOLERANCE: + _shift_section_trunk(graph, tgt_section, delta, exit_x) + + +def _shift_section_trunk( + graph: MetroGraph, section: Section, delta: float, exit_x: float +) -> None: + """Shift a section's trunk by *delta*, growing the box to fit the new extent. + + The trunk -- internal stations and the perpendicular drop entry/exit ports -- + moves; the flow-side LEFT/RIGHT ports stay on the grid column edge so a source + and target stacked in one column keep their shared right edge. The box widens + only if the shifted trunk would overrun ``exit_x`` plus a corner radius. + """ + bbox_right = section.bbox_x + section.bbox_w + for sid in section.station_ids: + port = graph.ports.get(sid) + if port is not None and port.side in (PortSide.LEFT, PortSide.RIGHT): + continue + st = graph.stations.get(sid) + if st: + st.x += delta + if port: + port.x += delta + overrun = (exit_x + CURVE_RADIUS) - bbox_right + if overrun > 0: + section.bbox_w += overrun def _drop_targets(graph: MetroGraph, port_id: str) -> Iterator[Section]: - """Yield TB/BT sections reached from an exit port via a vertical drop. + """Yield vertical-flow (TB/BT) sections reached from a perp exit drop.""" + return ( + sec + for sec, _port in _perp_drop_targets(graph, port_id) + if lanes_run_along_x(sec.direction) + ) + + +def _horizontal_drop_targets( + graph: MetroGraph, port_id: str +) -> Iterator[tuple[Section, Port]]: + """Yield (horizontal-flow LR/RL section, its perp entry port) for a drop.""" + return ( + (sec, port) + for sec, port in _perp_drop_targets(graph, port_id) + if lanes_run_along_y(sec.direction) + ) + + +def _perp_drop_targets( + graph: MetroGraph, port_id: str +) -> Iterator[tuple[Section, Port]]: + """Yield (section, its TOP/BOTTOM entry port) reached from a perp exit drop. - Follows the exit port's edges (directly or through a fan-out junction) to - any TOP/BOTTOM entry port on a TB/BT section. + Follows the exit port's edges, directly or through a fan-out junction, to any + TOP/BOTTOM entry port and its owning section. """ for edge in graph.edges_from(port_id): targets = [edge.target] @@ -1069,8 +1126,8 @@ def _drop_targets(graph: MetroGraph, port_id: str) -> Iterator[Section]: ): continue sec = graph.sections.get(tp.section_id) - if sec and sec.direction in ("TB", "BT"): - yield sec + if sec: + yield sec, tp def _align_lr_exit_port( diff --git a/src/nf_metro/layout/routing/perp.py b/src/nf_metro/layout/routing/perp.py index e557cce12..c3d250267 100644 --- a/src/nf_metro/layout/routing/perp.py +++ b/src/nf_metro/layout/routing/perp.py @@ -32,7 +32,7 @@ from __future__ import annotations -from nf_metro.layout.geometry import lanes_run_along_x +from nf_metro.layout.geometry import lanes_run_along_x, lanes_run_along_y from nf_metro.layout.routing.common import needs_perp_approach_fan from nf_metro.layout.routing.context import ( _get_offset, @@ -126,6 +126,11 @@ def _perp_entry_crossing_x( feeder_sec = ctx.graph.sections.get(section_id) if section_id else None if feeder_sec is not None and lanes_run_along_x(feeder_sec.direction): return port_x + _tb_x_offset(ctx, source, line_id, section_id) + if feeder_sec is not None and lanes_run_along_y(feeder_sec.direction): + # Horizontal-flow feeder: the up-and-over drop lands each line on the + # entry port's own per-line offset, so the crossing tracks that, not the + # feeder's section lane. + return port_x + _get_offset(ctx, entry_port_id, line_id) return port_x - max_index * ctx.offset_step diff --git a/src/nf_metro/layout/routing/reversal.py b/src/nf_metro/layout/routing/reversal.py index b638eea5b..b61f46755 100644 --- a/src/nf_metro/layout/routing/reversal.py +++ b/src/nf_metro/layout/routing/reversal.py @@ -176,12 +176,20 @@ def _detect_tb_bottom_top_entries( src_port and not src_port.is_entry and src_port.side == PortSide.BOTTOM - and src.section_id in tb_sections ): continue if lanes_run_along_x(section.direction): - vertical_receivers.setdefault(src.section_id, set()).add(sec_id) + # Vertical (TB/BT) receiver: a straight column continuation. + # Only a vertical-flow feeder seeds the positive_fan cascade; + # a horizontal-flow feeder drops in on the trunk column with no + # order flip, so it needs no marking. + if src.section_id in tb_sections: + vertical_receivers.setdefault(src.section_id, set()).add(sec_id) else: + # Horizontal (LR/RL) receiver: the BOTTOM-exit drop turns into + # the trunk through a corner that reverses bundle ordering, + # whatever the feeder's flow axis (a horizontal-flow feeder is + # the true-serpentine fold, an LR row folding into an RL row). reversed_secs.add(sec_id) # BFS: propagate positive_fan through vertical chains. diff --git a/tests/data/guard_golden_baseline.json b/tests/data/guard_golden_baseline.json index bc28d6546..05806aa5e 100644 --- a/tests/data/guard_golden_baseline.json +++ b/tests/data/guard_golden_baseline.json @@ -34051,6 +34051,218 @@ "_guard_symmetric_diamond_branches_half_pitch|final" ] }, + "examples/topologies/serpentine_rl_bundle.mmd": { + "raised": null, + "trace": [ + "_guard_no_same_row_backward_feed|None", + "_guard_no_mixed_entry_directions|None", + "_guard_section_bboxes_positive|after Stage 1.1", + "_guard_no_negative_grid_columns|after Stage 1.1", + "_guard_explicit_grid_directions|after Stage 1.1", + "_guard_fold_relocated_flow_ports_face_connections|after Stage 1.1", + "_guard_tall_anchor_stack_well_formed|after Stage 1.1", + "_guard_independent_components_disjoint|after Stage 1.3", + "_guard_coordinates_finite|after Stage 2.1", + "_guard_stations_in_sections|after Stage 2.1", + "_guard_section_bboxes_positive|after Stage 2.1", + "_guard_ports_on_boundaries|after Stage 3.1", + "_guard_ports_on_boundaries|after top-align", + "_guard_anchors_frozen_during_placement|Stage 4.9 _redistribute_fanout_siblings", + "_guard_anchors_frozen_during_placement|Stage 4.10 _redistribute_full_bundle_columns", + "_guard_coordinates_finite|after Stage 5.2", + "_guard_section_bboxes_positive|after Stage 5.2", + "_guard_ports_on_boundaries|after Stage 5.2", + "_guard_station_x_column_drift|after Stage 5.2", + "_guard_coordinates_finite|after Stage 5.3", + "_guard_section_bboxes_positive|after Stage 5.3", + "_guard_stations_in_sections|after Stage 5.3", + "_guard_ports_on_boundaries|after Stage 5.3", + "_guard_station_x_column_drift|after Stage 5.3", + "_guard_coordinates_finite|after Stage 5.4", + "_guard_section_bboxes_positive|after Stage 5.4", + "_guard_stations_in_sections|after Stage 5.4", + "_guard_ports_on_boundaries|after Stage 5.4", + "_guard_station_x_column_drift|after Stage 5.4", + "_guard_coordinates_finite|after Stage 5.5", + "_guard_section_bboxes_positive|after Stage 5.5", + "_guard_stations_in_sections|after Stage 5.5", + "_guard_ports_on_boundaries|after Stage 5.5", + "_guard_station_x_column_drift|after Stage 5.5", + "_guard_anchors_frozen_during_placement|Stage 6.1 _fan_free_content_upward", + "_guard_anchors_frozen_during_placement|Stage 6.1 _fan_free_content_upward", + "_guard_coordinates_finite|after Stage 6.1", + "_guard_section_bboxes_positive|after Stage 6.1", + "_guard_stations_in_sections|after Stage 6.1", + "_guard_ports_on_boundaries|after Stage 6.1", + "_guard_station_x_column_drift|after Stage 6.1", + "_guard_anchors_frozen_during_placement|Stage 6.2 _fan_source_inputs_upward", + "_guard_anchors_frozen_during_placement|Stage 6.2 _fan_source_inputs_upward", + "_guard_coordinates_finite|after Stage 6.2", + "_guard_section_bboxes_positive|after Stage 6.2", + "_guard_stations_in_sections|after Stage 6.2", + "_guard_ports_on_boundaries|after Stage 6.2", + "_guard_station_x_column_drift|after Stage 6.2", + "_guard_coordinates_finite|after Stage 6.4", + "_guard_section_bboxes_positive|after Stage 6.4", + "_guard_stations_in_sections|after Stage 6.4", + "_guard_ports_on_boundaries|after Stage 6.4", + "_guard_no_station_overlap|after Stage 6.4", + "_guard_no_coincident_station_coords|after Stage 6.4", + "_guard_station_x_column_drift|after Stage 6.4", + "_guard_coordinates_finite|after Stage 6.5", + "_guard_section_bboxes_positive|after Stage 6.5", + "_guard_stations_in_sections|after Stage 6.5", + "_guard_ports_on_boundaries|after Stage 6.5", + "_guard_no_station_overlap|after Stage 6.5", + "_guard_no_coincident_station_coords|after Stage 6.5", + "_guard_station_x_column_drift|after Stage 6.5", + "_guard_coordinates_finite|after Stage 6.6", + "_guard_section_bboxes_positive|after Stage 6.6", + "_guard_stations_in_sections|after Stage 6.6", + "_guard_ports_on_boundaries|after Stage 6.6", + "_guard_no_station_overlap|after Stage 6.6", + "_guard_no_coincident_station_coords|after Stage 6.6", + "_guard_station_x_column_drift|after Stage 6.6", + "_guard_coordinates_finite|after Stage 6.10", + "_guard_section_bboxes_positive|after Stage 6.10", + "_guard_stations_in_sections|after Stage 6.10", + "_guard_ports_on_boundaries|after Stage 6.10", + "_guard_no_station_overlap|after Stage 6.10", + "_guard_no_coincident_station_coords|after Stage 6.10", + "_guard_station_x_column_drift|after Stage 6.10", + "_guard_anchors_frozen_during_placement|Stage 6.11 _balance_section_content_around_trunk", + "_guard_anchors_frozen_during_placement|Stage 6.11 _balance_section_content_around_trunk", + "_guard_coordinates_finite|after Stage 6.11", + "_guard_section_bboxes_positive|after Stage 6.11", + "_guard_stations_in_sections|after Stage 6.11", + "_guard_ports_on_boundaries|after Stage 6.11", + "_guard_no_station_overlap|after Stage 6.11", + "_guard_no_coincident_station_coords|after Stage 6.11", + "_guard_station_x_column_drift|after Stage 6.11", + "_guard_anchors_frozen_during_placement|Stage 6.12 _recenter_loop_side_stations", + "_guard_anchors_frozen_during_placement|Stage 6.12 _recenter_loop_side_stations", + "_guard_coordinates_finite|after Stage 6.12", + "_guard_section_bboxes_positive|after Stage 6.12", + "_guard_stations_in_sections|after Stage 6.12", + "_guard_ports_on_boundaries|after Stage 6.12", + "_guard_no_station_overlap|after Stage 6.12", + "_guard_no_coincident_station_coords|after Stage 6.12", + "_guard_station_x_column_drift|after Stage 6.12", + "_guard_coordinates_finite|after Stage 6.13", + "_guard_section_bboxes_positive|after Stage 6.13", + "_guard_stations_in_sections|after Stage 6.13", + "_guard_ports_on_boundaries|after Stage 6.13", + "_guard_no_station_overlap|after Stage 6.13", + "_guard_no_coincident_station_coords|after Stage 6.13", + "_guard_station_x_column_drift|after Stage 6.13", + "_guard_coordinates_finite|after Stage 6.14", + "_guard_section_bboxes_positive|after Stage 6.14", + "_guard_stations_in_sections|after Stage 6.14", + "_guard_ports_on_boundaries|after Stage 6.14", + "_guard_no_station_overlap|after Stage 6.14", + "_guard_no_coincident_station_coords|after Stage 6.14", + "_guard_no_line_crosses_non_consumer|after Stage 6.14", + "_guard_sparse_loop_station_clears_column_neighbour|after Stage 6.14", + "_guard_station_x_column_drift|after Stage 6.14", + "_guard_coordinates_finite|after Stage 6.15", + "_guard_section_bboxes_positive|after Stage 6.15", + "_guard_stations_in_sections|after Stage 6.15", + "_guard_ports_on_boundaries|after Stage 6.15", + "_guard_no_station_overlap|after Stage 6.15", + "_guard_no_coincident_station_coords|after Stage 6.15", + "_guard_no_line_crosses_non_consumer|after Stage 6.15", + "_guard_sparse_loop_station_clears_column_neighbour|after Stage 6.15", + "_guard_station_x_column_drift|after Stage 6.15", + "_guard_coordinates_finite|after Stage 6.16", + "_guard_section_bboxes_positive|after Stage 6.16", + "_guard_stations_in_sections|after Stage 6.16", + "_guard_ports_on_boundaries|after Stage 6.16", + "_guard_no_station_overlap|after Stage 6.16", + "_guard_no_coincident_station_coords|after Stage 6.16", + "_guard_no_line_crosses_non_consumer|after Stage 6.16", + "_guard_sparse_loop_station_clears_column_neighbour|after Stage 6.16", + "_guard_station_x_column_drift|after Stage 6.16", + "_guard_coordinates_finite|after final", + "_guard_section_bboxes_positive|after final", + "_guard_stations_in_sections|after final", + "_guard_ports_on_boundaries|after final", + "_guard_no_station_overlap|after final", + "_guard_no_coincident_station_coords|after final", + "_guard_no_line_crosses_non_consumer|after final", + "_guard_sparse_loop_station_clears_column_neighbour|after final", + "_guard_station_x_column_drift|after final", + "_guard_row_trunk_cy_consistent|after final", + "_guard_off_track_clear_of_anchor|after final", + "_guard_fanout_junction_shares_exit_port_y|after final", + "_guard_fanout_junction_resolves_upstream|after final", + "_guard_entry_port_fed_only_by_ports|after final", + "_guard_flow_exit_anchored_to_carrier|after final", + "_guard_fold_lr_exit_follows_target|after final", + "_guard_fold_lr_exit_sections_share_bbox_bottom|after final", + "_guard_perp_fed_entry_anchored_to_consumer|after final", + "_guard_corridor_fed_solo_rides_trunk|after final", + "_guard_perp_entry_clears_vertical_trunk_head|after final", + "_guard_post_convergence_trunk_continues|after final", + "_guard_perp_entry_feed_not_collinear|after final", + "_guard_merge_port_approach_side|after final", + "_guard_convergence_shallow_feeder_concentric|after final", + "_guard_merge_port_outgoing_side_preserved|after final", + "_guard_exit_inherits_entry_bundle_order|after final", + "_guard_bypass_port_no_slot_gaps|after final", + "_guard_partial_branch_offset_gaps|after final", + "_guard_row_gaps|after final", + "_guard_section_top_padding|after final", + "_guard_terminus_icons_within_bbox|after final", + "_guard_inter_section_routes_in_row_band|after final", + "_guard_topmost_row_top_entry_hugs_section|after final", + "_guard_off_track_output_clears_non_producer|after final", + "_guard_tb_exit_corner_column_order|after final", + "_guard_no_split_same_line_fanout_descents|after final", + "_guard_no_distinct_line_fanout_crossing|after final", + "_guard_fan_merge_no_partition_crossing|after final", + "_guard_trunk_continuation_drops_straight|after final", + "_guard_no_dogleg_crosses_exempt_trunk|after final", + "_guard_no_stacked_elbow_graze|after final", + "_guard_fanout_tail_join|after final", + "_guard_perp_entry_boundary_consistent|after final", + "_guard_perp_exit_over_leadin_no_overdip|after final", + "_guard_right_entry_drop_in_when_clear|after final", + "_guard_right_entry_corridor_descent_no_jog|after final", + "_guard_inter_section_route_no_backtrack|after final", + "_guard_inter_section_route_no_full_width_backtrack|after final", + "_guard_routes_enter_sections_at_ports|after final", + "_guard_rail_connector_ports_no_stub|after final", + "_guard_no_route_through_section|after final", + "_guard_inter_section_route_clears_own_section_interior|after final", + "_guard_feeder_exits_section_through_side|after final", + "_guard_entry_approach_from_port_side|after final", + "_guard_no_opposing_line_overlap|after final", + "_guard_serpentine_no_backtrack|after final", + "_guard_no_artefactual_counter_flow|after final", + "_guard_inter_row_run_clearance|after final", + "_guard_trunk_bands_crossing_optimal|after final", + "_guard_inter_section_descent_edge_clearance|after final", + "_guard_fan_bundles_coincide_or_separate|after final", + "_guard_no_label_overlap|final", + "_guard_no_diagonal_strikes_horizontal_label|final", + "_guard_no_line_strikes_label|final", + "_guard_bypass_v_flat_visible|final", + "_guard_no_wrapped_label_trunk_strike|final", + "_guard_file_icon_no_name_label|final", + "_guard_no_line_crosses_file_icon|final", + "_guard_centered_line_spread_balanced|final", + "_guard_rail_above_label_band|final", + "_guard_rail_stations_seat_on_rails|final", + "_guard_rail_one_station_per_column|final", + "_guard_single_trunk_off_track_step|final", + "_guard_off_track_consumer_on_trunk|final", + "_guard_off_track_input_column_stack|final", + "_guard_interchange_bar_clears_non_members|final", + "_guard_tb_top_entry_drop_hugs_top|final", + "_guard_symmetric_diamond_branches_straddle_trunk|final", + "_guard_symmetric_diamond_branches_half_pitch|final" + ] + }, "examples/topologies/shared_sink_parallel.mmd": { "raised": null, "trace": [ diff --git a/tests/test_seam_orientation.py b/tests/test_seam_orientation.py index 653dba86d..e3a205c6c 100644 --- a/tests/test_seam_orientation.py +++ b/tests/test_seam_orientation.py @@ -124,6 +124,14 @@ def _machinery_is_over_top_right_entry( ("variantbenchmarking", "ensembl_truth", "benchmarking", "L->R"), ("variantbenchmarking", "filtering", "benchmarking", "R->R"), ("variantbenchmarking", "normalization", "benchmarking", "R->R"), + # True-serpentine fold: a horizontal-flow row folds down through a BOTTOM + # exit into a horizontal-flow return row, whose descend->turn corner + # reverses the bundle order. The machinery marks the receiver (and its + # row successors) reversed, but the classifier preserves the seam. + ("serpentine_rl_bundle", "variant_calling", "normalization", "B->T"), + ("serpentine_rl_bundle", "normalization", "consensus", "L->R"), + ("serpentine_rl_bundle", "consensus", "realignment", "L->R"), + ("branch_fold_forward", "post", "report", "B->T"), } ) diff --git a/tests/test_serpentine_rl_bundle.py b/tests/test_serpentine_rl_bundle.py new file mode 100644 index 000000000..989f41c9b --- /dev/null +++ b/tests/test_serpentine_rl_bundle.py @@ -0,0 +1,58 @@ +"""Regression: a true ``direction: RL`` serpentine fold of a multi-line bundle. + +Row 0 flows L->R, the corner section drops straight down into the section below, +and the return row flows R->L via ``%%metro direction: RL``. The fold corner is a +TOP entry port turning into the first station of a horizontal-flow (RL) section. +For a 2+-line bundle that descend->turn corner must nest concentrically and keep +bundle order; the inter-section feeder must land each line at the same per-line X +the intra drop departs from (no boundary reversal). +""" + +from __future__ import annotations + +from pathlib import Path + +from nf_metro.layout.engine import compute_layout +from nf_metro.layout.routing.core import route_edges_centred +from nf_metro.layout.routing.invariants import ( + assert_render_curve_invariants, + check_bundle_order_preserved, + check_concentric_bundle_corners, +) +from nf_metro.layout.routing.offsets import compute_station_offsets +from nf_metro.parser import parse_metro_mermaid + +FIXTURE = ( + Path(__file__).parent.parent + / "examples" + / "topologies" + / "serpentine_rl_bundle.mmd" +) + + +def _laid_out(): + graph = parse_metro_mermaid(FIXTURE.read_text()) + compute_layout(graph) + offsets = compute_station_offsets(graph) + routes = route_edges_centred(graph, station_offsets=offsets) + return graph, routes, offsets + + +def test_rl_serpentine_bundle_renders_without_abort(): + """The fold corner must not raise from the render curve invariants.""" + graph, routes, offsets = _laid_out() + assert_render_curve_invariants(graph, routes, offsets) + + +def test_rl_serpentine_fold_corner_is_concentric(): + """The descend->turn corner's two lines nest with a shared arc centre.""" + graph, routes, offsets = _laid_out() + violations = check_concentric_bundle_corners(graph, routes, offsets) + assert not violations, "\n".join(v.message() for v in violations) + + +def test_rl_serpentine_fold_corner_keeps_bundle_order(): + """The bundle keeps its order through the descend->turn corner.""" + _graph, routes, _offsets = _laid_out() + violations = check_bundle_order_preserved(routes) + assert not violations, "\n".join(v.message() for v in violations) diff --git a/tests/test_tb_branch_ratchet.py b/tests/test_tb_branch_ratchet.py index 6690b46b5..23ac8955a 100644 --- a/tests/test_tb_branch_ratchet.py +++ b/tests/test_tb_branch_ratchet.py @@ -14,7 +14,7 @@ _LAYOUT_DIR = Path(__file__).resolve().parents[1] / "src/nf_metro/layout" # Lower this (never raise it) when a heuristic migrates onto AxisFrame. -_BASELINE_TB_BRANCHES = 23 +_BASELINE_TB_BRANCHES = 22 def _count_in_file(path: Path) -> int: