render_regex 0.0.0

SVG visualization of regex DFAs.
Documentation
// src/render/svg.rs

use svg::node::element::Text;
use svg::node::element::{Circle, Path, Definitions, Marker, Title, Description};
use svg::node::Text as TextNode;
use svg::Document;
use crate::intent::{RenderIntentGraph, CurveType};
use crate::graph::ShapeHint;
use crate::layout::profile::{RenderProfile, ArrowStyle};
use svg::Node;

/// Compute the point on the boundary of a given shape where
/// an edge should connect.
pub fn contact_point(
    from: (f64, f64),
    to: (f64, f64),
    shape: ShapeHint,
    center: (f64, f64),
    size: f64,
) -> (f64, f64) {
    let (cx, cy) = center;
    let (dx, dy) = (to.0 - from.0, to.1 - from.1);
    let len = (dx * dx + dy * dy).sqrt().max(1e-6);
    let (ux, uy) = (dx / len, dy / len);

    match shape {
        ShapeHint::Circle => (cx - ux * size, cy - uy * size),
        ShapeHint::Box => {
            let half = size;
            let scale = 1.0 / (ux.abs().max(uy.abs()));
            (cx - ux * scale * half, cy - uy * scale * half)
        }
        ShapeHint::Diamond => {
            let half = size;
            let diag_len = (2.0f64).sqrt();
            let scale = 1.0 / ((ux.abs() + uy.abs()) / diag_len);
            (cx - ux * scale * half, cy - uy * scale * half)
        }
    }
}

/// Render the intent graph to an SVG `Document`, honoring metadata
/// and arrow/overlay settings from `profile`.
pub fn to_svg(intent: &RenderIntentGraph, profile: &RenderProfile) -> Document {
    // 1) Compute bounding box
    let mut min_x = f64::MAX;
    let mut min_y = f64::MAX;
    let mut max_x = f64::MIN;
    let mut max_y = f64::MIN;
    for node in &intent.nodes {
        let (x, y) = node.center;
        let r = node.radius;
        min_x = min_x.min(x - r);
        max_x = max_x.max(x + r);
        min_y = min_y.min(y - r);
        max_y = max_y.max(y + r);
    }
    let padding = 10.0;
    let view_x = min_x - padding;
    let view_y = min_y - padding;
    let view_w = (max_x - min_x) + 2.0 * padding;
    let view_h = (max_y - min_y) + 2.0 * padding;

    // 2) Base <svg>
    let mut doc = Document::new()
        .set("viewBox", (view_x, view_y, view_w, view_h))
        .set("xmlns", "http://www.w3.org/2000/svg");

    // 3) Metadata
    if let Some(title) = &profile.title {
        doc = doc.add(Title::new().add(TextNode::new(title.clone())));
    }
    if let Some(tool_name) = &profile.tool {
        doc = doc.add(Description::new().add(TextNode::new(tool_name.clone())));
    }

    // 4) <defs> and marker
    let mut defs = Definitions::new();
    if !profile.no_arrows {
        let mut arrow = Marker::new()
            .set("id", "arrow")
            .set("viewBox", "0 0 10 10")
            .set("refX", 10)
            .set("refY", 5)
            .set("markerWidth", profile.arrow_size)
            .set("markerHeight", profile.arrow_size)
            .set("orient", "auto-start-reverse");

        match profile.arrow_style {
            ArrowStyle::Triangle => {
                arrow.append(
                    Path::new()
                        .set("d", "M 0 0 L 10 5 L 0 10 z")
                        .set("fill", "black"),
                );
            }
            ArrowStyle::Dot => {
                arrow = Marker::new()
                    .set("id", "arrow")
                    .set("viewBox", "0 0 10 10")
                    .set("refX", profile.arrow_size / 2.0)
                    .set("refY", profile.arrow_size / 2.0)
                    .set("markerWidth", profile.arrow_size)
                    .set("markerHeight", profile.arrow_size)
                    .set("orient", "auto-start-reverse");
                arrow.append(
                    Circle::new()
                        .set("cx", 0)
                        .set("cy", 0)
                        .set("r", profile.arrow_size / 2.0)
                        .set("fill", "black"),
                );
            }
        }
        defs = defs.add(arrow);
    }
    doc = doc.add(defs);

    // 5) Draw nodes
    for node in &intent.nodes {
        // Base circle
        let base = Circle::new()
            .set("cx", node.center.0)
            .set("cy", node.center.1)
            .set("r", node.radius)
            .set("stroke", node.stroke_color.clone())
            .set("stroke-width", node.stroke_width)
            .set("fill", node.fill_color.clone());
        doc = doc.add(base);

        // Outer ring for accept states
        if node.is_accept {
            let outer = Circle::new()
                .set("cx", node.center.0)
                .set("cy", node.center.1)
                .set("r", node.radius + (node.stroke_width * 2.0))
                .set("stroke", node.stroke_color.clone())
                .set("stroke-width", node.stroke_width)
                .set("fill", "none");
            doc = doc.add(outer);
        }

	// ── West-facing arrow for the start node ──
	if node.is_start {
	    // length of the incoming arrow
	    let arrow_len = 30.0;
	    // if this is an accept node, account for the extra outer ring
	    let extra = if node.is_accept { node.stroke_width * 2.0 } else { 0.0 };
	    // compute tail (x_start) and tip (x_end) of the arrow
	    let x_end = node.center.0 - node.radius - extra;
	    let x_start = x_end - arrow_len;
	    let y = node.center.1;

	    let arrow_path = Path::new()
                .set("d", format!("M {} {} L {} {}", x_start, y, x_end, y))
                .set("stroke", node.stroke_color.clone())
                .set("stroke-width", profile.stroke_width)
                .set("fill", "none")
                .set("marker-end", "url(#arrow)");
	    doc = doc.add(arrow_path);
	}

        // Node label (unchanged)
        let label = Text::new()
            .set("x", node.center.0 + node.label_offset.0)
            .set("y", node.center.1 + node.label_offset.1)
            .set("font-size", node.font_size)
            .set("font-family", "monospace")
            .set("fill", node.font_color.clone())
            .set("stroke", "white")
            .set("stroke-width", node.stroke_width)
            .set("paint-order", "stroke fill")
            .set("text-anchor", "middle")
            .add(TextNode::new(node.label.clone()));
        doc = doc.add(label);
    }

    // 6) Draw edges
    for edge in &intent.edges {
        println!("EDGE {} -> {} has curve: {:?}", edge.from, edge.to, edge.curve);

        let from_node = &intent.nodes[edge.from];
        let to_node   = &intent.nodes[edge.to];

        // Endpoint contact points
        let from_r = if from_node.is_accept {
            from_node.radius + (from_node.stroke_width * 2.0)
        } else {
            from_node.radius
        };
        let to_r = if to_node.is_accept {
            to_node.radius + (to_node.stroke_width * 2.0)
        } else {
            to_node.radius
        };
        let (x1, y1) = contact_point(
            to_node.center,
            from_node.center,
            from_node.shape,
            from_node.center,
            from_r,
        );
        let (x2, y2) = contact_point(
            from_node.center,
            to_node.center,
            to_node.shape,
            to_node.center,
            to_r,
        );

        // Control point for bends
        let dx = x2 - x1;
        let dy = y2 - y1;
        let len = (dx * dx + dy * dy).sqrt().max(1.0);
        let (nx, ny) = (dx / len, dy / len);
        let (px, py) = (-ny, nx);
        let cx = (x1 + x2) / 2.0 + px * (edge.bend_offset as f64) * 40.0;
        let cy = (y1 + y2) / 2.0 + py * (edge.bend_offset as f64) * 40.0;

        // Path data
        let data = match &edge.curve {
            CurveType::Straight => format!("M {} {} L {} {}", x1, y1, x2, y2),
            CurveType::Cubic { control1, control2 } => {
                format!(
                    "M {} {} C {} {}, {} {}, {} {}",
                    x1, y1,
                    control1.0, control1.1,
                    control2.0, control2.1,
                    x2, y2
                )
            }
            CurveType::Loop { .. } => {
                // Smooth self-loop:
                //  • Endpoints on node perimeter at South ±45°
                //  • Single cubic from one to the other, controls pushed outward
                let (cxn, cyn) = from_node.center;
                let node_r = from_node.radius as f64;
                // Cardinal direction (south=270° by default)
                let dir = (profile.loop_angle as f64).to_radians();
                // Half-span of arc (45°)
                let half_span = (45.0_f64).to_radians();

                // Compute endpoint angles and positions
                let a1 = dir - half_span;
                let a2 = dir + half_span;
                let x1s = cxn + a1.cos() * node_r;
                let y1s = cyn + a1.sin() * node_r;
                let x2s = cxn + a2.cos() * node_r;
                let y2s = cyn + a2.sin() * node_r;

                // How far out to push control points
                let ext = profile.loop_radius as f64 * 2.0;
                let cx1 = x1s + dir.cos() * ext;
                let cy1 = y1s + dir.sin() * ext;
                let cx2 = x2s + dir.cos() * ext;
                let cy2 = y2s + dir.sin() * ext;

                // One smooth cubic segment
                format!(
                    "M {x1s:.2} {y1s:.2} C {cx1:.2} {cy1:.2}, {cx2:.2} {cy2:.2}, {x2s:.2} {y2s:.2}",
                    x1s = x1s, y1s = y1s,
                    cx1 = cx1, cy1 = cy1,
                    cx2 = cx2, cy2 = cy2,
                    x2s = x2s, y2s = y2s
                )
            }
            // Quadratic and Arc removed per new classification
        };

        let mut path_elem = Path::new()
            .set("d", data)
            .set("stroke", edge.stroke_color.as_str())
            .set("fill", "none")
	    .set("stroke-width", profile.stroke_width);

        if !profile.no_arrows {
            path_elem = path_elem.set("marker-end", "url(#arrow)");
        }

        // Edge label
        let label = Text::new()
            .set("x", edge.label_pos.0)
            .set("y", edge.label_pos.1)
            .set("font-size", edge.label_font_size)
            .set("font-family", "monospace")
            .set("fill", edge.label_font_color.as_str())
            .set("stroke", "white")
            .set("stroke-width", 3)
            .set("paint-order", "stroke fill")
            .set("text-anchor", "middle")
            .add(TextNode::new(&edge.label));

        doc = doc.add(path_elem).add(label);

        // Debug overlay
        if profile.show_debug_overlay {
            let debug_label = Text::new()
                .set("x", cx)
                .set("y", cy - 10.0)
                .set("font-size", 8)
                .set("font-family", "monospace")
                .set("fill", "red")
                .set("text-anchor", "middle")
                .add(TextNode::new(format!("{:.2}", edge.bend_offset)));
            doc = doc.add(debug_label);
        }
    }

    doc
}