merman-render 0.8.0

Headless layout + SVG renderer for Mermaid (parity-focused; upstream SVG goldens).
Documentation
use super::super::working::WorkingLayout;
use super::LayoutWorkBudget;
use super::geometry::{
    ThreeSegmentRouteKind, classify_three_segment_route, collect_real_node_bounds,
    dedupe_consecutive_points, get_node_pair_geometry, same_point, segment_conflicts_with_any_edge,
    segment_hits_any_rect,
};
use crate::Result;
use crate::model::LayoutPoint;
use crate::swimlane::config::EPSILON;

const MINIMUM_PORT_SPACING: f64 = 8.0;
const PORT_SHIFT: f64 = MINIMUM_PORT_SPACING;
const TRY_DELTAS: [f64; 5] = [
    0.0,
    PORT_SHIFT,
    -PORT_SHIFT,
    2.0 * PORT_SHIFT,
    -2.0 * PORT_SHIFT,
];

pub(super) fn port_swap_to_l_shape(
    layout: &mut WorkingLayout,
    work_budget: &mut LayoutWorkBudget,
) -> Result<()> {
    work_budget.charge(layout.nodes.len())?;
    let (node_info_by_id, real_node_rects) = collect_real_node_bounds(layout);

    work_budget.charge(layout.original_edges.len())?;
    for edge_index in 0..layout.original_edges.len() {
        let edge = layout.original_edges[edge_index].clone();
        if edge.points.len() < 4 {
            continue;
        }

        let deduped = dedupe_consecutive_points(&edge.points, EPSILON);
        let Some(route) = classify_three_segment_route(&deduped, EPSILON) else {
            continue;
        };
        let Some(node_pair) = get_node_pair_geometry(&edge, &node_info_by_id, EPSILON) else {
            continue;
        };
        if node_pair.collinear_x || node_pair.collinear_y {
            continue;
        }

        let is_hvh = route.kind == ThreeSegmentRouteKind::Hvh;
        let mut replacement = None;
        for delta in TRY_DELTAS {
            let (first, corner, destination) = if is_hvh {
                let destination_below = node_pair.dst_info.cy > node_pair.src_info.cy;
                let source_y = if destination_below {
                    node_pair.src_info.rect.bottom
                } else {
                    node_pair.src_info.rect.top
                };
                let source_x = node_pair.src_info.cx + delta;
                if source_x <= node_pair.src_info.rect.left + EPSILON
                    || source_x >= node_pair.src_info.rect.right - EPSILON
                {
                    continue;
                }
                (
                    LayoutPoint {
                        x: source_x,
                        y: source_y,
                    },
                    LayoutPoint {
                        x: source_x,
                        y: route.p3.y,
                    },
                    route.p3.clone(),
                )
            } else {
                let destination_east = node_pair.dst_info.cx > node_pair.src_info.cx;
                let source_x = if destination_east {
                    node_pair.src_info.rect.right
                } else {
                    node_pair.src_info.rect.left
                };
                let source_y = node_pair.src_info.cy + delta;
                if source_y <= node_pair.src_info.rect.top + EPSILON
                    || source_y >= node_pair.src_info.rect.bottom - EPSILON
                {
                    continue;
                }
                (
                    LayoutPoint {
                        x: source_x,
                        y: source_y,
                    },
                    LayoutPoint {
                        x: route.p3.x,
                        y: source_y,
                    },
                    route.p3.clone(),
                )
            };

            let first_segment_degenerate = same_point(&first, &corner, EPSILON);
            let second_segment_degenerate = same_point(&corner, &destination, EPSILON);
            if first_segment_degenerate && second_segment_degenerate {
                continue;
            }

            work_budget.charge(1)?;
            if !first_segment_degenerate {
                work_budget.charge(real_node_rects.len())?;
                if segment_hits_any_rect(
                    &first,
                    &corner,
                    &real_node_rects,
                    &[node_pair.src_id.as_str()],
                    1.0,
                ) {
                    continue;
                }
            }
            if !second_segment_degenerate {
                work_budget.charge(real_node_rects.len())?;
                if segment_hits_any_rect(
                    &corner,
                    &destination,
                    &real_node_rects,
                    &[node_pair.dst_id.as_str()],
                    1.0,
                ) {
                    continue;
                }
            }

            let first_segment_conflicts = if first_segment_degenerate {
                false
            } else {
                work_budget.charge(layout.original_edges.len())?;
                segment_conflicts_with_any_edge(
                    &first,
                    &corner,
                    &layout.original_edges,
                    Some(edge.id.as_str()),
                    EPSILON,
                    true,
                )
            };
            let second_segment_conflicts = if second_segment_degenerate {
                false
            } else {
                work_budget.charge(layout.original_edges.len())?;
                segment_conflicts_with_any_edge(
                    &corner,
                    &destination,
                    &layout.original_edges,
                    Some(edge.id.as_str()),
                    EPSILON,
                    true,
                )
            };
            if first_segment_conflicts || second_segment_conflicts {
                continue;
            }

            replacement = Some(if first_segment_degenerate {
                vec![corner, destination]
            } else if second_segment_degenerate {
                vec![first, corner]
            } else {
                vec![first, corner, destination]
            });
            break;
        }

        if let Some(points) = replacement {
            layout.original_edges[edge_index].points = points;
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::super::super::working::{WorkingEdge, WorkingNode, WorkingNodeKind};
    use super::*;
    use crate::model::SwimlaneDirection;
    use indexmap::IndexMap;

    fn point(x: f64, y: f64) -> LayoutPoint {
        LayoutPoint { x, y }
    }

    fn node(id: &str, x: f64, y: f64, width: f64, height: f64) -> WorkingNode {
        WorkingNode {
            id: id.to_string(),
            label: id.to_string(),
            label_type: "text".to_string(),
            shape: "rect".to_string(),
            kind: WorkingNodeKind::Content,
            parent_id: None,
            top_lane_id: None,
            requested_dir: None,
            padding: 0.0,
            x,
            y,
            width,
            height,
            label_width: width,
            label_height: height,
            layer: 0,
            order: 0,
            content_top: None,
            title_rect: None,
        }
    }

    fn edge(points: Vec<LayoutPoint>) -> WorkingEdge {
        WorkingEdge {
            id: "A_B".to_string(),
            from: "A".to_string(),
            to: "B".to_string(),
            reference_id: "A_B".to_string(),
            label_node_id: None,
            reversed_for_layout: false,
            points,
        }
    }

    fn layout(nodes: Vec<WorkingNode>, edge: WorkingEdge) -> WorkingLayout {
        WorkingLayout {
            direction: SwimlaneDirection::Tb,
            nodes: nodes
                .into_iter()
                .map(|node| (node.id.clone(), node))
                .collect::<IndexMap<_, _>>(),
            graph_edges: Vec::new(),
            original_edges: vec![edge],
            top_lane_order: Vec::new(),
        }
    }

    fn detour() -> Vec<LayoutPoint> {
        vec![
            point(20.0, 0.0),
            point(60.0, 0.0),
            point(60.0, 80.0),
            point(100.0, 80.0),
        ]
    }

    fn assert_points(actual: &[LayoutPoint], expected: &[(f64, f64)]) {
        assert_eq!(actual.len(), expected.len());
        for (actual, expected) in actual.iter().zip(expected) {
            assert!((actual.x - expected.0).abs() < EPSILON);
            assert!((actual.y - expected.1).abs() < EPSILON);
        }
    }

    #[test]
    fn rewrites_a_non_collinear_hvh_detour_to_an_l_shape() {
        let mut layout = layout(
            vec![
                node("A", 0.0, 0.0, 40.0, 40.0),
                node("B", 100.0, 100.0, 40.0, 40.0),
            ],
            edge(detour()),
        );

        port_swap_to_l_shape(&mut layout, &mut LayoutWorkBudget::unbounded_for_tests()).unwrap();

        assert_points(
            &layout.original_edges[0].points,
            &[(0.0, 20.0), (0.0, 80.0), (100.0, 80.0)],
        );
    }

    #[test]
    fn rejects_the_swap_when_every_candidate_source_segment_hits_a_node() {
        let original = detour();
        let mut layout = layout(
            vec![
                node("A", 0.0, 0.0, 40.0, 40.0),
                node("B", 100.0, 100.0, 40.0, 40.0),
                node("blocker", 0.0, 50.0, 40.0, 20.0),
            ],
            edge(original.clone()),
        );

        port_swap_to_l_shape(&mut layout, &mut LayoutWorkBudget::unbounded_for_tests()).unwrap();

        assert_points(
            &layout.original_edges[0].points,
            &original
                .iter()
                .map(|point| (point.x, point.y))
                .collect::<Vec<_>>(),
        );
    }
}