Skip to main content

jstd/triskel/
layout.rs

1//! Public layout API and per-component orchestration.
2//!
3//! [`LayoutBuilder`] is the entry point. It runs the layered-layout pipeline on
4//! each weakly-connected component of the input graph independently, then packs
5//! the components left-to-right. Internally the work is done on a `usize`-id
6//! [`LayoutGraph`] (so dummy vertices can be minted freely); results are mapped
7//! back to the caller's strongly-typed node/edge ids.
8
9use std::{
10    collections::BTreeMap,
11    error::Error,
12    fmt::{self, Debug},
13};
14
15use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
16
17use crate::{
18    graph::{
19        Graph, GraphMut,
20        analysis::{SeseRegion, SeseTree, compute_sese, compute_sese_candidates},
21        edge::Edge,
22        node::Node,
23        owning::OwningGraph,
24    },
25    registry::Identifier,
26    triskel::{
27        coordinate, cycle,
28        hammock::compute_hammocks,
29        order, rank,
30        render::{render_html, render_html_with_labels, render_svg, render_svg_with_labels},
31        router::{EdgeRouter, EdgeStyle, OrthogonalRouter, StraightRouter},
32        segment,
33    },
34};
35
36const DEFAULT_NODE_SIZE: f64 = 24.0;
37const DEFAULT_LAYER_GAP: f64 = 50.0;
38const DEFAULT_NODE_GAP: f64 = 40.0;
39const DEFAULT_MAX_SWEEPS: usize = 8;
40
41/// Protrusion of the innermost self-loop beyond the node's right edge.
42const SELF_LOOP_GAP: f64 = 16.0;
43/// Extra protrusion for each further self-loop stacked on the same node.
44const SELF_LOOP_STEP: f64 = 8.0;
45
46/// Horizontal room a node must reserve on its right to draw `count` self-loops.
47pub(crate) fn loop_reserve(count: u32) -> f64 {
48    if count == 0 {
49        0.0
50    } else {
51        SELF_LOOP_GAP + (count - 1) as f64 * SELF_LOOP_STEP
52    }
53}
54
55/// The orthogonal waypoints for the `index`-th of `total` self-loops on a node
56/// at `(x, y)` with size `width`×`height`. Loops nest: outer ones span more of
57/// the right face and protrude further, so stacked loops never draw over each
58/// other. The polyline leaves and re-enters the node's right face.
59fn self_loop_waypoints(
60    x: f64,
61    y: f64,
62    width: f64,
63    height: f64,
64    index: u32,
65    total: u32,
66) -> Vec<Point> {
67    let right = x + width / 2.0;
68    let span = height / 2.0 * (index + 1) as f64 / (total + 1) as f64;
69    let out = right + SELF_LOOP_GAP + index as f64 * SELF_LOOP_STEP;
70    vec![
71        Point {
72            x: right,
73            y: y - span,
74        },
75        Point {
76            x: out,
77            y: y - span,
78        },
79        Point {
80            x: out,
81            y: y + span,
82        },
83        Point {
84            x: right,
85            y: y + span,
86        },
87    ]
88}
89
90// ── Public value types ──────────────────────────────────────────────────────
91
92#[derive(Debug, Clone, Copy, Default, PartialEq)]
93pub struct Point {
94    pub x: f64,
95    pub y: f64,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq)]
99pub struct NodeGeometry {
100    pub width: f64,
101    pub height: f64,
102}
103
104impl Default for NodeGeometry {
105    fn default() -> Self {
106        Self {
107            width: DEFAULT_NODE_SIZE,
108            height: DEFAULT_NODE_SIZE,
109        }
110    }
111}
112
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
114pub enum LayoutMode {
115    /// Lay out each weak component as one Eiglsperger graph.
116    #[default]
117    Flat,
118    /// Recursively lay out canonical SESE regions as sized quotient nodes.
119    Sese,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq)]
123pub struct LayoutSettings {
124    /// Vertical gap between the facing edges of adjacent ranks.
125    pub layer_gap: f64,
126    /// Minimum horizontal gap between the facing edges of adjacent nodes.
127    pub node_gap: f64,
128    /// Edge drawing style.
129    pub edge_style: EdgeStyle,
130    /// Maximum up/down ordering sweeps during crossing reduction.
131    pub max_sweeps: usize,
132    /// Component orchestration strategy.
133    pub mode: LayoutMode,
134}
135
136impl Default for LayoutSettings {
137    fn default() -> Self {
138        Self {
139            layer_gap: DEFAULT_LAYER_GAP,
140            node_gap: DEFAULT_NODE_GAP,
141            edge_style: EdgeStyle::default(),
142            max_sweeps: DEFAULT_MAX_SWEEPS,
143            mode: LayoutMode::Flat,
144        }
145    }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum LayoutError {
150    EmptyGraph,
151}
152
153impl fmt::Display for LayoutError {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            LayoutError::EmptyGraph => f.write_str("cannot lay out an empty graph"),
157        }
158    }
159}
160
161impl Error for LayoutError {}
162
163#[derive(Debug, Clone, Copy, PartialEq)]
164pub struct LayoutNode<NodeId: Identifier> {
165    pub id: NodeId,
166    pub x: f64,
167    pub y: f64,
168    pub width: f64,
169    pub height: f64,
170}
171
172#[derive(Debug, Clone)]
173pub struct LayoutResult<NodeId: Identifier, EdgeId: Identifier> {
174    pub nodes: HashMap<NodeId, LayoutNode<NodeId>>,
175    pub edges: HashMap<EdgeId, Vec<Point>>,
176    /// Analysis-only SESE proxy bounds, populated in [`LayoutMode::Sese`].
177    /// They are useful for debug renderers and are empty for flat layout.
178    pub regions: Vec<LayoutRegion>,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq)]
182pub struct LayoutRegion {
183    pub id: usize,
184    pub parent: Option<usize>,
185    pub x: f64,
186    pub y: f64,
187    pub width: f64,
188    pub height: f64,
189}
190
191impl<NodeId: Identifier, EdgeId: Identifier> LayoutResult<NodeId, EdgeId> {
192    pub fn get_node(&self, node: NodeId) -> Option<&LayoutNode<NodeId>> {
193        self.nodes.get(&node)
194    }
195
196    pub fn get_waypoints(&self, edge: EdgeId) -> Option<&[Point]> {
197        self.edges.get(&edge).map(Vec::as_slice)
198    }
199
200    pub fn render_svg(&self) -> String {
201        render_svg::<NodeId, EdgeId>(self)
202    }
203
204    pub fn render_svg_with_labels<F>(&self, label_for: F) -> String
205    where
206        F: FnMut(NodeId) -> String,
207    {
208        render_svg_with_labels::<NodeId, EdgeId, F>(self, label_for)
209    }
210
211    pub fn render_html(&self, title: &str) -> String {
212        render_html::<NodeId, EdgeId>(self, title)
213    }
214
215    pub fn render_html_with_labels<F>(&self, title: &str, label_for: F) -> String
216    where
217        F: FnMut(NodeId) -> String,
218    {
219        render_html_with_labels::<NodeId, EdgeId, F>(self, title, label_for)
220    }
221}
222
223// ── Internal working graph ──────────────────────────────────────────────────
224
225#[derive(Clone, Copy)]
226pub(crate) struct NodeLayoutData {
227    pub width: f64,
228    pub height: f64,
229    pub rank: i64,
230    pub order: usize,
231    pub x: f64,
232    pub y: f64,
233    pub is_dummy: bool,
234    /// Number of self-loop edges drawn off this node's right face. Each reserves
235    /// horizontal room (see [`loop_reserve`]) so the loops clear the right
236    /// neighbour, mirroring how back-edges reserve a wrap column.
237    pub self_loops: u32,
238}
239
240impl Default for NodeLayoutData {
241    fn default() -> Self {
242        Self {
243            width: DEFAULT_NODE_SIZE,
244            height: DEFAULT_NODE_SIZE,
245            rank: 0,
246            order: 0,
247            x: 0.0,
248            y: 0.0,
249            is_dummy: false,
250            self_loops: 0,
251        }
252    }
253}
254
255#[derive(Clone, Copy)]
256pub(crate) struct EdgeLayoutData {
257    pub minlen: i64,
258    pub weight: i64,
259    /// True if this edge was reversed to break a cycle (drawn back-to-front).
260    pub reversed: bool,
261    /// The caller's original edge id (as `usize`) this internal edge belongs to.
262    pub orig: usize,
263    /// Fixed offsets for composition through a region proxy.  These remain
264    /// layout-local: public graph edges never carry routing state.
265    pub port_start: Option<f64>,
266    pub port_end: Option<f64>,
267}
268
269impl Default for EdgeLayoutData {
270    fn default() -> Self {
271        Self {
272            minlen: 1,
273            weight: 1,
274            reversed: false,
275            orig: 0,
276            port_start: None,
277            port_end: None,
278        }
279    }
280}
281
282pub(crate) type LayoutGraph = OwningGraph<usize, usize, NodeLayoutData, EdgeLayoutData>;
283
284#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
285enum RegionPortId<NodeId> {
286    Entry,
287    Exit { source: NodeId },
288}
289
290#[derive(Clone, Copy, Debug)]
291struct ProxyPort<NodeId> {
292    id: RegionPortId<NodeId>,
293    x_offset: f64,
294    is_entry: bool,
295}
296
297#[derive(Clone, Copy, Debug, Default)]
298struct PortHint {
299    source_bottom: Option<f64>,
300    target_top: Option<f64>,
301}
302
303/// Rank read as a layer index (ranks are normalised non-negative before use).
304pub(crate) fn layer_of(graph: &LayoutGraph, node: usize) -> usize {
305    graph.get_node(node).unwrap().rank.max(0) as usize
306}
307
308// ── Builder ─────────────────────────────────────────────────────────────────
309
310pub struct LayoutBuilder<'g, NodeId, EdgeId, NodeData, EdgeData>
311where
312    NodeId: Identifier,
313    EdgeId: Identifier,
314{
315    graph: &'g OwningGraph<NodeId, EdgeId, NodeData, EdgeData>,
316    root: Option<NodeId>,
317    settings: LayoutSettings,
318    geometry: Box<dyn FnMut(NodeId) -> NodeGeometry + 'g>,
319}
320
321impl<'g, NodeId, EdgeId, NodeData, EdgeData> LayoutBuilder<'g, NodeId, EdgeId, NodeData, EdgeData>
322where
323    NodeId: Identifier + Debug,
324    EdgeId: Identifier + Debug,
325{
326    pub fn new(graph: &'g OwningGraph<NodeId, EdgeId, NodeData, EdgeData>) -> Self {
327        Self {
328            graph,
329            root: None,
330            settings: LayoutSettings::default(),
331            geometry: Box::new(|_| NodeGeometry::default()),
332        }
333    }
334
335    /// Preferred root: nodes in its component are seeded from it. Optional —
336    /// other components fall back to their min-id node.
337    pub fn root(mut self, root: NodeId) -> Self {
338        self.root = Some(root);
339        self
340    }
341
342    pub fn node_gap(mut self, gap: f64) -> Self {
343        self.settings.node_gap = gap;
344        self
345    }
346
347    pub fn layer_gap(mut self, gap: f64) -> Self {
348        self.settings.layer_gap = gap;
349        self
350    }
351
352    pub fn edge_style(mut self, style: EdgeStyle) -> Self {
353        self.settings.edge_style = style;
354        self
355    }
356
357    pub fn max_sweeps(mut self, sweeps: usize) -> Self {
358        self.settings.max_sweeps = sweeps;
359        self
360    }
361
362    pub fn mode(mut self, mode: LayoutMode) -> Self {
363        self.settings.mode = mode;
364        self
365    }
366
367    pub fn settings(mut self, settings: LayoutSettings) -> Self {
368        self.settings = settings;
369        self
370    }
371
372    pub fn geometry<F>(mut self, geometry: F) -> Self
373    where
374        F: FnMut(NodeId) -> NodeGeometry + 'g,
375    {
376        self.geometry = Box::new(geometry);
377        self
378    }
379
380    pub fn build(mut self) -> Result<LayoutResult<NodeId, EdgeId>, LayoutError> {
381        let mut node_ids: Vec<NodeId> = self.graph.nodes().map(|n| n.id()).collect();
382        node_ids.sort_by_key(|id| Into::<usize>::into(*id));
383        if node_ids.is_empty() {
384            return Err(LayoutError::EmptyGraph);
385        }
386
387        let mut geometry = HashMap::default();
388        for id in &node_ids {
389            let g = (self.geometry)(*id);
390            geometry.insert(
391                *id,
392                NodeGeometry {
393                    width: g.width.max(1.0),
394                    height: g.height.max(1.0),
395                },
396            );
397        }
398
399        let components = weakly_connected_components(self.graph, &node_ids);
400
401        let mut nodes = HashMap::default();
402        let mut edges = HashMap::default();
403        let mut regions = Vec::new();
404        let mut x_offset = 0.0f64;
405
406        for component in components {
407            let local = match self.settings.mode {
408                LayoutMode::Flat => {
409                    layout_component(self.graph, &component, &geometry, self.root, &self.settings)
410                }
411                LayoutMode::Sese => layout_component_sese(
412                    self.graph,
413                    &component,
414                    &geometry,
415                    self.root,
416                    &self.settings,
417                ),
418            };
419
420            let (min_x, max_x, min_y) = local_bounds(&local);
421            let shift_x = x_offset - min_x;
422            let shift_y = -min_y;
423
424            for (id, mut node) in local.nodes {
425                node.x += shift_x;
426                node.y += shift_y;
427                nodes.insert(id, node);
428            }
429            let region_base = regions.len();
430            for region in local.regions {
431                regions.push(LayoutRegion {
432                    id: region_base + region.id,
433                    parent: region.parent.map(|parent| region_base + parent),
434                    x: region.x + shift_x,
435                    y: region.y + shift_y,
436                    ..region
437                });
438            }
439            for (id, points) in local.edges {
440                edges.insert(
441                    id,
442                    points
443                        .into_iter()
444                        .map(|p| Point {
445                            x: p.x + shift_x,
446                            y: p.y + shift_y,
447                        })
448                        .collect(),
449                );
450            }
451
452            x_offset += (max_x - min_x) + self.settings.node_gap;
453        }
454
455        Ok(LayoutResult {
456            nodes,
457            edges,
458            regions,
459        })
460    }
461}
462
463// ── Per-component pipeline ──────────────────────────────────────────────────
464
465struct ComponentLayout<NodeId: Identifier, EdgeId: Identifier> {
466    nodes: HashMap<NodeId, LayoutNode<NodeId>>,
467    edges: HashMap<EdgeId, Vec<Point>>,
468    regions: Vec<LayoutRegion>,
469}
470
471/// Enumerate raw edge-SESE candidates on the same explicitly normalized CFG
472/// used by [`compute_sese_normalized`]. Synthetic boundaries and nodes are
473/// discarded before layout sees the candidate.
474fn compute_sese_candidates_normalized<NodeId, EdgeId, NodeData, EdgeData>(
475    graph: &OwningGraph<NodeId, EdgeId, NodeData, EdgeData>,
476    component: &[NodeId],
477    root: NodeId,
478) -> Vec<(Vec<NodeId>, EdgeId, EdgeId)>
479where
480    NodeId: Identifier + Debug + Ord,
481    EdgeId: Identifier + Debug + Ord,
482{
483    let mut augmented = OwningGraph::<usize, usize, (), ()>::default();
484    let super_entry = augmented.make_node(());
485    let super_exit = augmented.make_node(());
486    let mut augmented_node = HashMap::default();
487    let mut original_node = HashMap::default();
488    for &node in component {
489        let augmented_id = augmented.make_node(());
490        augmented_node.insert(node, augmented_id);
491        original_node.insert(augmented_id, node);
492    }
493    augmented.make_edge(super_entry, augmented_node[&root], ());
494    let component_set: HashSet<_> = component.iter().copied().collect();
495    let mut has_real_out = HashMap::<NodeId, bool>::default();
496    for &node in component {
497        has_real_out.insert(node, false);
498    }
499    let mut original_edge = HashMap::default();
500    let mut edge_ids: Vec<_> = graph.edges().map(|edge| edge.id()).collect();
501    edge_ids.sort();
502    for edge_id in edge_ids {
503        let edge = graph.get_edge(edge_id).unwrap();
504        let from = edge.from_id();
505        let to = edge.to_id();
506        if !component_set.contains(&from) || !component_set.contains(&to) {
507            continue;
508        }
509        let augmented_id = augmented.make_edge(augmented_node[&from], augmented_node[&to], ());
510        original_edge.insert(augmented_id, edge_id);
511        if from != to {
512            has_real_out.insert(from, true);
513        }
514    }
515    let exits: Vec<_> = component
516        .iter()
517        .copied()
518        .filter(|node| !has_real_out[node])
519        .collect();
520    if exits.is_empty() {
521        augmented.make_edge(augmented_node[component.last().unwrap()], super_exit, ());
522    } else {
523        for node in exits {
524            augmented.make_edge(augmented_node[&node], super_exit, ());
525        }
526    }
527
528    compute_sese_candidates(&augmented, super_entry)
529        .into_iter()
530        .filter_map(|candidate| {
531            Some((
532                candidate
533                    .contained_nodes
534                    .into_iter()
535                    .map(|node| original_node.get(&node).copied())
536                    .collect::<Option<Vec<_>>>()?,
537                original_edge.get(&candidate.entry_edge).copied()?,
538                original_edge.get(&candidate.exit_edge).copied()?,
539            ))
540        })
541        .collect()
542}
543
544/// Compute SESE regions on a CFG normalized into one explicit hammock: an
545/// analysis-only source enters the selected root and all natural exits enter
546/// an analysis-only sink.  The synthetic boundaries establish the outer
547/// hammock without leaking fake nodes or edges into the composition tree.
548fn compute_sese_normalized<NodeId, EdgeId, NodeData, EdgeData>(
549    graph: &OwningGraph<NodeId, EdgeId, NodeData, EdgeData>,
550    component: &[NodeId],
551    root: NodeId,
552) -> SeseTree<NodeId, EdgeId>
553where
554    NodeId: Identifier + Debug + Ord,
555    EdgeId: Identifier + Debug + Ord,
556{
557    let mut augmented = OwningGraph::<usize, usize, (), ()>::default();
558    let super_entry = augmented.make_node(());
559    let super_exit = augmented.make_node(());
560    let mut augmented_node = HashMap::default();
561    let mut original_node = HashMap::default();
562    for &node in component {
563        let augmented_id = augmented.make_node(());
564        augmented_node.insert(node, augmented_id);
565        original_node.insert(augmented_id, node);
566    }
567    augmented.make_edge(super_entry, augmented_node[&root], ());
568
569    let component_set: HashSet<_> = component.iter().copied().collect();
570    let mut has_real_out = HashMap::<NodeId, bool>::default();
571    for &node in component {
572        has_real_out.insert(node, false);
573    }
574    let mut original_edge = HashMap::default();
575    let mut edge_ids: Vec<_> = graph.edges().map(|edge| edge.id()).collect();
576    edge_ids.sort();
577    for edge_id in edge_ids {
578        let edge = graph.get_edge(edge_id).unwrap();
579        let from = edge.from_id();
580        let to = edge.to_id();
581        if !component_set.contains(&from) || !component_set.contains(&to) {
582            continue;
583        }
584        let augmented_id = augmented.make_edge(augmented_node[&from], augmented_node[&to], ());
585        original_edge.insert(augmented_id, edge_id);
586        if from != to {
587            has_real_out.insert(from, true);
588        }
589    }
590    let exits: Vec<_> = component
591        .iter()
592        .copied()
593        .filter(|node| !has_real_out[node])
594        .collect();
595    if exits.is_empty() {
596        // Match compute_sese's deterministic normalization for a closed CFG.
597        augmented.make_edge(augmented_node[component.last().unwrap()], super_exit, ());
598    } else {
599        for node in exits {
600            augmented.make_edge(augmented_node[&node], super_exit, ());
601        }
602    }
603
604    let raw = compute_sese(&augmented, super_entry);
605    let mut old_to_new = vec![None; raw.regions.len()];
606    old_to_new[0] = Some(0);
607    let mut regions = vec![SeseRegion {
608        parent: None,
609        children: Vec::new(),
610        entry_edge: None,
611        exit_edge: None,
612        nodes: Vec::new(),
613        contained_nodes: component.to_vec(),
614    }];
615    for (old, region) in raw.regions.iter().enumerate().skip(1) {
616        let Some(entry) = region
617            .entry_edge
618            .and_then(|edge| original_edge.get(&edge))
619            .copied()
620        else {
621            continue;
622        };
623        let Some(exit) = region
624            .exit_edge
625            .and_then(|edge| original_edge.get(&edge))
626            .copied()
627        else {
628            continue;
629        };
630        let Some(contained_nodes) = region
631            .contained_nodes
632            .iter()
633            .map(|node| original_node.get(node).copied())
634            .collect::<Option<Vec<_>>>()
635        else {
636            continue;
637        };
638        if contained_nodes.is_empty() {
639            continue;
640        }
641        let new = regions.len();
642        old_to_new[old] = Some(new);
643        regions.push(SeseRegion {
644            parent: None,
645            children: Vec::new(),
646            entry_edge: Some(entry),
647            exit_edge: Some(exit),
648            nodes: Vec::new(),
649            contained_nodes,
650        });
651    }
652    for old in 1..raw.regions.len() {
653        let Some(new) = old_to_new[old] else {
654            continue;
655        };
656        let mut parent = raw.regions[old].parent;
657        while let Some(old_parent) = parent {
658            if let Some(new_parent) = old_to_new[old_parent] {
659                regions[new].parent = Some(new_parent);
660                regions[new_parent].children.push(new);
661                break;
662            }
663            parent = raw.regions[old_parent].parent;
664        }
665    }
666    for &node in component {
667        let owner = regions
668            .iter()
669            .enumerate()
670            .skip(1)
671            .filter(|(_, region)| region.contained_nodes.contains(&node))
672            .min_by_key(|(_, region)| region.contained_nodes.len())
673            .map(|(id, _)| id)
674            .unwrap_or(0);
675        regions[owner].nodes.push(node);
676    }
677    SeseTree { regions }
678}
679
680/// Candidate node hammocks. A hammock may have several boundary edges,
681/// provided they all leave for the same external exit node.
682#[cfg(test)]
683fn compute_hammock_candidates<NodeId, EdgeId, NodeData, EdgeData>(
684    graph: &OwningGraph<NodeId, EdgeId, NodeData, EdgeData>,
685    component: &[NodeId],
686) -> Vec<(Vec<NodeId>, EdgeId, EdgeId)>
687where
688    NodeId: Identifier + Debug + Ord,
689    EdgeId: Identifier + Debug + Ord,
690{
691    let component_set: HashSet<_> = component.iter().copied().collect();
692    let mut edges: Vec<_> = graph
693        .edges()
694        .filter_map(|edge| {
695            (component_set.contains(&edge.from_id()) && component_set.contains(&edge.to_id()))
696                .then_some((edge.id(), edge.from_id(), edge.to_id()))
697        })
698        .collect();
699    edges.sort_by_key(|(id, _, _)| *id);
700    let mut candidates = Vec::<(Vec<NodeId>, EdgeId, EdgeId)>::new();
701    for &entry in component {
702        for &exit in component {
703            if entry == exit {
704                continue;
705            }
706            let mut forward = HashSet::default();
707            let mut stack = vec![entry];
708            while let Some(node) = stack.pop() {
709                if node == exit || !forward.insert(node) {
710                    continue;
711                }
712                stack.extend(
713                    edges
714                        .iter()
715                        .filter_map(|(_, from, to)| (*from == node).then_some(*to)),
716                );
717            }
718            let mut reverse = HashSet::default();
719            let mut stack = vec![exit];
720            while let Some(node) = stack.pop() {
721                if !reverse.insert(node) {
722                    continue;
723                }
724                stack.extend(
725                    edges
726                        .iter()
727                        .filter_map(|(_, from, to)| (*to == node).then_some(*from)),
728                );
729            }
730            let contained: HashSet<_> = forward.intersection(&reverse).copied().collect();
731            if contained.len() <= 1 || !contained.contains(&entry) {
732                continue;
733            }
734            let incoming: Vec<_> = edges
735                .iter()
736                .filter(|(_, from, to)| !contained.contains(from) && contained.contains(to))
737                .collect();
738            let outgoing: Vec<_> = edges
739                .iter()
740                .filter(|(_, from, to)| contained.contains(from) && !contained.contains(to))
741                .collect();
742            if incoming.is_empty()
743                || outgoing.is_empty()
744                || incoming.iter().any(|(_, _, to)| *to != entry)
745                || outgoing.iter().any(|(_, _, to)| *to != exit)
746            {
747                continue;
748            }
749            let mut nodes: Vec<_> = contained.into_iter().collect();
750            nodes.sort();
751            candidates.push((nodes, incoming[0].0, outgoing[0].0));
752        }
753    }
754    candidates.sort_by_key(|(nodes, entry, exit)| (std::cmp::Reverse(nodes.len()), *entry, *exit));
755    candidates
756}
757
758/// Finds maximal node-hammocks when edge-based SESE has no useful region.
759#[cfg(test)]
760fn compute_hammock_fallback<NodeId, EdgeId, NodeData, EdgeData>(
761    graph: &OwningGraph<NodeId, EdgeId, NodeData, EdgeData>,
762    component: &[NodeId],
763) -> SeseTree<NodeId, EdgeId>
764where
765    NodeId: Identifier + Debug + Ord,
766    EdgeId: Identifier + Debug + Ord,
767{
768    let candidates = compute_hammock_candidates(graph, component);
769    let mut regions = vec![SeseRegion {
770        parent: None,
771        children: Vec::new(),
772        entry_edge: None,
773        exit_edge: None,
774        nodes: Vec::new(),
775        contained_nodes: component.to_vec(),
776    }];
777    for (nodes, entry, exit) in candidates {
778        if regions.iter().skip(1).any(|region| {
779            region
780                .contained_nodes
781                .iter()
782                .any(|node| nodes.contains(node))
783        }) {
784            continue;
785        }
786        let id = regions.len();
787        regions.push(SeseRegion {
788            parent: Some(0),
789            children: Vec::new(),
790            entry_edge: Some(entry),
791            exit_edge: Some(exit),
792            nodes: Vec::new(),
793            contained_nodes: nodes,
794        });
795        regions[0].children.push(id);
796    }
797    for &node in component {
798        let owner = regions
799            .iter()
800            .enumerate()
801            .skip(1)
802            .find_map(|(id, region)| region.contained_nodes.contains(&node).then_some(id))
803            .unwrap_or(0);
804        regions[owner].nodes.push(node);
805    }
806    SeseTree { regions }
807}
808
809/// Layout's mixed region tree retains maximal node hammocks and useful raw
810/// edge-SESE regions nested inside them. This deliberately does not alter the
811/// public canonical `compute_sese` selection rule.
812fn compute_layout_sese_tree<NodeId, EdgeId, NodeData, EdgeData>(
813    graph: &OwningGraph<NodeId, EdgeId, NodeData, EdgeData>,
814    component: &[NodeId],
815    root: NodeId,
816) -> SeseTree<NodeId, EdgeId>
817where
818    NodeId: Identifier + Debug + Ord,
819    EdgeId: Identifier + Debug + Ord,
820{
821    let hammocks: Vec<_> = compute_hammocks(graph, component, root)
822        .into_iter()
823        .map(|hammock| (hammock.nodes, hammock.entry_edge, hammock.exit_edge))
824        .collect();
825    // Without a hammock, the established canonical hierarchy is the best
826    // structural representation and preserves previous SESE behaviour.
827    if hammocks.is_empty() {
828        return compute_sese_normalized(graph, component, root);
829    }
830
831    let mut selected = Vec::new();
832    for candidate in compute_sese_candidates_normalized(graph, component, root) {
833        if candidate.0.len() <= 1
834            || !hammocks.iter().any(|(nodes, _, _)| {
835                candidate.0.len() < nodes.len()
836                    && candidate.0.iter().all(|node| nodes.contains(node))
837            })
838        {
839            continue;
840        }
841        let laminar = selected
842            .iter()
843            .all(|(other, _, _): &(Vec<NodeId>, EdgeId, EdgeId)| {
844                let intersects = candidate.0.iter().any(|node| other.contains(node));
845                !intersects
846                    || candidate.0.iter().all(|node| other.contains(node))
847                    || other.iter().all(|node| candidate.0.contains(node))
848            });
849        if laminar
850            && !selected
851                .iter()
852                .any(|(nodes, _, _): &(Vec<NodeId>, EdgeId, EdgeId)| *nodes == candidate.0)
853        {
854            selected.push(candidate);
855        }
856    }
857    selected.sort_by_key(|(nodes, entry, exit)| (std::cmp::Reverse(nodes.len()), *entry, *exit));
858
859    let mut regions = vec![SeseRegion {
860        parent: None,
861        children: Vec::new(),
862        entry_edge: None,
863        exit_edge: None,
864        nodes: Vec::new(),
865        contained_nodes: component.to_vec(),
866    }];
867    for (nodes, entry_edge, exit_edge) in hammocks {
868        let id = regions.len();
869        regions.push(SeseRegion {
870            parent: Some(0),
871            children: Vec::new(),
872            entry_edge: Some(entry_edge),
873            exit_edge: Some(exit_edge),
874            nodes: Vec::new(),
875            contained_nodes: nodes,
876        });
877        regions[0].children.push(id);
878    }
879    for (nodes, entry_edge, exit_edge) in selected {
880        let parent = regions
881            .iter()
882            .enumerate()
883            .filter(|(_, region)| {
884                nodes
885                    .iter()
886                    .all(|node| region.contained_nodes.contains(node))
887            })
888            .min_by_key(|(_, region)| region.contained_nodes.len())
889            .map(|(id, _)| id)
890            .unwrap_or(0);
891        let id = regions.len();
892        regions.push(SeseRegion {
893            parent: Some(parent),
894            children: Vec::new(),
895            entry_edge: Some(entry_edge),
896            exit_edge: Some(exit_edge),
897            nodes: Vec::new(),
898            contained_nodes: nodes,
899        });
900        regions[parent].children.push(id);
901    }
902    for &node in component {
903        let owner = regions
904            .iter()
905            .enumerate()
906            .filter(|(_, region)| region.contained_nodes.contains(&node))
907            .min_by_key(|(_, region)| region.contained_nodes.len())
908            .map(|(id, _)| id)
909            .unwrap_or(0);
910        regions[owner].nodes.push(node);
911    }
912    SeseTree { regions }
913}
914
915fn layout_component_sese<NodeId, EdgeId, NodeData, EdgeData>(
916    graph: &OwningGraph<NodeId, EdgeId, NodeData, EdgeData>,
917    component: &[NodeId],
918    geometry: &HashMap<NodeId, NodeGeometry>,
919    preferred_root: Option<NodeId>,
920    settings: &LayoutSettings,
921) -> ComponentLayout<NodeId, EdgeId>
922where
923    NodeId: Identifier + Debug + Ord,
924    EdgeId: Identifier + Debug + Ord,
925{
926    let component_set: HashSet<_> = component.iter().copied().collect();
927    let root = preferred_root
928        .filter(|root| component_set.contains(root))
929        .unwrap_or_else(|| *component.iter().min().unwrap());
930    let tree = compute_layout_sese_tree(graph, component, root);
931    let useful_region = tree
932        .regions
933        .iter()
934        .skip(1)
935        .any(|region| region.contained_nodes.len() > 1);
936    if !useful_region || tree.root().contained_nodes.len() != component.len() {
937        return layout_component(graph, component, geometry, preferred_root, settings);
938    }
939
940    let composition = compose_sese_region(graph, geometry, settings, &tree, 0, root);
941    // A proxy port is valid only when every parent/child endpoint equality and
942    // geometry invariant holds. Do not resurrect an expanded-graph router to
943    // repair a bad composition: retain flat layout as the defensive fallback.
944    if !composition.valid
945        || !routes_clear_of_nodes(&composition.nodes, &composition.edges)
946        || !routes_are_deconflicted(&composition.edges)
947        || (settings.edge_style == EdgeStyle::Orthogonal
948            && !routes_are_orthogonal(&composition.edges))
949    {
950        return layout_component(graph, component, geometry, preferred_root, settings);
951    }
952    let RegionComposition {
953        mut nodes,
954        edges,
955        mut region_boxes,
956        ..
957    } = composition;
958    // Keep the component centred around its own bounds; the public packer will
959    // apply the final top/left translation.
960    let (min_x, max_x, min_y, max_y) = node_bounds(&nodes);
961    let dx = -(min_x + max_x) / 2.0;
962    let dy = -(min_y + max_y) / 2.0;
963    for node in nodes.values_mut() {
964        node.x += dx;
965        node.y += dy;
966    }
967    let regions = region_boxes
968        .drain(..)
969        .map(|region| LayoutRegion {
970            id: region.id,
971            parent: region.parent,
972            x: (region.min_x + region.max_x) / 2.0 + dx,
973            y: (region.min_y + region.max_y) / 2.0 + dy,
974            width: region.max_x - region.min_x,
975            height: region.max_y - region.min_y,
976        })
977        .collect();
978    let edges = edges
979        .into_iter()
980        .map(|(id, points)| {
981            (
982                id,
983                points
984                    .into_iter()
985                    .map(|point| Point {
986                        x: point.x + dx,
987                        y: point.y + dy,
988                    })
989                    .collect(),
990            )
991        })
992        .collect();
993    ComponentLayout {
994        nodes,
995        edges,
996        regions,
997    }
998}
999
1000#[derive(Clone, Copy)]
1001struct RegionBox {
1002    id: usize,
1003    parent: Option<usize>,
1004    min_x: f64,
1005    max_x: f64,
1006    min_y: f64,
1007    max_y: f64,
1008}
1009
1010struct RegionInterface<NodeId: Identifier> {
1011    width: f64,
1012    height: f64,
1013    ports: BTreeMap<RegionPortId<NodeId>, ProxyPort<NodeId>>,
1014    /// Internal routes start/end at the corresponding named boundary port.
1015    entry_route: Option<Vec<Point>>,
1016    exit_routes: HashMap<NodeId, Vec<Point>>,
1017}
1018
1019struct RegionComposition<NodeId: Identifier, EdgeId: Identifier> {
1020    nodes: HashMap<NodeId, LayoutNode<NodeId>>,
1021    /// Original-edge routes produced by flat layouts at this region or one of
1022    /// its descendants. A region proxy is only an intermediate layout node.
1023    edges: HashMap<EdgeId, Vec<Point>>,
1024    interface: RegionInterface<NodeId>,
1025    valid: bool,
1026    region_boxes: Vec<RegionBox>,
1027}
1028
1029fn compose_sese_region<NodeId, EdgeId, NodeData, EdgeData>(
1030    graph: &OwningGraph<NodeId, EdgeId, NodeData, EdgeData>,
1031    geometry: &HashMap<NodeId, NodeGeometry>,
1032    settings: &LayoutSettings,
1033    tree: &SeseTree<NodeId, EdgeId>,
1034    region_id: usize,
1035    component_root: NodeId,
1036) -> RegionComposition<NodeId, EdgeId>
1037where
1038    NodeId: Identifier + Debug + Ord,
1039    EdgeId: Identifier + Debug + Ord,
1040{
1041    let region = &tree.regions[region_id];
1042    let children: Vec<_> = region
1043        .children
1044        .iter()
1045        .map(|child| {
1046            (
1047                *child,
1048                compose_sese_region(graph, geometry, settings, tree, *child, component_root),
1049            )
1050        })
1051        .collect();
1052
1053    let mut valid = children.iter().all(|(_, child)| child.valid);
1054    let mut quotient = OwningGraph::<usize, usize, (), ()>::default();
1055    let mut entity_for_node = HashMap::default();
1056    let mut direct_entity = HashMap::default();
1057    let mut proxy_entity = HashMap::default();
1058    let mut child_for_proxy = HashMap::default();
1059    let mut quotient_geometry = HashMap::default();
1060    for &node in &region.nodes {
1061        let entity = quotient.make_node(());
1062        direct_entity.insert(entity, node);
1063        entity_for_node.insert(node, entity);
1064        quotient_geometry.insert(entity, geometry[&node]);
1065    }
1066    for (child_id, child) in &children {
1067        let entity = quotient.make_node(());
1068        proxy_entity.insert(*child_id, entity);
1069        child_for_proxy.insert(entity, *child_id);
1070        quotient_geometry.insert(
1071            entity,
1072            // The parent proxy is exactly the child's interface rectangle.
1073            // Its named ports are offsets from this same centre.
1074            NodeGeometry {
1075                width: child.interface.width,
1076                height: child.interface.height,
1077            },
1078        );
1079        for &node in &tree.regions[*child_id].contained_nodes {
1080            entity_for_node.insert(node, entity);
1081        }
1082    }
1083
1084    let contained: HashSet<_> = region.contained_nodes.iter().copied().collect();
1085    let mut original_for_quotient = HashMap::default();
1086    // A non-root region has an entry terminal and one exit terminal for every
1087    // boundary source. Canonical edge SESE has one; a node hammock can have
1088    // several edges leaving distinct nodes for the same external exit node.
1089    let exit_sources: Vec<_> = graph
1090        .edges()
1091        .filter_map(|edge| {
1092            (contained.contains(&edge.from_id())
1093                && !contained.contains(&edge.to_id())
1094                && edge.from_id() != edge.to_id())
1095            .then_some(edge.from_id())
1096        })
1097        .collect::<std::collections::BTreeSet<_>>()
1098        .into_iter()
1099        .collect();
1100    let interface = region.entry_edge.map(|entry| {
1101        let entry_terminal = quotient.make_node(());
1102        quotient_geometry.insert(
1103            entry_terminal,
1104            NodeGeometry {
1105                width: 1.0,
1106                height: 1.0,
1107            },
1108        );
1109        let entry_target = graph.get_edge(entry).unwrap().to_id();
1110        let entry_link = quotient.make_edge(entry_terminal, entity_for_node[&entry_target], ());
1111        let mut exits = Vec::new();
1112        for source in &exit_sources {
1113            let terminal = quotient.make_node(());
1114            quotient_geometry.insert(
1115                terminal,
1116                NodeGeometry {
1117                    width: 1.0,
1118                    height: 1.0,
1119                },
1120            );
1121            let link = quotient.make_edge(entity_for_node[source], terminal, ());
1122            exits.push((*source, terminal, link));
1123        }
1124        (entry_terminal, entry_link, exits)
1125    });
1126    let mut edge_ids: Vec<_> = graph.edges().map(|edge| edge.id()).collect();
1127    edge_ids.sort();
1128    for edge_id in edge_ids {
1129        let edge = graph.get_edge(edge_id).unwrap();
1130        let from = edge.from_id();
1131        let to = edge.to_id();
1132        if !contained.contains(&from) || !contained.contains(&to) || from == to {
1133            continue;
1134        }
1135        let from_entity = entity_for_node[&from];
1136        let to_entity = entity_for_node[&to];
1137        if from_entity != to_entity {
1138            let quotient_edge = quotient.make_edge(from_entity, to_entity, ());
1139            original_for_quotient.insert(quotient_edge, edge_id);
1140        }
1141    }
1142
1143    let mut entities: Vec<_> = quotient.nodes().map(|node| node.id()).collect();
1144    entities.sort_unstable();
1145    let entry_node = region
1146        .entry_edge
1147        .and_then(|edge| graph.get_edge(edge).map(|edge| edge.to_id()))
1148        .unwrap_or(component_root);
1149    let quotient_root = entity_for_node
1150        .get(&entry_node)
1151        .copied()
1152        .unwrap_or_else(|| entities[0]);
1153    // A quotient edge that touches a child proxy is constrained to the exact
1154    // named child port. This is established before coordinate assignment and
1155    // routing, rather than repaired while expanding the child.
1156    let child_ports: HashMap<usize, BTreeMap<RegionPortId<NodeId>, ProxyPort<NodeId>>> = children
1157        .iter()
1158        .map(|(child_id, child)| (proxy_entity[child_id], child.interface.ports.clone()))
1159        .collect();
1160    let mut port_hints = HashMap::default();
1161    for (&quotient_edge, &original) in &original_for_quotient {
1162        let edge = graph.get_edge(original).unwrap();
1163        let mut hint = PortHint::default();
1164        if let Some(ports) = child_ports.get(&entity_for_node[&edge.from_id()]) {
1165            hint.source_bottom = ports
1166                .get(&RegionPortId::Exit {
1167                    source: edge.from_id(),
1168                })
1169                .filter(|port| {
1170                    !port.is_entry
1171                        && port.id
1172                            == RegionPortId::Exit {
1173                                source: edge.from_id(),
1174                            }
1175                })
1176                .map(|port| port.x_offset);
1177        }
1178        if let Some(ports) = child_ports.get(&entity_for_node[&edge.to_id()]) {
1179            hint.target_top = ports
1180                .get(&RegionPortId::Entry)
1181                .filter(|port| port.is_entry && port.id == RegionPortId::Entry)
1182                .map(|port| port.x_offset);
1183        }
1184        if hint.source_bottom.is_some() || hint.target_top.is_some() {
1185            port_hints.insert(quotient_edge, hint);
1186        }
1187    }
1188    if let Some((_, entry_link, exits)) = &interface {
1189        let target = graph.get_edge(region.entry_edge.unwrap()).unwrap().to_id();
1190        if let Some(port) = child_ports
1191            .get(&entity_for_node[&target])
1192            .and_then(|ports| ports.get(&RegionPortId::Entry))
1193            .filter(|port| port.is_entry && port.id == RegionPortId::Entry)
1194        {
1195            port_hints.insert(
1196                *entry_link,
1197                PortHint {
1198                    target_top: Some(port.x_offset),
1199                    ..Default::default()
1200                },
1201            );
1202        }
1203        for (source, _, link) in exits {
1204            if let Some(port) = child_ports
1205                .get(&entity_for_node[source])
1206                .and_then(|ports| ports.get(&RegionPortId::Exit { source: *source }))
1207                .filter(|port| !port.is_entry && port.id == RegionPortId::Exit { source: *source })
1208            {
1209                port_hints.insert(
1210                    *link,
1211                    PortHint {
1212                        source_bottom: Some(port.x_offset),
1213                        ..Default::default()
1214                    },
1215                );
1216            }
1217        }
1218    }
1219    let mut flat_settings = *settings;
1220    flat_settings.mode = LayoutMode::Flat;
1221    let quotient_layout = layout_component_with_port_hints(
1222        &quotient,
1223        &entities,
1224        &quotient_geometry,
1225        Some(quotient_root),
1226        &flat_settings,
1227        &port_hints,
1228    );
1229
1230    let (mut entry_interface, mut exit_interfaces) =
1231        if let Some((entry, entry_link, exits)) = interface {
1232            let entry_node = quotient_layout.nodes[&entry];
1233            let mut entry_points = vec![
1234                Point {
1235                    x: entry_node.x,
1236                    y: entry_node.y - entry_node.height / 2.0,
1237                },
1238                Point {
1239                    x: entry_node.x,
1240                    y: entry_node.y + entry_node.height / 2.0,
1241                },
1242            ];
1243            entry_points.extend(quotient_layout.edges[&entry_link].iter().copied().skip(1));
1244            let mut paths = HashMap::default();
1245            for (source, terminal, link) in exits {
1246                let terminal_node = quotient_layout.nodes[&terminal];
1247                let mut points = quotient_layout.edges[&link].clone();
1248                points.push(Point {
1249                    x: terminal_node.x,
1250                    y: terminal_node.y + terminal_node.height / 2.0,
1251                });
1252                paths.insert(source, points);
1253            }
1254            (Some(entry_points), paths)
1255        } else {
1256            (None, HashMap::default())
1257        };
1258
1259    let mut nodes = HashMap::default();
1260    for (entity, original) in direct_entity {
1261        let positioned = quotient_layout.nodes[&entity];
1262        let geom = geometry[&original];
1263        nodes.insert(
1264            original,
1265            LayoutNode {
1266                id: original,
1267                x: positioned.x,
1268                y: positioned.y,
1269                width: geom.width,
1270                height: geom.height,
1271            },
1272        );
1273    }
1274    let mut edges = HashMap::default();
1275    let mut region_boxes = Vec::new();
1276    let mut child_interfaces = HashMap::default();
1277    for (child_id, mut child) in children {
1278        let proxy = quotient_layout.nodes[&proxy_entity[&child_id]];
1279        for (_, mut node) in child.nodes.drain() {
1280            node.x += proxy.x;
1281            node.y += proxy.y;
1282            nodes.insert(node.id, node);
1283        }
1284        for (edge_id, points) in child.edges.drain() {
1285            edges.insert(edge_id, translate_points(points, proxy.x, proxy.y));
1286        }
1287        for mut region_box in child.region_boxes.drain(..) {
1288            region_box.min_x += proxy.x;
1289            region_box.max_x += proxy.x;
1290            region_box.min_y += proxy.y;
1291            region_box.max_y += proxy.y;
1292            region_boxes.push(region_box);
1293        }
1294        let child_interface = RegionInterface {
1295            width: child.interface.width,
1296            height: child.interface.height,
1297            ports: child.interface.ports,
1298            entry_route: child
1299                .interface
1300                .entry_route
1301                .take()
1302                .map(|points| translate_points(points, proxy.x, proxy.y)),
1303            exit_routes: child
1304                .interface
1305                .exit_routes
1306                .drain()
1307                .map(|(source, points)| (source, translate_points(points, proxy.x, proxy.y)))
1308                .collect(),
1309        };
1310        child_interfaces.insert(proxy_entity[&child_id], child_interface);
1311    }
1312
1313    // Synthetic terminal links can themselves end at a nested proxy. Expand
1314    // them exactly like an original quotient edge, so an interface always
1315    // reaches the real boundary node rather than stopping at an inner proxy.
1316    if let Some(entry) = region.entry_edge {
1317        let target = graph.get_edge(entry).unwrap().to_id();
1318        let entity = entity_for_node[&target];
1319        if child_for_proxy.contains_key(&entity) {
1320            let nested_entry = child_interfaces[&entity].entry_route.as_ref().unwrap();
1321            let points = entry_interface.as_mut().unwrap();
1322            valid &= join_equal_points(points, nested_entry);
1323        }
1324    }
1325    for (&source, points) in &mut exit_interfaces {
1326        let entity = entity_for_node[&source];
1327        if child_for_proxy.contains_key(&entity) {
1328            let nested_exits = &child_interfaces[&entity].exit_routes;
1329            let mut expanded = nested_exits[&source].clone();
1330            valid &= join_equal_points(&mut expanded, points);
1331            *points = expanded;
1332        }
1333    }
1334
1335    // Every quotient edge retains the route from the ordinary flat pipeline.
1336    // When an endpoint is a proxy, splice that route to the original endpoint
1337    // inside the expanded region.  This is composition glue, not a second
1338    // routing pass over the expanded graph.
1339    for (quotient_edge, mut points) in quotient_layout.edges {
1340        let Some(&original) = original_for_quotient.get(&quotient_edge) else {
1341            continue; // synthetic entry/exit terminal edge
1342        };
1343        let edge = graph.get_edge(original).unwrap();
1344        let from = edge.from_id();
1345        let to = edge.to_id();
1346        if child_for_proxy.contains_key(&entity_for_node[&from]) {
1347            let exits = &child_interfaces[&entity_for_node[&from]].exit_routes;
1348            let mut expanded = exits[&from].clone();
1349            valid &= join_equal_points(&mut expanded, &points);
1350            points = expanded;
1351        }
1352        if child_for_proxy.contains_key(&entity_for_node[&to]) {
1353            let entry = child_interfaces[&entity_for_node[&to]]
1354                .entry_route
1355                .as_ref()
1356                .unwrap();
1357            valid &= join_equal_points(&mut points, entry);
1358        }
1359        simplify_points(&mut points);
1360        edges.insert(original, points);
1361    }
1362
1363    // Self-loops are deliberately outside every quotient graph, just as they
1364    // are in flat layout.  Emit them once at the root after all real positions
1365    // are known.
1366    if region_id == 0 {
1367        let mut loop_index = HashMap::<NodeId, u32>::default();
1368        let mut loop_count = HashMap::<NodeId, u32>::default();
1369        for edge in graph.edges() {
1370            if edge.from_id() == edge.to_id() && contained.contains(&edge.from_id()) {
1371                *loop_count.entry(edge.from_id()).or_default() += 1;
1372            }
1373        }
1374        for edge in graph.edges() {
1375            if edge.from_id() != edge.to_id() || !contained.contains(&edge.from_id()) {
1376                continue;
1377            }
1378            let node = nodes[&edge.from_id()];
1379            let index = loop_index.entry(node.id).or_default();
1380            edges.insert(
1381                edge.id(),
1382                self_loop_waypoints(
1383                    node.x,
1384                    node.y,
1385                    node.width,
1386                    node.height,
1387                    *index,
1388                    loop_count[&node.id],
1389                ),
1390            );
1391            *index += 1;
1392        }
1393    }
1394
1395    // Proxy geometry is real content plus precisely one intentional margin.
1396    // Terminal ranks are routing-only: expose their paths through zero-area
1397    // perimeter ports rather than allowing them to enlarge this rectangle.
1398    let (content_min_x, content_max_x, content_min_y, content_max_y) = node_bounds(&nodes);
1399    let width = content_max_x - content_min_x + settings.node_gap;
1400    let height = content_max_y - content_min_y + settings.layer_gap;
1401    let center_x = (content_min_x + content_max_x) / 2.0;
1402    let center_y = (content_min_y + content_max_y) / 2.0;
1403    let top = center_y - height / 2.0;
1404    let bottom = center_y + height / 2.0;
1405    if let Some(points) = &mut entry_interface {
1406        // Replace the temporary terminal's top/bottom faces with the real
1407        // proxy attachment; retaining them would make an out-and-back detour.
1408        let terminal_x = points[0].x;
1409        let port_x = terminal_x.clamp(center_x - width / 2.0, center_x + width / 2.0);
1410        points.drain(..2);
1411        points.insert(0, Point { x: port_x, y: top });
1412    }
1413    let mut exit_sources: Vec<_> = exit_interfaces.keys().copied().collect();
1414    exit_sources.sort();
1415    let exit_count = exit_sources.len();
1416    let mut used_exit_x = Vec::new();
1417    for source in exit_sources {
1418        let points = exit_interfaces.get_mut(&source).unwrap();
1419        // The final two points are the temporary terminal's top/bottom faces.
1420        let terminal_x = points[points.len() - 2].x;
1421        let mut port_x = terminal_x.clamp(center_x - width / 2.0, center_x + width / 2.0);
1422        if used_exit_x.iter().any(|x: &f64| (*x - port_x).abs() < 1e-6) {
1423            let step = width / (exit_count + 1) as f64;
1424            port_x = (center_x - width / 2.0 + step * (used_exit_x.len() + 1) as f64)
1425                .clamp(center_x - width / 2.0, center_x + width / 2.0);
1426        }
1427        used_exit_x.push(port_x);
1428        points.truncate(points.len() - 2);
1429        points.push(Point {
1430            x: port_x,
1431            y: bottom,
1432        });
1433    }
1434    for node in nodes.values_mut() {
1435        node.x -= center_x;
1436        node.y -= center_y;
1437    }
1438    for points in edges.values_mut() {
1439        translate_points_in_place(points, -center_x, -center_y);
1440    }
1441    for region_box in &mut region_boxes {
1442        region_box.min_x -= center_x;
1443        region_box.max_x -= center_x;
1444        region_box.min_y -= center_y;
1445        region_box.max_y -= center_y;
1446    }
1447    region_boxes.push(RegionBox {
1448        id: region_id,
1449        parent: region.parent,
1450        min_x: content_min_x - center_x - settings.node_gap / 2.0,
1451        max_x: content_max_x - center_x + settings.node_gap / 2.0,
1452        min_y: content_min_y - center_y - settings.layer_gap / 2.0,
1453        max_y: content_max_y - center_y + settings.layer_gap / 2.0,
1454    });
1455    if let Some(points) = &mut entry_interface {
1456        translate_points_in_place(points, -center_x, -center_y);
1457    }
1458    for points in exit_interfaces.values_mut() {
1459        translate_points_in_place(points, -center_x, -center_y);
1460    }
1461    let mut ports = BTreeMap::new();
1462    if let Some(route) = &entry_interface {
1463        let point = route[0];
1464        valid &= (point.y + height / 2.0).abs() < 1e-6;
1465        ports.insert(
1466            RegionPortId::Entry,
1467            ProxyPort {
1468                id: RegionPortId::Entry,
1469                x_offset: point.x,
1470                is_entry: true,
1471            },
1472        );
1473    }
1474    for (&source, route) in &exit_interfaces {
1475        let point = *route.last().unwrap();
1476        valid &= (point.y - height / 2.0).abs() < 1e-6;
1477        ports.insert(
1478            RegionPortId::Exit { source },
1479            ProxyPort {
1480                id: RegionPortId::Exit { source },
1481                x_offset: point.x,
1482                is_entry: false,
1483            },
1484        );
1485    }
1486    RegionComposition {
1487        nodes,
1488        edges,
1489        interface: RegionInterface {
1490            width,
1491            height,
1492            ports,
1493            entry_route: entry_interface,
1494            exit_routes: exit_interfaces,
1495        },
1496        valid,
1497        region_boxes,
1498    }
1499}
1500
1501fn translate_points(mut points: Vec<Point>, dx: f64, dy: f64) -> Vec<Point> {
1502    translate_points_in_place(&mut points, dx, dy);
1503    points
1504}
1505
1506fn translate_points_in_place(points: &mut [Point], dx: f64, dy: f64) {
1507    for point in points {
1508        point.x += dx;
1509        point.y += dy;
1510    }
1511}
1512
1513/// Concatenate routes at a router-native proxy port. A mismatch means a
1514/// caller failed to carry the fixed port through the ordinary layout pipeline;
1515/// inventing an elbow here would hide that error and violate SESE composition.
1516fn join_equal_points(points: &mut Vec<Point>, suffix: &[Point]) -> bool {
1517    const EPS: f64 = 1e-6;
1518    let last = *points.last().expect("non-empty route");
1519    let first = *suffix.first().expect("non-empty route");
1520    let equal = (last.x - first.x).abs() <= EPS && (last.y - first.y).abs() <= EPS;
1521    if equal {
1522        points.extend(suffix.iter().copied().skip(1));
1523    }
1524    equal
1525}
1526
1527fn simplify_points(points: &mut Vec<Point>) {
1528    let mut simplified = Vec::with_capacity(points.len());
1529    for point in points.drain(..) {
1530        if simplified.last() != Some(&point) {
1531            simplified.push(point);
1532        }
1533    }
1534    *points = simplified;
1535}
1536
1537fn routes_clear_of_nodes<NodeId: Identifier, EdgeId: Identifier>(
1538    nodes: &HashMap<NodeId, LayoutNode<NodeId>>,
1539    edges: &HashMap<EdgeId, Vec<Point>>,
1540) -> bool {
1541    edges.values().all(|points| {
1542        points.windows(2).all(|segment| {
1543            nodes.values().all(|node| {
1544                !crate::triskel::geometry::segment_enters_rect_strict(
1545                    segment[0],
1546                    segment[1],
1547                    Point {
1548                        x: node.x,
1549                        y: node.y,
1550                    },
1551                    node.width,
1552                    node.height,
1553                )
1554            })
1555        })
1556    })
1557}
1558
1559fn routes_are_orthogonal<EdgeId: Identifier>(edges: &HashMap<EdgeId, Vec<Point>>) -> bool {
1560    edges.values().all(|points| {
1561        points.windows(2).all(|segment| {
1562            (segment[0].x - segment[1].x).abs() < 1e-6 || (segment[0].y - segment[1].y).abs() < 1e-6
1563        })
1564    })
1565}
1566
1567fn routes_are_deconflicted<EdgeId: Identifier>(edges: &HashMap<EdgeId, Vec<Point>>) -> bool {
1568    let routes: Vec<_> = edges.values().collect();
1569    for (index, route) in routes.iter().enumerate() {
1570        for other in routes.iter().skip(index + 1) {
1571            if routes_have_collinear_overlap(route, other) {
1572                return false;
1573            }
1574        }
1575    }
1576    true
1577}
1578
1579fn routes_have_collinear_overlap(a: &[Point], b: &[Point]) -> bool {
1580    const EPS: f64 = 1e-6;
1581    a.windows(2).any(|lhs| {
1582        b.windows(2).any(|rhs| {
1583            let lhs_vertical = (lhs[0].x - lhs[1].x).abs() < EPS;
1584            let rhs_vertical = (rhs[0].x - rhs[1].x).abs() < EPS;
1585            if lhs_vertical && rhs_vertical && (lhs[0].x - rhs[0].x).abs() < EPS {
1586                lhs[0].y.min(lhs[1].y).max(rhs[0].y.min(rhs[1].y)) + EPS
1587                    < lhs[0].y.max(lhs[1].y).min(rhs[0].y.max(rhs[1].y))
1588            } else if !lhs_vertical && !rhs_vertical && (lhs[0].y - rhs[0].y).abs() < EPS {
1589                lhs[0].x.min(lhs[1].x).max(rhs[0].x.min(rhs[1].x)) + EPS
1590                    < lhs[0].x.max(lhs[1].x).min(rhs[0].x.max(rhs[1].x))
1591            } else {
1592                false
1593            }
1594        })
1595    })
1596}
1597
1598fn node_bounds<NodeId: Identifier>(
1599    nodes: &HashMap<NodeId, LayoutNode<NodeId>>,
1600) -> (f64, f64, f64, f64) {
1601    let mut bounds = (
1602        f64::INFINITY,
1603        f64::NEG_INFINITY,
1604        f64::INFINITY,
1605        f64::NEG_INFINITY,
1606    );
1607    for node in nodes.values() {
1608        bounds.0 = bounds.0.min(node.x - node.width / 2.0);
1609        bounds.1 = bounds.1.max(node.x + node.width / 2.0);
1610        bounds.2 = bounds.2.min(node.y - node.height / 2.0);
1611        bounds.3 = bounds.3.max(node.y + node.height / 2.0);
1612    }
1613    bounds
1614}
1615
1616fn layout_component<NodeId, EdgeId, NodeData, EdgeData>(
1617    graph: &OwningGraph<NodeId, EdgeId, NodeData, EdgeData>,
1618    component: &[NodeId],
1619    geometry: &HashMap<NodeId, NodeGeometry>,
1620    preferred_root: Option<NodeId>,
1621    settings: &LayoutSettings,
1622) -> ComponentLayout<NodeId, EdgeId>
1623where
1624    NodeId: Identifier + Debug,
1625    EdgeId: Identifier + Debug,
1626{
1627    layout_component_with_port_hints(
1628        graph,
1629        component,
1630        geometry,
1631        preferred_root,
1632        settings,
1633        &HashMap::default(),
1634    )
1635}
1636
1637fn layout_component_with_port_hints<NodeId, EdgeId, NodeData, EdgeData>(
1638    graph: &OwningGraph<NodeId, EdgeId, NodeData, EdgeData>,
1639    component: &[NodeId],
1640    geometry: &HashMap<NodeId, NodeGeometry>,
1641    preferred_root: Option<NodeId>,
1642    settings: &LayoutSettings,
1643    hints: &HashMap<EdgeId, PortHint>,
1644) -> ComponentLayout<NodeId, EdgeId>
1645where
1646    NodeId: Identifier + Debug,
1647    EdgeId: Identifier + Debug,
1648{
1649    // Build an internal usize-id graph for this component, recording the
1650    // mapping back to the caller's node/edge ids.
1651    let mut local = LayoutGraph::default();
1652    let mut to_local: HashMap<NodeId, usize> = HashMap::default();
1653    let mut to_orig: HashMap<usize, NodeId> = HashMap::default();
1654
1655    let mut sorted: Vec<NodeId> = component.to_vec();
1656    sorted.sort_by_key(|id| Into::<usize>::into(*id));
1657    for id in &sorted {
1658        let geom = geometry[id];
1659        let local_id = local.make_node(NodeLayoutData {
1660            width: geom.width,
1661            height: geom.height,
1662            ..Default::default()
1663        });
1664        to_local.insert(*id, local_id);
1665        to_orig.insert(local_id, *id);
1666    }
1667
1668    let in_component: HashSet<NodeId> = component.iter().copied().collect();
1669    // Self-loops are kept out of the layered graph (they would be degenerate
1670    // rank-0 cycles); instead each is drawn as a loop off its node's right face,
1671    // recorded here as (original edge id, local node id) in edge-id order.
1672    let mut self_loops: Vec<(EdgeId, usize)> = Vec::new();
1673    let mut edge_ids: Vec<EdgeId> = graph.edges().map(|e| e.id()).collect();
1674    edge_ids.sort_by_key(|id| Into::<usize>::into(*id));
1675    for edge_id in edge_ids {
1676        let edge = graph.get_edge(edge_id).unwrap();
1677        let from = edge.from_id();
1678        let to = edge.to_id();
1679        if !in_component.contains(&from) {
1680            continue; // cross-component edges impossible
1681        }
1682        if from == to {
1683            let node = to_local[&from];
1684            local.get_node_mut(node).unwrap().self_loops += 1;
1685            self_loops.push((edge_id, node));
1686            continue;
1687        }
1688        local.make_edge(
1689            to_local[&from],
1690            to_local[&to],
1691            EdgeLayoutData {
1692                orig: edge_id.into(),
1693                port_start: hints.get(&edge_id).and_then(|hint| hint.source_bottom),
1694                port_end: hints.get(&edge_id).and_then(|hint| hint.target_top),
1695                ..Default::default()
1696            },
1697        );
1698    }
1699
1700    // Pick a deterministic root: the preferred root if it lives here, else the
1701    // component's min-id node.
1702    let root_local = preferred_root
1703        .filter(|r| in_component.contains(r))
1704        .map(|r| to_local[&r])
1705        .unwrap_or_else(|| to_local[&sorted[0]]);
1706
1707    cycle::break_cycles(&mut local, root_local);
1708    rank::assign_ranks(&mut local);
1709    let seg = segment::build_segments(&mut local);
1710    let ordering = order::order(&mut local, &seg, root_local, settings.max_sweeps);
1711    // This is the ordering→coordinates phase contract.  The generated layout
1712    // properties execute the real cycle/rank/segment/order pipeline and check
1713    // that no mandatory p/lane/q equality can contradict slot separation.
1714    #[cfg(test)]
1715    assert!(coordinate::mandatory_constraints_feasible(
1716        &local, &seg, &ordering
1717    ));
1718    coordinate::assign_x(&mut local, &seg, &ordering, settings.node_gap);
1719    assign_y(&mut local, &ordering.layers, settings.layer_gap);
1720
1721    let waypoints = match settings.edge_style {
1722        EdgeStyle::Orthogonal => OrthogonalRouter.route(&local, &ordering.layers),
1723        EdgeStyle::Straight => StraightRouter.route(&local, &ordering.layers),
1724    };
1725
1726    let mut nodes = HashMap::default();
1727    for local_id in to_orig.keys().copied() {
1728        let node = local.get_node(local_id).unwrap();
1729        let orig = to_orig[&local_id];
1730        nodes.insert(
1731            orig,
1732            LayoutNode {
1733                id: orig,
1734                x: node.x,
1735                y: node.y,
1736                width: node.width,
1737                height: node.height,
1738            },
1739        );
1740    }
1741
1742    let mut edges = HashMap::default();
1743    for (orig, points) in waypoints {
1744        edges.insert(EdgeId::from(orig), points);
1745    }
1746
1747    // Lay out the self-loops over their node's reserved right margin. Multiple
1748    // loops on one node nest, drawn in stable edge-id order.
1749    let mut loop_index: HashMap<usize, u32> = HashMap::default();
1750    for (edge_id, node_id) in self_loops {
1751        let node = local.get_node(node_id).unwrap();
1752        let total = node.self_loops;
1753        let index = loop_index.entry(node_id).or_insert(0);
1754        let points = self_loop_waypoints(node.x, node.y, node.width, node.height, *index, total);
1755        *index += 1;
1756        edges.insert(edge_id, points);
1757    }
1758
1759    ComponentLayout {
1760        nodes,
1761        edges,
1762        regions: Vec::new(),
1763    }
1764}
1765
1766/// Assigns each node a y by rank, using the tallest node in each rank so bends
1767/// never fall inside a neighbouring rank's bounding box.
1768fn assign_y(graph: &mut LayoutGraph, layers: &[Vec<usize>], layer_gap: f64) {
1769    let mut max_h = vec![0.0f64; layers.len()];
1770    for (rank, nodes) in layers.iter().enumerate() {
1771        for &id in nodes {
1772            max_h[rank] = max_h[rank].max(graph.get_node(id).unwrap().height);
1773        }
1774    }
1775
1776    let mut centers = vec![0.0f64; layers.len()];
1777    for rank in 1..layers.len() {
1778        centers[rank] = centers[rank - 1] + (max_h[rank - 1] + max_h[rank]) / 2.0 + layer_gap;
1779    }
1780
1781    for (rank, nodes) in layers.iter().enumerate() {
1782        for &id in nodes {
1783            graph.get_node_mut(id).unwrap().y = centers[rank];
1784        }
1785    }
1786}
1787
1788// ── Helpers ─────────────────────────────────────────────────────────────────
1789
1790fn weakly_connected_components<NodeId, EdgeId, NodeData, EdgeData>(
1791    graph: &OwningGraph<NodeId, EdgeId, NodeData, EdgeData>,
1792    sorted_ids: &[NodeId],
1793) -> Vec<Vec<NodeId>>
1794where
1795    NodeId: Identifier + Debug,
1796    EdgeId: Identifier + Debug,
1797{
1798    let mut seen: HashSet<NodeId> = HashSet::default();
1799    let mut components = Vec::new();
1800    for &start in sorted_ids {
1801        if seen.contains(&start) {
1802            continue;
1803        }
1804        let mut component: Vec<NodeId> = graph
1805            .undirected_dfs(start)
1806            .map(|(_, node)| node.id())
1807            .collect();
1808        component.sort_by_key(|id| Into::<usize>::into(*id));
1809        for id in &component {
1810            seen.insert(*id);
1811        }
1812        components.push(component);
1813    }
1814    components
1815}
1816
1817fn local_bounds<NodeId: Identifier, EdgeId: Identifier>(
1818    layout: &ComponentLayout<NodeId, EdgeId>,
1819) -> (f64, f64, f64) {
1820    let mut min_x = f64::INFINITY;
1821    let mut max_x = f64::NEG_INFINITY;
1822    let mut min_y = f64::INFINITY;
1823
1824    for node in layout.nodes.values() {
1825        min_x = min_x.min(node.x - node.width / 2.0);
1826        max_x = max_x.max(node.x + node.width / 2.0);
1827        min_y = min_y.min(node.y - node.height / 2.0);
1828    }
1829    for point in layout.edges.values().flatten() {
1830        min_x = min_x.min(point.x);
1831        max_x = max_x.max(point.x);
1832        min_y = min_y.min(point.y);
1833    }
1834
1835    if !min_x.is_finite() {
1836        return (0.0, 0.0, 0.0);
1837    }
1838    (min_x, max_x, min_y)
1839}
1840
1841#[cfg(test)]
1842mod tests {
1843    use super::*;
1844    use jstd_derive::Identifier;
1845    use proptest::prelude::*;
1846
1847    use crate::triskel::geometry::segment_enters_rect_strict;
1848
1849    #[derive(Identifier)]
1850    struct N(usize);
1851    #[derive(Identifier)]
1852    struct E(usize);
1853    type G = OwningGraph<N, E, (), ()>;
1854
1855    fn geom() -> impl FnMut(N) -> NodeGeometry {
1856        |_| NodeGeometry {
1857            width: 60.0,
1858            height: 30.0,
1859        }
1860    }
1861
1862    fn layout(graph: &G, root: N) -> LayoutResult<N, E> {
1863        LayoutBuilder::new(graph)
1864            .root(root)
1865            .geometry(geom())
1866            .build()
1867            .expect("graph should lay out")
1868    }
1869
1870    fn diamond() -> (G, N) {
1871        let mut g = G::default();
1872        let a = g.make_node(());
1873        let b = g.make_node(());
1874        let c = g.make_node(());
1875        let d = g.make_node(());
1876        g.make_edge(a, b, ());
1877        g.make_edge(a, c, ());
1878        g.make_edge(b, d, ());
1879        g.make_edge(c, d, ());
1880        (g, a)
1881    }
1882
1883    fn signature(result: &LayoutResult<N, E>) -> Vec<String> {
1884        let mut nodes: Vec<_> = result.nodes.values().collect();
1885        nodes.sort_by_key(|n| usize::from(n.id));
1886        let mut lines: Vec<String> = nodes
1887            .iter()
1888            .map(|n| format!("n{}:{:.3},{:.3}", usize::from(n.id), n.x, n.y))
1889            .collect();
1890        let mut edges: Vec<_> = result.edges.iter().collect();
1891        edges.sort_by_key(|(e, _)| usize::from(**e));
1892        for (e, pts) in edges {
1893            let s = pts
1894                .iter()
1895                .map(|p| format!("{:.3},{:.3}", p.x, p.y))
1896                .collect::<Vec<_>>()
1897                .join(";");
1898            lines.push(format!("e{}:{s}", usize::from(*e)));
1899        }
1900        lines
1901    }
1902
1903    fn assert_no_node_overlap(result: &LayoutResult<N, E>) {
1904        let nodes: Vec<_> = result.nodes.values().collect();
1905        for (i, a) in nodes.iter().enumerate() {
1906            for b in nodes.iter().skip(i + 1) {
1907                let sep_x = (a.x - b.x).abs() + 1e-6 >= (a.width + b.width) / 2.0;
1908                let sep_y = (a.y - b.y).abs() + 1e-6 >= (a.height + b.height) / 2.0;
1909                assert!(
1910                    sep_x || sep_y,
1911                    "nodes {} and {} overlap at ({:.2},{:.2}) / ({:.2},{:.2})",
1912                    usize::from(a.id),
1913                    usize::from(b.id),
1914                    a.x,
1915                    a.y,
1916                    b.x,
1917                    b.y
1918                );
1919            }
1920        }
1921    }
1922
1923    fn assert_no_edge_through_node(result: &LayoutResult<N, E>) {
1924        for (edge_id, points) in &result.edges {
1925            for (index, seg) in points.windows(2).enumerate() {
1926                for node in result.nodes.values() {
1927                    assert!(
1928                        !segment_enters_rect_strict(
1929                            seg[0],
1930                            seg[1],
1931                            Point {
1932                                x: node.x,
1933                                y: node.y
1934                            },
1935                            node.width,
1936                            node.height,
1937                        ),
1938                        "edge {} segment {index} passes through node {}",
1939                        usize::from(*edge_id),
1940                        usize::from(node.id)
1941                    );
1942                }
1943            }
1944        }
1945    }
1946
1947    #[test]
1948    #[should_panic(expected = "segment 1 passes through node 0")]
1949    fn edge_checker_rejects_later_reentry_into_endpoint_node() {
1950        let source = N::from(0);
1951        let target = N::from(1);
1952        let edge = E::from(0);
1953        let mut nodes = HashMap::default();
1954        nodes.insert(
1955            source,
1956            LayoutNode {
1957                id: source,
1958                x: 0.0,
1959                y: 0.0,
1960                width: 10.0,
1961                height: 10.0,
1962            },
1963        );
1964        nodes.insert(
1965            target,
1966            LayoutNode {
1967                id: target,
1968                x: 20.0,
1969                y: 0.0,
1970                width: 10.0,
1971                height: 10.0,
1972            },
1973        );
1974        let mut edges = HashMap::default();
1975        // The first point validly touches source's bottom boundary. Segment 1
1976        // then re-enters its interior, which the former whole-polyline
1977        // endpoint exemption incorrectly accepted.
1978        edges.insert(
1979            edge,
1980            vec![
1981                Point { x: 0.0, y: 5.0 },
1982                Point { x: 0.0, y: 10.0 },
1983                Point { x: 0.0, y: 0.0 },
1984                Point { x: 20.0, y: 0.0 },
1985            ],
1986        );
1987        assert_no_edge_through_node(&LayoutResult {
1988            nodes,
1989            edges,
1990            regions: Vec::new(),
1991        });
1992    }
1993
1994    /// Rejects every edge intersection with a node that is not an endpoint of
1995    /// that edge. Endpoint interiors are checked separately because a route may
1996    /// validly begin/end on their boundaries.
1997    fn assert_route_attaches_to_endpoints(
1998        result: &LayoutResult<N, E>,
1999        endpoints: &HashMap<E, (N, N)>,
2000    ) {
2001        let eps = 1e-6;
2002        for (edge, points) in &result.edges {
2003            let &(source, target) = endpoints.get(edge).expect("missing edge endpoint data");
2004            for (point, node) in [
2005                (points.first().unwrap(), result.nodes[&source]),
2006                (points.last().unwrap(), result.nodes[&target]),
2007            ] {
2008                let on_vertical_face = (point.x - (node.x - node.width / 2.0)).abs() < eps
2009                    || (point.x - (node.x + node.width / 2.0)).abs() < eps;
2010                let on_horizontal_face = (point.y - (node.y - node.height / 2.0)).abs() < eps
2011                    || (point.y - (node.y + node.height / 2.0)).abs() < eps;
2012                assert!(
2013                    (on_vertical_face
2014                        && point.y >= node.y - node.height / 2.0 - eps
2015                        && point.y <= node.y + node.height / 2.0 + eps)
2016                        || (on_horizontal_face
2017                            && point.x >= node.x - node.width / 2.0 - eps
2018                            && point.x <= node.x + node.width / 2.0 + eps),
2019                    "edge {} does not attach to node {}: {point:?} vs {node:?}",
2020                    usize::from(*edge),
2021                    usize::from(node.id),
2022                );
2023            }
2024        }
2025    }
2026
2027    fn assert_no_edge_through_nonincident_node(
2028        result: &LayoutResult<N, E>,
2029        endpoints: &HashMap<E, (N, N)>,
2030    ) {
2031        for (edge_id, points) in &result.edges {
2032            let &(source, target) = endpoints.get(edge_id).expect("missing edge endpoint data");
2033            for seg in points.windows(2) {
2034                for node in result.nodes.values() {
2035                    if node.id == source || node.id == target {
2036                        continue;
2037                    }
2038                    assert!(
2039                        !segment_enters_rect_strict(
2040                            seg[0],
2041                            seg[1],
2042                            Point {
2043                                x: node.x,
2044                                y: node.y
2045                            },
2046                            node.width,
2047                            node.height,
2048                        ),
2049                        "edge {} passes through non-incident node {}",
2050                        usize::from(*edge_id),
2051                        usize::from(node.id)
2052                    );
2053                }
2054            }
2055        }
2056    }
2057
2058    /// No two horizontal edge segments (from distinct edges) may share a y while
2059    /// their x-ranges overlap — that is the overlap the lane assignment removes.
2060    fn assert_no_horizontal_overlap(result: &LayoutResult<N, E>) {
2061        let eps = 1e-6;
2062        // (edge id, y, x0, x1) for every horizontal segment.
2063        let mut hsegs: Vec<(usize, f64, f64, f64)> = Vec::new();
2064        for (edge_id, points) in &result.edges {
2065            for seg in points.windows(2) {
2066                let (p, q) = (seg[0], seg[1]);
2067                if (p.y - q.y).abs() < eps && (p.x - q.x).abs() > eps {
2068                    hsegs.push((usize::from(*edge_id), p.y, p.x.min(q.x), p.x.max(q.x)));
2069                }
2070            }
2071        }
2072        for (i, &(ea, ya, ax0, ax1)) in hsegs.iter().enumerate() {
2073            for &(eb, yb, bx0, bx1) in hsegs.iter().skip(i + 1) {
2074                if ea == eb || (ya - yb).abs() > eps {
2075                    continue;
2076                }
2077                let overlap = ax0.max(bx0) + eps < ax1.min(bx1);
2078                assert!(
2079                    !overlap,
2080                    "edges {ea} and {eb} have overlapping horizontal segments at y={ya:.2}: \
2081                     [{ax0:.2},{ax1:.2}] vs [{bx0:.2},{bx1:.2}]"
2082                );
2083            }
2084        }
2085    }
2086
2087    /// No two vertical edge segments (from distinct edges) may share an x while
2088    /// their y-ranges overlap. This is the vertical counterpart of the channel
2089    /// lane check above and catches accidentally bundled dummy columns.
2090    fn assert_no_vertical_overlap(result: &LayoutResult<N, E>) {
2091        let eps = 1e-6;
2092        let mut vsegs: Vec<(usize, f64, f64, f64)> = Vec::new();
2093        for (edge_id, points) in &result.edges {
2094            for seg in points.windows(2) {
2095                let (p, q) = (seg[0], seg[1]);
2096                if (p.x - q.x).abs() < eps && (p.y - q.y).abs() > eps {
2097                    vsegs.push((usize::from(*edge_id), p.x, p.y.min(q.y), p.y.max(q.y)));
2098                }
2099            }
2100        }
2101        for (i, &(ea, xa, ay0, ay1)) in vsegs.iter().enumerate() {
2102            for &(eb, xb, by0, by1) in vsegs.iter().skip(i + 1) {
2103                if ea == eb || (xa - xb).abs() > eps {
2104                    continue;
2105                }
2106                let overlap = ay0.max(by0) + eps < ay1.min(by1);
2107                assert!(
2108                    !overlap,
2109                    "edges {ea} and {eb} have overlapping vertical segments at x={xa:.2}: \
2110                     [{ay0:.2},{ay1:.2}] vs [{by0:.2},{by1:.2}]"
2111                );
2112            }
2113        }
2114    }
2115
2116    proptest! {
2117        #![proptest_config(ProptestConfig::with_cases(64))]
2118        #[test]
2119        fn generated_segmented_layouts_preserve_phase_and_route_invariants(
2120            // Every generated graph includes the fixture below, which forces
2121            // simultaneous/nested long segments, a cycle/back-edge gadget and
2122            // a self-loop reservation through the real pipeline.
2123            node_count in 6usize..=10,
2124            edge_pairs in proptest::collection::vec((0usize..20, 0usize..20), 0..24),
2125            dimensions in proptest::collection::vec((10u32..=140, 10u32..=80), 6..=10),
2126            node_gap in 8u32..=40,
2127            layer_gap in 16u32..=80,
2128            sweeps in 1usize..=6,
2129            orthogonal in any::<bool>(),
2130            sese in any::<bool>(),
2131        ) {
2132            let mut graph = G::default();
2133            let nodes: Vec<_> = (0..node_count).map(|_| graph.make_node(())).collect();
2134            let mut endpoints = HashMap::default();
2135            for (from, to) in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (0, 5), (1, 4), (5, 0), (2, 2)]
2136                .into_iter()
2137                .chain(edge_pairs)
2138            {
2139                let (from, to) = (nodes[from % node_count], nodes[to % node_count]);
2140                let edge = graph.make_edge(from, to, ());
2141                endpoints.insert(edge, (from, to));
2142            }
2143            let geometry = dimensions.clone();
2144            let build = || LayoutBuilder::new(&graph)
2145                .root(nodes[0])
2146                .node_gap(node_gap as f64)
2147                .layer_gap(layer_gap as f64)
2148                .max_sweeps(sweeps)
2149                .edge_style(if orthogonal { EdgeStyle::Orthogonal } else { EdgeStyle::Straight })
2150                .mode(if sese { LayoutMode::Sese } else { LayoutMode::Flat })
2151                .geometry(|id| {
2152                    let (width, height) = geometry[usize::from(id) % geometry.len()];
2153                    NodeGeometry { width: width as f64, height: height as f64 }
2154                })
2155                .build()
2156                .unwrap();
2157            let first = build();
2158            let second = build();
2159            prop_assert_eq!(signature(&first), signature(&second));
2160            prop_assert_eq!(first.nodes.len(), node_count);
2161            prop_assert_eq!(first.edges.len(), graph.edges().count());
2162            for node in first.nodes.values() {
2163                prop_assert!(node.x.is_finite() && node.y.is_finite());
2164                prop_assert!(node.width.is_finite() && node.height.is_finite());
2165                prop_assert!(node.width > 0.0 && node.height > 0.0);
2166            }
2167            for points in first.edges.values() {
2168                prop_assert!(points.len() >= 2);
2169                for pair in points.windows(2) {
2170                    prop_assert!(pair[0].x.is_finite() && pair[0].y.is_finite());
2171                    prop_assert!(pair[1].x.is_finite() && pair[1].y.is_finite());
2172                    prop_assert!((pair[0].x - pair[1].x).abs() > 1e-9 || (pair[0].y - pair[1].y).abs() > 1e-9);
2173                    if orthogonal {
2174                        prop_assert!((pair[0].x - pair[1].x).abs() < 1e-6 || (pair[0].y - pair[1].y).abs() < 1e-6);
2175                    }
2176                }
2177            }
2178            assert_no_node_overlap(&first);
2179            assert_no_edge_through_node(&first);
2180            assert_no_edge_through_nonincident_node(&first, &endpoints);
2181            // Mixed Straight output can contain fallback orthogonal segments,
2182            // so collinear-overlap checks apply even when the requested style
2183            // is not fully orthogonal.
2184            if !orthogonal {
2185                assert_no_horizontal_overlap(&first);
2186                assert_no_vertical_overlap(&first);
2187            }
2188        }
2189    }
2190
2191    proptest! {
2192        #![proptest_config(ProptestConfig::with_cases(256))]
2193        #[test]
2194        #[ignore = "run explicitly as cargo test triskel_stress_generated_layouts -- --ignored"]
2195        fn triskel_stress_generated_layouts(
2196            node_count in 2usize..=10,
2197            edge_pairs in proptest::collection::vec((0usize..20, 0usize..20), 1..24),
2198            dimensions in proptest::collection::vec((10u32..=140, 10u32..=80), 2..=10),
2199            root_index in 0usize..24,
2200            orthogonal in any::<bool>(),
2201            sese in any::<bool>(),
2202        ) {
2203            // This deliberately uses proptest rather than seed enumeration, so
2204            // failures shrink to an edge list, root, dimensions, and style.
2205            let mut graph = G::default();
2206            let nodes: Vec<_> = (0..node_count).map(|_| graph.make_node(())).collect();
2207            let mut endpoints = HashMap::default();
2208            for (from, to) in edge_pairs {
2209                let edge = graph.make_edge(nodes[from % node_count], nodes[to % node_count], ());
2210                endpoints.insert(edge, (nodes[from % node_count], nodes[to % node_count]));
2211            }
2212            let geometry = dimensions.clone();
2213            let result = LayoutBuilder::new(&graph)
2214                .root(nodes[root_index % node_count])
2215                .max_sweeps(8)
2216                .edge_style(if orthogonal { EdgeStyle::Orthogonal } else { EdgeStyle::Straight })
2217                .mode(if sese { LayoutMode::Sese } else { LayoutMode::Flat })
2218                .geometry(|id| {
2219                    let (width, height) = geometry[usize::from(id) % geometry.len()];
2220                    NodeGeometry { width: width as f64, height: height as f64 }
2221                })
2222                .build()
2223                .unwrap();
2224            prop_assert_eq!(result.edges.len(), graph.edges().count());
2225            assert_no_node_overlap(&result);
2226            assert_no_edge_through_node(&result);
2227            assert_no_edge_through_nonincident_node(&result, &endpoints);
2228        }
2229    }
2230
2231    #[test]
2232    fn crossing_edges_use_separate_horizontal_lanes() {
2233        // a,b on rank 0; c,d on rank 1. a->d and b->c cross, so both jog across
2234        // the same channel with overlapping x-ranges. They must land on
2235        // different y-lanes rather than drawing over each other.
2236        let mut g = G::default();
2237        let a = g.make_node(());
2238        let b = g.make_node(());
2239        let c = g.make_node(());
2240        let d = g.make_node(());
2241        g.make_edge(a, c, ());
2242        g.make_edge(a, d, ());
2243        g.make_edge(b, c, ());
2244        g.make_edge(b, d, ());
2245        let r = layout(&g, a);
2246        assert_no_horizontal_overlap(&r);
2247        assert_no_edge_through_node(&r);
2248        assert_no_node_overlap(&r);
2249    }
2250
2251    #[test]
2252    fn non_overlapping_jogs_share_the_channel_midpoint() {
2253        // In the diamond, a's two out-edges jog in channel 0 toward b (left) and
2254        // c (right); their x-ranges do not overlap, so both keep lane 0 — the
2255        // exact channel midpoint — confirming the lane pass is a no-op when there
2256        // is nothing to deconflict.
2257        let (g, root) = diamond();
2258        let r = layout(&g, root);
2259        let a = r.get_node(root).unwrap();
2260        let any_child = r.nodes.values().find(|n| n.y > a.y).unwrap();
2261        let midpoint = ((a.y + a.height / 2.0) + (any_child.y - any_child.height / 2.0)) / 2.0;
2262        for points in r.edges.values() {
2263            for seg in points.windows(2) {
2264                let (p, q) = (seg[0], seg[1]);
2265                let horizontal = (p.y - q.y).abs() < 1e-9 && (p.x - q.x).abs() > 1e-9;
2266                // Only the top channel (a → b/c) sits at this midpoint.
2267                if horizontal && (p.y - midpoint).abs() < 5.0 {
2268                    assert!(
2269                        (p.y - midpoint).abs() < 1e-6,
2270                        "jog y {:.3} should equal channel midpoint {:.3}",
2271                        p.y,
2272                        midpoint
2273                    );
2274                }
2275            }
2276        }
2277        assert_no_horizontal_overlap(&r);
2278        assert_no_vertical_overlap(&r);
2279    }
2280
2281    #[test]
2282    fn is_deterministic() {
2283        let (g, root) = diamond();
2284        let first = signature(&layout(&g, root));
2285        for _ in 0..10 {
2286            assert_eq!(first, signature(&layout(&g, root)));
2287        }
2288    }
2289
2290    #[test]
2291    fn chain_ranks_increase_downward() {
2292        let mut g = G::default();
2293        let a = g.make_node(());
2294        let b = g.make_node(());
2295        let c = g.make_node(());
2296        g.make_edge(a, b, ());
2297        g.make_edge(b, c, ());
2298        let r = layout(&g, a);
2299        let ay = r.get_node(a).unwrap().y;
2300        let by = r.get_node(b).unwrap().y;
2301        let cy = r.get_node(c).unwrap().y;
2302        assert!(ay < by && by < cy, "{ay} {by} {cy}");
2303    }
2304
2305    #[test]
2306    fn diamond_has_no_overlap_and_clean_edges() {
2307        let (g, root) = diamond();
2308        let r = layout(&g, root);
2309        assert_no_node_overlap(&r);
2310        assert_no_edge_through_node(&r);
2311        for points in r.edges.values() {
2312            assert!(points.len() >= 2);
2313        }
2314    }
2315
2316    #[test]
2317    fn long_edge_spans_with_dummies_and_renders_once() {
2318        let mut g = G::default();
2319        let a = g.make_node(());
2320        let b = g.make_node(());
2321        let c = g.make_node(());
2322        let d = g.make_node(());
2323        g.make_edge(a, b, ());
2324        g.make_edge(b, c, ());
2325        g.make_edge(c, d, ());
2326        let long = g.make_edge(a, d, ());
2327        let r = layout(&g, a);
2328        assert!(r.get_waypoints(long).unwrap().len() >= 2);
2329        let svg = r.render_svg();
2330        let marker = format!("data-edge-id=\"{}\"", usize::from(long));
2331        assert_eq!(svg.matches(&marker).count(), 1);
2332    }
2333
2334    #[test]
2335    fn orthogonal_router_yields_right_angles() {
2336        let (g, root) = diamond();
2337        let r = layout(&g, root);
2338        for points in r.edges.values() {
2339            for seg in points.windows(2) {
2340                let axis_aligned =
2341                    (seg[0].x - seg[1].x).abs() < 1e-6 || (seg[0].y - seg[1].y).abs() < 1e-6;
2342                assert!(
2343                    axis_aligned,
2344                    "non-orthogonal segment {:?}->{:?}",
2345                    seg[0], seg[1]
2346                );
2347            }
2348        }
2349    }
2350
2351    #[test]
2352    fn back_edge_runs_upward_to_target() {
2353        // a -> b -> c -> a : the back edge c->a should be drawn source(c)->target(a)
2354        // travelling upward (first point lower than last).
2355        let mut g = G::default();
2356        let a = g.make_node(());
2357        let b = g.make_node(());
2358        let c = g.make_node(());
2359        g.make_edge(a, b, ());
2360        g.make_edge(b, c, ());
2361        let back = g.make_edge(c, a, ());
2362        let r = layout(&g, a);
2363        let pts = r.get_waypoints(back).unwrap();
2364        assert!(
2365            pts.first().unwrap().y > pts.last().unwrap().y,
2366            "back edge should travel upward: {:?}",
2367            pts
2368        );
2369
2370        // It wraps around the end blocks: leaving the source (c) from its bottom
2371        // and entering the target (a) at its top, like every forward edge.
2372        let eps = 1e-6;
2373        let src = r.get_node(c).unwrap();
2374        let tgt = r.get_node(a).unwrap();
2375        let start = pts.first().unwrap();
2376        let end = pts.last().unwrap();
2377        assert!(
2378            (start.y - (src.y + src.height / 2.0)).abs() < eps,
2379            "back edge should start at the source's bottom: {start:?} vs c={src:?}"
2380        );
2381        assert!(
2382            (end.y - (tgt.y - tgt.height / 2.0)).abs() < eps,
2383            "back edge should end at the target's top: {end:?} vs a={tgt:?}"
2384        );
2385        assert_no_edge_through_node(&r);
2386    }
2387
2388    #[test]
2389    fn adjacent_back_edge_wraps_without_dummies() {
2390        // a <-> b : the back edge b->a is one rank apart, so it has no dummy
2391        // column to reuse. It must still exit b's bottom and enter a's top,
2392        // wrapping around the side, and pass through no node.
2393        let mut g = G::default();
2394        let a = g.make_node(());
2395        let b = g.make_node(());
2396        g.make_edge(a, b, ());
2397        let back = g.make_edge(b, a, ());
2398        let r = layout(&g, a);
2399        let pts = r.get_waypoints(back).unwrap();
2400        let eps = 1e-6;
2401        let src = r.get_node(b).unwrap();
2402        let tgt = r.get_node(a).unwrap();
2403        let start = pts.first().unwrap();
2404        let end = pts.last().unwrap();
2405        assert!(
2406            (start.y - (src.y + src.height / 2.0)).abs() < eps,
2407            "back edge should start at the source's bottom: {start:?} vs b={src:?}"
2408        );
2409        assert!(
2410            (end.y - (tgt.y - tgt.height / 2.0)).abs() < eps,
2411            "back edge should end at the target's top: {end:?} vs a={tgt:?}"
2412        );
2413        assert_no_edge_through_node(&r);
2414    }
2415
2416    #[test]
2417    fn back_edge_keeps_source_port_on_the_bottom_fan() {
2418        // top -> s, s -> a, s -> b, and the back edge s -> top. All three edges
2419        // leaving s (two forward, one back) must fan across s's BOTTOM face — the
2420        // back edge counts as an exit, not as if s still had one fewer.
2421        let mut g = G::default();
2422        let top = g.make_node(());
2423        let s = g.make_node(());
2424        let a = g.make_node(());
2425        let b = g.make_node(());
2426        g.make_edge(top, s, ());
2427        let e_a = g.make_edge(s, a, ());
2428        let e_b = g.make_edge(s, b, ());
2429        let e_back = g.make_edge(s, top, ()); // back edge: top is s's ancestor
2430        let r = layout(&g, top);
2431
2432        let sn = r.get_node(s).unwrap();
2433        let bottom_y = sn.y + sn.height / 2.0;
2434        let eps = 1e-6;
2435
2436        // Each edge's endpoint that touches s is the one at s's bottom face. For
2437        // forward edges that's the first point; for the reversed edge the polyline
2438        // runs s -> top, so it is also the first point (s is its source).
2439        let port_x = |edge| {
2440            let pts = r.get_waypoints(edge).unwrap();
2441            let p = pts.first().unwrap();
2442            assert!(
2443                (p.y - bottom_y).abs() < eps,
2444                "edge should leave s's bottom: {p:?} vs y={bottom_y}"
2445            );
2446            p.x
2447        };
2448        let mut xs = [port_x(e_a), port_x(e_b), port_x(e_back)];
2449        xs.sort_by(f64::total_cmp);
2450
2451        // The bottom face fans three edges across three slots — one each in the
2452        // left, middle and right third of s's width. Under the old (broken)
2453        // assignment the back edge would have counted on the TOP face, leaving
2454        // only a two-slot fan and the back edge starting at s's top instead.
2455        let half = sn.width / 2.0;
2456        assert!(
2457            xs[0] < sn.x - half / 4.0
2458                && (xs[1] - sn.x).abs() <= half / 2.0
2459                && xs[2] > sn.x + half / 4.0,
2460            "the three exits should fan across s's bottom in distinct slots: {xs:?} (s.x={})",
2461            sn.x
2462        );
2463        for p in xs {
2464            assert!(
2465                p >= sn.x - half - eps && p <= sn.x + half + eps,
2466                "port {p} outside s's width [{}, {}]",
2467                sn.x - half,
2468                sn.x + half
2469            );
2470        }
2471        assert_no_edge_through_node(&r);
2472    }
2473
2474    #[test]
2475    fn multiple_back_edges_into_one_node_wrap_cleanly() {
2476        // a -> b, b -> c, b -> d, c -> a, d -> a : two back edges (c->a and d->a)
2477        // both target a. Each must wrap into a's top through its own reserved
2478        // lane, leaving its source's bottom, and clip no node.
2479        let mut g = G::default();
2480        let a = g.make_node(());
2481        let b = g.make_node(());
2482        let c = g.make_node(());
2483        let d = g.make_node(());
2484        g.make_edge(a, b, ());
2485        g.make_edge(b, c, ());
2486        g.make_edge(b, d, ());
2487        let back_c = g.make_edge(c, a, ());
2488        let back_d = g.make_edge(d, a, ());
2489        let r = layout(&g, a);
2490
2491        let eps = 1e-6;
2492        let tgt = r.get_node(a).unwrap();
2493        let mut top_ports = Vec::new();
2494        for (src_id, back) in [(c, back_c), (d, back_d)] {
2495            let pts = r.get_waypoints(back).unwrap();
2496            let src = r.get_node(src_id).unwrap();
2497            let start = pts.first().unwrap();
2498            let end = pts.last().unwrap();
2499            assert!(
2500                (start.y - (src.y + src.height / 2.0)).abs() < eps,
2501                "back edge should leave the source's bottom: {start:?}"
2502            );
2503            assert!(
2504                (end.y - (tgt.y - tgt.height / 2.0)).abs() < eps,
2505                "back edge should enter the target's top: {end:?}"
2506            );
2507            top_ports.push(end.x);
2508        }
2509        assert!(
2510            (top_ports[0] - top_ports[1]).abs() > eps,
2511            "the two back edges must enter a's top at distinct ports: {top_ports:?}"
2512        );
2513        assert_no_edge_through_node(&r);
2514        assert_no_node_overlap(&r);
2515    }
2516
2517    #[test]
2518    fn long_back_edge_has_a_straight_column() {
2519        // a -> b -> c -> d -> e with the back edge e -> a spanning every rank.
2520        // Its gadget should draw as a single straight vertical column with one
2521        // jog at each end: source bottom -> down -> column -> up -> top jog ->
2522        // target top (six points).
2523        let mut g = G::default();
2524        let n: Vec<_> = (0..5).map(|_| g.make_node(())).collect();
2525        for w in n.windows(2) {
2526            g.make_edge(w[0], w[1], ());
2527        }
2528        let back = g.make_edge(n[4], n[0], ());
2529        let r = layout(&g, n[0]);
2530        let pts = r.get_waypoints(back).unwrap();
2531        assert_eq!(
2532            pts.len(),
2533            6,
2534            "a single long back edge should wrap with a straight column: {pts:?}"
2535        );
2536        // Points 2..3 are the column: vertical and spanning the ranks.
2537        assert!(
2538            (pts[2].x - pts[3].x).abs() < 1e-6,
2539            "the column must be vertical: {pts:?}"
2540        );
2541        assert!(
2542            (pts[2].y - pts[3].y).abs() > r.get_node(n[0]).unwrap().height,
2543            "the column must span the ranks: {pts:?}"
2544        );
2545        assert_no_edge_through_node(&r);
2546    }
2547
2548    #[test]
2549    fn self_loop_is_rendered_off_the_right_face() {
2550        let mut g = G::default();
2551        let a = g.make_node(());
2552        let b = g.make_node(());
2553        let loop_edge = g.make_edge(a, a, ());
2554        g.make_edge(a, b, ());
2555        let r = layout(&g, a);
2556        assert_eq!(r.nodes.len(), 2);
2557
2558        // The loop is a polyline that leaves and re-enters a's right face.
2559        let an = r.get_node(a).unwrap();
2560        let pts = r.get_waypoints(loop_edge).expect("self-loop must render");
2561        assert!(
2562            pts.len() >= 4,
2563            "self-loop should be a loop polyline: {pts:?}"
2564        );
2565        let right = an.x + an.width / 2.0;
2566        let eps = 1e-6;
2567        assert!(
2568            (pts.first().unwrap().x - right).abs() < eps
2569                && (pts.last().unwrap().x - right).abs() < eps,
2570            "loop must attach to the right face at x={right}: {pts:?}"
2571        );
2572        // It protrudes to the right of the node and stays within its height band.
2573        assert!(
2574            pts.iter().any(|p| p.x > right + eps),
2575            "loop must protrude rightward: {pts:?}"
2576        );
2577        for p in pts {
2578            assert!(
2579                p.y >= an.y - an.height / 2.0 - eps && p.y <= an.y + an.height / 2.0 + eps,
2580                "loop must stay within the node's height band: {p:?}"
2581            );
2582        }
2583
2584        // The reserved margin keeps the loop clear of the other node.
2585        assert_no_node_overlap(&r);
2586    }
2587
2588    #[test]
2589    fn self_loop_reserves_room_from_neighbours() {
2590        // a has a self-loop and a wide right neighbour `c`; the loop must not
2591        // collide with c. b/c sit on the rank below a.
2592        let mut g = G::default();
2593        let a = g.make_node(());
2594        let b = g.make_node(());
2595        let c = g.make_node(());
2596        let loop_edge = g.make_edge(a, a, ());
2597        g.make_edge(a, b, ());
2598        g.make_edge(a, c, ());
2599        let r = layout(&g, a);
2600        let an = r.get_node(a).unwrap();
2601        let loop_max_x = r
2602            .get_waypoints(loop_edge)
2603            .unwrap()
2604            .iter()
2605            .map(|p| p.x)
2606            .fold(f64::NEG_INFINITY, f64::max);
2607        // The loop protrudes past the node's right edge but the reservation is
2608        // accounted for in spacing, so no node box is crossed by it.
2609        assert!(loop_max_x > an.x + an.width / 2.0);
2610        assert_no_edge_through_node(&r);
2611    }
2612
2613    #[test]
2614    fn sese_mode_expands_nested_regions_and_preserves_routes() {
2615        let mut graph = G::default();
2616        let nodes: Vec<_> = (0..10).map(|_| graph.make_node(())).collect();
2617        let mut endpoints = HashMap::default();
2618        for (from, to) in [
2619            (0, 1),
2620            (1, 2),
2621            (1, 3),
2622            (2, 4),
2623            (3, 4),
2624            (4, 5),
2625            (5, 6),
2626            (5, 7),
2627            (6, 8),
2628            (7, 8),
2629            (8, 9),
2630        ] {
2631            let edge = graph.make_edge(nodes[from], nodes[to], ());
2632            endpoints.insert(edge, (nodes[from], nodes[to]));
2633        }
2634        let build = || {
2635            LayoutBuilder::new(&graph)
2636                .root(nodes[0])
2637                .mode(LayoutMode::Sese)
2638                .geometry(|id| NodeGeometry {
2639                    width: 30.0 + usize::from(id) as f64 * 3.0,
2640                    height: 24.0,
2641                })
2642                .build()
2643                .unwrap()
2644        };
2645        let first = build();
2646        let second = build();
2647        assert_eq!(signature(&first), signature(&second));
2648        assert_eq!(first.nodes.len(), nodes.len());
2649        assert_eq!(first.edges.len(), endpoints.len());
2650        assert!(
2651            !first.regions.is_empty(),
2652            "SESE debug regions should be retained"
2653        );
2654        assert_no_node_overlap(&first);
2655        assert_no_edge_through_nonincident_node(&first, &endpoints);
2656        assert_route_attaches_to_endpoints(&first, &endpoints);
2657        for points in first.edges.values() {
2658            assert!(points.len() >= 2);
2659            assert!(points.windows(2).all(|pair| {
2660                (pair[0].x - pair[1].x).abs() < 1e-6 || (pair[0].y - pair[1].y).abs() < 1e-6
2661            }));
2662        }
2663
2664        let straight = LayoutBuilder::new(&graph)
2665            .root(nodes[0])
2666            .mode(LayoutMode::Sese)
2667            .edge_style(EdgeStyle::Straight)
2668            .build()
2669            .unwrap();
2670        assert!(
2671            straight
2672                .edges
2673                .values()
2674                .any(|points| points.windows(2).any(|pair| {
2675                    (pair[0].x - pair[1].x).abs() > 1e-6 && (pair[0].y - pair[1].y).abs() > 1e-6
2676                }))
2677        );
2678        assert_no_edge_through_nonincident_node(&straight, &endpoints);
2679    }
2680
2681    #[test]
2682    fn sese_normalization_keeps_virtual_hammock_terminals_internal() {
2683        let mut graph = G::default();
2684        let entry = graph.make_node(());
2685        let left = graph.make_node(());
2686        let right = graph.make_node(());
2687        graph.make_edge(entry, left, ());
2688        graph.make_edge(entry, right, ());
2689        let component = vec![entry, left, right];
2690        let tree = compute_sese_normalized(&graph, &component, entry);
2691        assert_eq!(tree.root().contained_nodes, component);
2692        let mut owned: Vec<_> = tree
2693            .regions
2694            .iter()
2695            .flat_map(|region| region.nodes.iter().copied())
2696            .collect();
2697        owned.sort();
2698        assert_eq!(owned, vec![entry, left, right]);
2699        assert!(tree.regions.iter().all(|region| {
2700            region
2701                .contained_nodes
2702                .iter()
2703                .all(|node| [entry, left, right].contains(node))
2704        }));
2705    }
2706
2707    #[test]
2708    fn hammock_fallback_groups_multiple_exit_edges_to_one_exit_node() {
2709        let mut graph = G::default();
2710        let root = graph.make_node(());
2711        let a = graph.make_node(());
2712        let end = graph.make_node(());
2713        let b = graph.make_node(());
2714        let c = graph.make_node(());
2715        let d = graph.make_node(());
2716        let e = graph.make_node(());
2717        let f = graph.make_node(());
2718        graph.make_edge(root, a, ());
2719        graph.make_edge(a, end, ());
2720        let entry = graph.make_edge(root, b, ());
2721        graph.make_edge(b, c, ());
2722        graph.make_edge(c, d, ());
2723        let first_exit = graph.make_edge(d, end, ());
2724        graph.make_edge(b, e, ());
2725        graph.make_edge(e, f, ());
2726        graph.make_edge(f, end, ());
2727        let component = vec![root, a, end, b, c, d, e, f];
2728        let fallback = compute_hammock_fallback(&graph, &component);
2729        let tree = compute_layout_sese_tree(&graph, &component, root);
2730        assert_eq!(
2731            tree.regions.len(),
2732            4,
2733            "root, hammock, and two edge-SESE children"
2734        );
2735        let hammock = tree
2736            .regions
2737            .iter()
2738            .find(|region| region.contained_nodes == vec![b, c, d, e, f])
2739            .expect("b..f hammock");
2740        assert_eq!(hammock.entry_edge, Some(entry));
2741        assert_eq!(hammock.exit_edge, Some(first_exit));
2742        let hammock_id = tree
2743            .regions
2744            .iter()
2745            .position(|region| region.contained_nodes == vec![b, c, d, e, f])
2746            .unwrap();
2747        for nodes in [[c, d], [e, f]] {
2748            let child = tree
2749                .regions
2750                .iter()
2751                .find(|region| region.contained_nodes == nodes)
2752                .expect("nested edge-SESE");
2753            assert_eq!(child.parent, Some(hammock_id));
2754        }
2755        assert!(
2756            fallback
2757                .regions
2758                .iter()
2759                .any(|region| region.contained_nodes == vec![b, c, d, e, f])
2760        );
2761
2762        // The two exits are distinct named bottom-face ports. Their internal
2763        // routes terminate exactly there, and composing the root records that
2764        // every proxy join was an equality (rather than an inserted elbow).
2765        let geometry: HashMap<_, _> = graph
2766            .nodes()
2767            .map(|node| (node.id(), NodeGeometry::default()))
2768            .collect();
2769        let hammock_composition = compose_sese_region(
2770            &graph,
2771            &geometry,
2772            &LayoutSettings::default(),
2773            &tree,
2774            hammock_id,
2775            root,
2776        );
2777        assert!(hammock_composition.valid, "all proxy joins are point equal");
2778        // Region bounds are content plus one margin; terminal rank spacing is
2779        // not allowed to inflate a proxy rectangle.
2780        for region_box in &hammock_composition.region_boxes {
2781            let owned = &tree.regions[region_box.id].contained_nodes;
2782            let owned_nodes: HashMap<_, _> = hammock_composition
2783                .nodes
2784                .iter()
2785                .filter(|(id, _)| owned.contains(id))
2786                .map(|(&id, &node)| (id, node))
2787                .collect();
2788            let (min_x, max_x, min_y, max_y) = node_bounds(&owned_nodes);
2789            assert!((region_box.max_x - region_box.min_x - (max_x - min_x + 40.0)).abs() < 1e-6);
2790            assert!((region_box.max_y - region_box.min_y - (max_y - min_y + 50.0)).abs() < 1e-6);
2791        }
2792        let first_port = hammock_composition.interface.ports[&RegionPortId::Exit { source: d }];
2793        let second_port = hammock_composition.interface.ports[&RegionPortId::Exit { source: f }];
2794        assert_ne!(first_port.x_offset, second_port.x_offset);
2795        for (source, port) in [(d, first_port), (f, second_port)] {
2796            assert!(!port.is_entry);
2797            assert_eq!(port.id, RegionPortId::Exit { source });
2798            assert!(port.x_offset.abs() <= hammock_composition.interface.width / 2.0);
2799            let route = &hammock_composition.interface.exit_routes[&source];
2800            let endpoint = route.last().unwrap();
2801            assert!((endpoint.x - port.x_offset).abs() < 1e-6);
2802            assert!((endpoint.y - hammock_composition.interface.height / 2.0).abs() < 1e-6);
2803        }
2804        assert!(
2805            compose_sese_region(
2806                &graph,
2807                &geometry,
2808                &LayoutSettings::default(),
2809                &tree,
2810                0,
2811                root,
2812            )
2813            .valid
2814        );
2815
2816        let layout = LayoutBuilder::new(&graph)
2817            .root(root)
2818            .mode(LayoutMode::Sese)
2819            .build()
2820            .unwrap();
2821        assert!(layout.regions.len() >= 2, "root and hammock debug bounds");
2822        let mut endpoints = HashMap::default();
2823        for edge in graph.edges() {
2824            endpoints.insert(edge.id(), (edge.from_id(), edge.to_id()));
2825        }
2826        assert_route_attaches_to_endpoints(&layout, &endpoints);
2827        assert_no_edge_through_nonincident_node(&layout, &endpoints);
2828    }
2829
2830    #[test]
2831    fn sese_parallel_boundary_edges_keep_flat_router_ports() {
2832        // More than the former 29-port cap: these must keep the ordinary flat
2833        // router's degree-aware fan, not be sent through a bespoke SESE lane
2834        // allocator that reuses port positions.
2835        let mut graph = G::default();
2836        let s = graph.make_node(());
2837        let a = graph.make_node(());
2838        let b = graph.make_node(());
2839        let c = graph.make_node(());
2840        let d = graph.make_node(());
2841        let t = graph.make_node(());
2842        let mut endpoints = HashMap::default();
2843        let record = |graph: &mut G, from, to, endpoints: &mut HashMap<E, (N, N)>| {
2844            let edge = graph.make_edge(from, to, ());
2845            endpoints.insert(edge, (from, to));
2846        };
2847        record(&mut graph, s, a, &mut endpoints);
2848        for _ in 0..35 {
2849            record(&mut graph, a, b, &mut endpoints);
2850        }
2851        record(&mut graph, a, c, &mut endpoints);
2852        record(&mut graph, b, d, &mut endpoints);
2853        record(&mut graph, c, d, &mut endpoints);
2854        record(&mut graph, d, t, &mut endpoints);
2855
2856        let result = LayoutBuilder::new(&graph)
2857            .root(s)
2858            .mode(LayoutMode::Sese)
2859            .build()
2860            .unwrap();
2861        assert_route_attaches_to_endpoints(&result, &endpoints);
2862        assert_no_edge_through_nonincident_node(&result, &endpoints);
2863        assert_no_horizontal_overlap(&result);
2864        assert_no_vertical_overlap(&result);
2865    }
2866
2867    #[test]
2868    fn sese_mode_falls_back_for_unstructured_component() {
2869        let mut graph = G::default();
2870        let root = graph.make_node(());
2871        let middle = graph.make_node(());
2872        let exit = graph.make_node(());
2873        graph.make_edge(root, middle, ());
2874        graph.make_edge(middle, exit, ());
2875        let flat = LayoutBuilder::new(&graph).root(root).build().unwrap();
2876        let sese = LayoutBuilder::new(&graph)
2877            .root(root)
2878            .mode(LayoutMode::Sese)
2879            .build()
2880            .unwrap();
2881        assert_eq!(signature(&flat), signature(&sese));
2882    }
2883
2884    #[test]
2885    fn disconnected_components_pack_without_overlap() {
2886        let mut g = G::default();
2887        let a = g.make_node(());
2888        let b = g.make_node(());
2889        let c = g.make_node(());
2890        let d = g.make_node(());
2891        g.make_edge(a, b, ());
2892        g.make_edge(c, d, ());
2893        for mode in [LayoutMode::Flat, LayoutMode::Sese] {
2894            let r = LayoutBuilder::new(&g).root(a).mode(mode).build().unwrap();
2895            assert_no_node_overlap(&r);
2896            // Two components: their x-extents must not interleave.
2897            let comp1_max = r.get_node(a).unwrap().x.max(r.get_node(b).unwrap().x);
2898            let comp2_min = r.get_node(c).unwrap().x.min(r.get_node(d).unwrap().x);
2899            assert!(comp2_min > comp1_max, "components overlap horizontally");
2900        }
2901    }
2902
2903    #[test]
2904    fn variable_width_nodes_do_not_overlap() {
2905        let mut g = G::default();
2906        let a = g.make_node(());
2907        let wide = g.make_node(());
2908        let narrow = g.make_node(());
2909        let d = g.make_node(());
2910        g.make_edge(a, wide, ());
2911        g.make_edge(a, narrow, ());
2912        g.make_edge(wide, d, ());
2913        g.make_edge(narrow, d, ());
2914        let r = LayoutBuilder::new(&g)
2915            .root(a)
2916            .geometry(move |id| {
2917                let w = if id == wide { 200.0 } else { 40.0 };
2918                NodeGeometry {
2919                    width: w,
2920                    height: 30.0,
2921                }
2922            })
2923            .build()
2924            .unwrap();
2925        assert_no_node_overlap(&r);
2926        assert_no_edge_through_node(&r);
2927    }
2928
2929    #[test]
2930    fn segment_chain_preserves_order_beside_wide_node_regression() {
2931        // Regression for a p/q segment whose endpoints were assigned different
2932        // x coordinates, making the router bridge through node 3.
2933        let mut g = G::default();
2934        let n: Vec<_> = (0..4).map(|_| g.make_node(())).collect();
2935        g.make_edge(n[0], n[2], ());
2936        g.make_edge(n[1], n[0], ());
2937        let edge = g.make_edge(n[1], n[2], ());
2938        g.make_edge(n[2], n[0], ());
2939        g.make_edge(n[2], n[3], ());
2940        g.make_edge(n[3], n[1], ());
2941        let r = LayoutBuilder::new(&g)
2942            .root(n[0])
2943            .geometry(|id| NodeGeometry {
2944                width: if usize::from(id) % 3 == 0 {
2945                    120.0
2946                } else {
2947                    40.0
2948                },
2949                height: 30.0,
2950            })
2951            .build()
2952            .unwrap();
2953        assert!(r.get_waypoints(edge).unwrap().len() >= 2);
2954        assert_no_edge_through_node(&r);
2955        assert_no_node_overlap(&r);
2956    }
2957
2958    #[test]
2959    fn long_edge_segment_avoids_wide_intermediate_node() {
2960        // Chain a..e over 5 ranks plus a long edge a->e spanning all of them.
2961        // Its q-vertex/segment passes through rank 2 where `c` is very wide; the
2962        // segment lane must route clear of c (the container-reserves-width
2963        // guarantee the old x-assignment violated).
2964        let mut g = G::default();
2965        let a = g.make_node(());
2966        let b = g.make_node(());
2967        let c = g.make_node(());
2968        let d = g.make_node(());
2969        let e = g.make_node(());
2970        g.make_edge(a, b, ());
2971        g.make_edge(b, c, ());
2972        g.make_edge(c, d, ());
2973        g.make_edge(d, e, ());
2974        g.make_edge(a, e, ());
2975        let r = LayoutBuilder::new(&g)
2976            .root(a)
2977            .geometry(move |id| {
2978                let w = if id == c { 220.0 } else { 40.0 };
2979                NodeGeometry {
2980                    width: w,
2981                    height: 30.0,
2982                }
2983            })
2984            .build()
2985            .unwrap();
2986        assert_no_node_overlap(&r);
2987        assert_no_edge_through_node(&r);
2988    }
2989
2990    #[test]
2991    fn root_sits_above_its_back_edge_predecessor() {
2992        // A 2-cycle where the entry is NOT the min-id node: pred(0) -> entry(1)
2993        // and entry(1) -> pred(0). Cycle-breaking rooted at `entry` must treat
2994        // entry -> pred as forward and pred -> entry as the back-edge, so the
2995        // entry lands on top — even though `pred` has the smaller id and the DFS
2996        // would otherwise have started there and floated it above the entry.
2997        let mut g = G::default();
2998        let pred = g.make_node(());
2999        let entry = g.make_node(());
3000        g.make_edge(pred, entry, ());
3001        g.make_edge(entry, pred, ());
3002        let r = layout(&g, entry);
3003        let ey = r.get_node(entry).unwrap().y;
3004        let py = r.get_node(pred).unwrap().y;
3005        assert!(
3006            ey < py,
3007            "entry (root) should sit above its back-edge predecessor: entry.y={ey} pred.y={py}"
3008        );
3009    }
3010
3011    #[test]
3012    fn root_tops_loop_when_entry_has_higher_id() {
3013        // entry(2) -> a(0) -> b(1) -> entry  : the latch b -> entry is the loop
3014        // back-edge. With id-order cycle breaking the DFS would start at a(0) and
3015        // misclassify, pushing a real block above the entry. Rooted at the entry,
3016        // the entry is the unique top real node.
3017        let mut g = G::default();
3018        let a = g.make_node(());
3019        let b = g.make_node(());
3020        let entry = g.make_node(());
3021        g.make_edge(entry, a, ());
3022        g.make_edge(a, b, ());
3023        g.make_edge(b, entry, ());
3024        let r = layout(&g, entry);
3025        let ey = r.get_node(entry).unwrap().y;
3026        for (id, n) in &r.nodes {
3027            if *id != entry {
3028                assert!(
3029                    ey <= n.y,
3030                    "entry must be the top real node: entry.y={ey} node{}.y={}",
3031                    usize::from(*id),
3032                    n.y
3033                );
3034            }
3035        }
3036    }
3037
3038    #[test]
3039    fn empty_graph_errors() {
3040        let g = G::default();
3041        let err = LayoutBuilder::new(&g).geometry(geom()).build().unwrap_err();
3042        assert_eq!(err, LayoutError::EmptyGraph);
3043    }
3044}