Skip to main content

merman_render/
class.rs

1use crate::entities::decode_entities_minimal;
2use crate::model::{
3    Bounds, ClassDiagramV2Layout, ClassNodeRowMetrics, LayoutCluster, LayoutEdge, LayoutLabel,
4    LayoutNode, LayoutPoint,
5};
6use crate::text::{TextMeasurer, TextStyle, WrapMode};
7use crate::{Error, Result};
8use dugong::graphlib::{Graph, GraphOptions};
9use dugong::{EdgeLabel, GraphLabel, LabelPos, NodeLabel, RankDir};
10use indexmap::IndexMap;
11use rustc_hash::FxHashMap;
12use serde_json::Value;
13use std::collections::{BTreeMap, HashMap, HashSet};
14use std::sync::Arc;
15
16type ClassDiagramModel = merman_core::models::class_diagram::ClassDiagram;
17type ClassNode = merman_core::models::class_diagram::ClassNode;
18
19fn json_f64(v: &Value) -> Option<f64> {
20    v.as_f64()
21        .or_else(|| v.as_i64().map(|n| n as f64))
22        .or_else(|| v.as_u64().map(|n| n as f64))
23}
24
25fn config_f64(cfg: &Value, path: &[&str]) -> Option<f64> {
26    let mut cur = cfg;
27    for key in path {
28        cur = cur.get(*key)?;
29    }
30    json_f64(cur)
31}
32
33fn config_bool(cfg: &Value, path: &[&str]) -> Option<bool> {
34    let mut cur = cfg;
35    for key in path {
36        cur = cur.get(*key)?;
37    }
38    cur.as_bool()
39}
40
41fn config_string(cfg: &Value, path: &[&str]) -> Option<String> {
42    let mut cur = cfg;
43    for key in path {
44        cur = cur.get(*key)?;
45    }
46    cur.as_str().map(|s| s.to_string())
47}
48
49fn normalize_dir(direction: &str) -> String {
50    match direction.trim().to_uppercase().as_str() {
51        "TB" | "TD" => "TB".to_string(),
52        "BT" => "BT".to_string(),
53        "LR" => "LR".to_string(),
54        "RL" => "RL".to_string(),
55        other => other.to_string(),
56    }
57}
58
59fn rank_dir_from(direction: &str) -> RankDir {
60    match normalize_dir(direction).as_str() {
61        "TB" => RankDir::TB,
62        "BT" => RankDir::BT,
63        "LR" => RankDir::LR,
64        "RL" => RankDir::RL,
65        _ => RankDir::TB,
66    }
67}
68
69type Rect = merman_core::geom::Box2;
70
71struct PreparedGraph {
72    graph: Graph<NodeLabel, EdgeLabel, GraphLabel>,
73    extracted: BTreeMap<String, PreparedGraph>,
74    prefer_dagreish_disconnected: bool,
75}
76
77fn extract_descendants(
78    graph: &Graph<NodeLabel, EdgeLabel, GraphLabel>,
79    id: &str,
80    out: &mut Vec<String>,
81) {
82    for child in graph.children(id) {
83        out.push(child.to_string());
84        extract_descendants(graph, child, out);
85    }
86}
87
88fn is_descendant(descendants: &HashMap<String, HashSet<String>>, id: &str, ancestor: &str) -> bool {
89    descendants
90        .get(ancestor)
91        .is_some_and(|set| set.contains(id))
92}
93
94fn prepare_graph(
95    mut graph: Graph<NodeLabel, EdgeLabel, GraphLabel>,
96    depth: usize,
97    prefer_dagreish_disconnected: bool,
98) -> Result<PreparedGraph> {
99    if depth > 10 {
100        return Ok(PreparedGraph {
101            graph,
102            extracted: BTreeMap::new(),
103            prefer_dagreish_disconnected,
104        });
105    }
106
107    // Mermaid's dagre-wrapper performs a pre-pass that extracts clusters *without* external
108    // connections into their own subgraphs, toggles their rankdir (TB <-> LR), and renders them
109    // recursively to obtain concrete cluster geometry before laying out the parent graph.
110    //
111    // Reference: Mermaid@11.12.2 `mermaid-graphlib.js` extractor + `recursiveRender`:
112    // - eligible cluster: has children, and no edge crosses its descendant boundary
113    // - extracted subgraph gets `rankdir = parent.rankdir === 'TB' ? 'LR' : 'TB'`
114    // - subgraph rank spacing uses `ranksep = parent.ranksep + 25`
115    // - margins are fixed at 8
116
117    let cluster_ids: Vec<String> = graph
118        .node_ids()
119        .into_iter()
120        .filter(|id| !graph.children(id).is_empty())
121        .collect();
122
123    let mut descendants: HashMap<String, HashSet<String>> = HashMap::new();
124    for id in &cluster_ids {
125        let mut vec: Vec<String> = Vec::new();
126        extract_descendants(&graph, id, &mut vec);
127        descendants.insert(id.clone(), vec.into_iter().collect());
128    }
129
130    let mut external: HashMap<String, bool> =
131        cluster_ids.iter().map(|id| (id.clone(), false)).collect();
132    for id in &cluster_ids {
133        for e in graph.edge_keys() {
134            // Mermaid's `edgeInCluster` treats edges incident on the cluster node itself as
135            // non-descendant edges. Class diagrams do not normally connect edges to namespaces,
136            // but keep the guard to mirror upstream behavior.
137            if e.v == *id || e.w == *id {
138                continue;
139            }
140            let d1 = is_descendant(&descendants, &e.v, id);
141            let d2 = is_descendant(&descendants, &e.w, id);
142            if d1 ^ d2 {
143                external.insert(id.clone(), true);
144                break;
145            }
146        }
147    }
148
149    let mut extracted: BTreeMap<String, PreparedGraph> = BTreeMap::new();
150    let candidate_clusters: Vec<String> = graph
151        .node_ids()
152        .into_iter()
153        .filter(|id| !graph.children(id).is_empty() && !external.get(id).copied().unwrap_or(false))
154        .collect();
155
156    for cluster_id in candidate_clusters {
157        if graph.children(&cluster_id).is_empty() {
158            continue;
159        }
160        let parent_dir = graph.graph().rankdir;
161        let dir = if parent_dir == RankDir::TB {
162            RankDir::LR
163        } else {
164            RankDir::TB
165        };
166
167        let nodesep = graph.graph().nodesep;
168        let ranksep = graph.graph().ranksep;
169
170        let mut subgraph = extract_cluster_graph(&cluster_id, &mut graph)?;
171        subgraph.graph_mut().rankdir = dir;
172        subgraph.graph_mut().nodesep = nodesep;
173        subgraph.graph_mut().ranksep = ranksep + 25.0;
174        subgraph.graph_mut().marginx = 8.0;
175        subgraph.graph_mut().marginy = 8.0;
176
177        let prepared = prepare_graph(subgraph, depth + 1, prefer_dagreish_disconnected)?;
178        extracted.insert(cluster_id, prepared);
179    }
180
181    Ok(PreparedGraph {
182        graph,
183        extracted,
184        prefer_dagreish_disconnected,
185    })
186}
187
188fn extract_cluster_graph(
189    cluster_id: &str,
190    graph: &mut Graph<NodeLabel, EdgeLabel, GraphLabel>,
191) -> Result<Graph<NodeLabel, EdgeLabel, GraphLabel>> {
192    if graph.children(cluster_id).is_empty() {
193        return Err(Error::InvalidModel {
194            message: format!("cluster has no children: {cluster_id}"),
195        });
196    }
197
198    let mut descendants: Vec<String> = Vec::new();
199    extract_descendants(graph, cluster_id, &mut descendants);
200    descendants.sort();
201    descendants.dedup();
202
203    let moved_set: HashSet<String> = descendants.iter().cloned().collect();
204
205    let mut sub = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
206        directed: true,
207        multigraph: true,
208        compound: true,
209    });
210
211    // Preserve parent graph settings as a base.
212    sub.set_graph(graph.graph().clone());
213
214    for id in &descendants {
215        let Some(label) = graph.node(id).cloned() else {
216            continue;
217        };
218        sub.set_node(id.clone(), label);
219    }
220
221    for key in graph.edge_keys() {
222        if moved_set.contains(&key.v) && moved_set.contains(&key.w) {
223            if let Some(label) = graph.edge_by_key(&key).cloned() {
224                sub.set_edge_named(key.v.clone(), key.w.clone(), key.name.clone(), Some(label));
225            }
226        }
227    }
228
229    for id in &descendants {
230        let Some(parent) = graph.parent(id) else {
231            continue;
232        };
233        if moved_set.contains(parent) {
234            sub.set_parent(id.clone(), parent.to_string());
235        }
236    }
237
238    for id in &descendants {
239        let _ = graph.remove_node(id);
240    }
241
242    Ok(sub)
243}
244
245#[derive(Debug, Clone)]
246struct EdgeTerminalMetrics {
247    start_left: Option<(f64, f64)>,
248    start_right: Option<(f64, f64)>,
249    end_left: Option<(f64, f64)>,
250    end_right: Option<(f64, f64)>,
251    start_marker: f64,
252    end_marker: f64,
253}
254
255fn edge_terminal_metrics_from_extras(e: &EdgeLabel) -> EdgeTerminalMetrics {
256    let get_pair = |key: &str| -> Option<(f64, f64)> {
257        let obj = e.extras.get(key)?;
258        let w = obj.get("width").and_then(|v| v.as_f64()).unwrap_or(0.0);
259        let h = obj.get("height").and_then(|v| v.as_f64()).unwrap_or(0.0);
260        if w > 0.0 && h > 0.0 {
261            Some((w, h))
262        } else {
263            None
264        }
265    };
266    let start_marker = e
267        .extras
268        .get("startMarker")
269        .and_then(|v| v.as_f64())
270        .unwrap_or(0.0);
271    let end_marker = e
272        .extras
273        .get("endMarker")
274        .and_then(|v| v.as_f64())
275        .unwrap_or(0.0);
276    EdgeTerminalMetrics {
277        start_left: get_pair("startLeft"),
278        start_right: get_pair("startRight"),
279        end_left: get_pair("endLeft"),
280        end_right: get_pair("endRight"),
281        start_marker,
282        end_marker,
283    }
284}
285
286#[derive(Debug, Clone)]
287struct LayoutFragments {
288    nodes: IndexMap<String, LayoutNode>,
289    edges: Vec<(LayoutEdge, Option<EdgeTerminalMetrics>)>,
290}
291
292fn round_number(num: f64, precision: i32) -> f64 {
293    if !num.is_finite() {
294        return 0.0;
295    }
296    let factor = 10_f64.powi(precision);
297    (num * factor).round() / factor
298}
299
300fn distance(a: &LayoutPoint, b: Option<&LayoutPoint>) -> f64 {
301    let Some(b) = b else {
302        return 0.0;
303    };
304    let dx = a.x - b.x;
305    let dy = a.y - b.y;
306    (dx * dx + dy * dy).sqrt()
307}
308
309fn calculate_point(points: &[LayoutPoint], distance_to_traverse: f64) -> Option<LayoutPoint> {
310    if points.is_empty() {
311        return None;
312    }
313    let mut prev: Option<&LayoutPoint> = None;
314    let mut remaining = distance_to_traverse.max(0.0);
315    for p in points {
316        if let Some(prev_p) = prev {
317            let vector_distance = distance(p, Some(prev_p));
318            if vector_distance == 0.0 {
319                return Some(prev_p.clone());
320            }
321            if vector_distance < remaining {
322                remaining -= vector_distance;
323            } else {
324                let ratio = remaining / vector_distance;
325                if ratio <= 0.0 {
326                    return Some(prev_p.clone());
327                }
328                if ratio >= 1.0 {
329                    return Some(p.clone());
330                }
331                return Some(LayoutPoint {
332                    x: round_number((1.0 - ratio) * prev_p.x + ratio * p.x, 5),
333                    y: round_number((1.0 - ratio) * prev_p.y + ratio * p.y, 5),
334                });
335            }
336        }
337        prev = Some(p);
338    }
339    None
340}
341
342#[derive(Debug, Clone, Copy)]
343enum TerminalPos {
344    StartLeft,
345    StartRight,
346    EndLeft,
347    EndRight,
348}
349
350fn point_inside_rect(rect: Rect, x: f64, y: f64, eps: f64) -> bool {
351    x > rect.min_x() + eps
352        && x < rect.max_x() - eps
353        && y > rect.min_y() + eps
354        && y < rect.max_y() - eps
355}
356
357fn nudge_point_outside_rect(mut x: f64, mut y: f64, rect: Rect) -> (f64, f64) {
358    let eps = 0.01;
359    if !point_inside_rect(rect, x, y, eps) {
360        return (x, y);
361    }
362
363    let (cx, cy) = rect.center();
364    let mut dx = x - cx;
365    let mut dy = y - cy;
366    let len = (dx * dx + dy * dy).sqrt();
367    if len < 1e-9 {
368        dx = 1.0;
369        dy = 0.0;
370    } else {
371        dx /= len;
372        dy /= len;
373    }
374
375    let mut t_exit = f64::INFINITY;
376    if dx > 1e-9 {
377        t_exit = t_exit.min((rect.max_x() - x) / dx);
378    } else if dx < -1e-9 {
379        t_exit = t_exit.min((rect.min_x() - x) / dx);
380    }
381    if dy > 1e-9 {
382        t_exit = t_exit.min((rect.max_y() - y) / dy);
383    } else if dy < -1e-9 {
384        t_exit = t_exit.min((rect.min_y() - y) / dy);
385    }
386
387    if t_exit.is_finite() && t_exit >= 0.0 {
388        let margin = 0.5;
389        x += dx * (t_exit + margin);
390        y += dy * (t_exit + margin);
391    }
392
393    (x, y)
394}
395
396fn calc_terminal_label_position(
397    terminal_marker_size: f64,
398    position: TerminalPos,
399    points: &[LayoutPoint],
400) -> Option<(f64, f64)> {
401    if points.len() < 2 {
402        return None;
403    }
404
405    let mut pts = points.to_vec();
406    match position {
407        TerminalPos::StartLeft | TerminalPos::StartRight => {}
408        TerminalPos::EndLeft | TerminalPos::EndRight => pts.reverse(),
409    }
410
411    let distance_to_cardinality_point = 25.0 + terminal_marker_size;
412    let center = calculate_point(&pts, distance_to_cardinality_point)?;
413    let d = 10.0 + terminal_marker_size * 0.5;
414    let angle = (pts[0].y - center.y).atan2(pts[0].x - center.x);
415
416    let (x, y) = match position {
417        TerminalPos::StartLeft => {
418            let a = angle + std::f64::consts::PI;
419            (
420                a.sin() * d + (pts[0].x + center.x) / 2.0,
421                -a.cos() * d + (pts[0].y + center.y) / 2.0,
422            )
423        }
424        TerminalPos::StartRight => (
425            angle.sin() * d + (pts[0].x + center.x) / 2.0,
426            -angle.cos() * d + (pts[0].y + center.y) / 2.0,
427        ),
428        TerminalPos::EndLeft => (
429            angle.sin() * d + (pts[0].x + center.x) / 2.0 - 5.0,
430            -angle.cos() * d + (pts[0].y + center.y) / 2.0 - 5.0,
431        ),
432        TerminalPos::EndRight => {
433            let a = angle - std::f64::consts::PI;
434            (
435                a.sin() * d + (pts[0].x + center.x) / 2.0 - 5.0,
436                -a.cos() * d + (pts[0].y + center.y) / 2.0 - 5.0,
437            )
438        }
439    };
440    Some((x, y))
441}
442
443fn intersect_segment_with_rect(
444    p0: &LayoutPoint,
445    p1: &LayoutPoint,
446    rect: Rect,
447) -> Option<LayoutPoint> {
448    let dx = p1.x - p0.x;
449    let dy = p1.y - p0.y;
450    if dx == 0.0 && dy == 0.0 {
451        return None;
452    }
453
454    let mut candidates: Vec<(f64, LayoutPoint)> = Vec::new();
455    let eps = 1e-9;
456    let min_x = rect.min_x();
457    let max_x = rect.max_x();
458    let min_y = rect.min_y();
459    let max_y = rect.max_y();
460
461    if dx.abs() > eps {
462        for x_edge in [min_x, max_x] {
463            let t = (x_edge - p0.x) / dx;
464            if t < -eps || t > 1.0 + eps {
465                continue;
466            }
467            let y = p0.y + t * dy;
468            if y + eps >= min_y && y <= max_y + eps {
469                candidates.push((t, LayoutPoint { x: x_edge, y }));
470            }
471        }
472    }
473
474    if dy.abs() > eps {
475        for y_edge in [min_y, max_y] {
476            let t = (y_edge - p0.y) / dy;
477            if t < -eps || t > 1.0 + eps {
478                continue;
479            }
480            let x = p0.x + t * dx;
481            if x + eps >= min_x && x <= max_x + eps {
482                candidates.push((t, LayoutPoint { x, y: y_edge }));
483            }
484        }
485    }
486
487    candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
488    candidates
489        .into_iter()
490        .find(|(t, _)| *t >= 0.0)
491        .map(|(_, p)| p)
492}
493
494fn terminal_path_for_edge(
495    points: &[LayoutPoint],
496    from_rect: Rect,
497    to_rect: Rect,
498) -> Vec<LayoutPoint> {
499    if points.len() < 2 {
500        return points.to_vec();
501    }
502    let mut out = points.to_vec();
503
504    if let Some(p) = intersect_segment_with_rect(&out[0], &out[1], from_rect) {
505        out[0] = p;
506    }
507    let last = out.len() - 1;
508    if let Some(p) = intersect_segment_with_rect(&out[last], &out[last - 1], to_rect) {
509        out[last] = p;
510    }
511
512    out
513}
514
515fn layout_prepared(
516    prepared: &mut PreparedGraph,
517    node_label_metrics_by_id: &HashMap<String, (f64, f64)>,
518) -> Result<(LayoutFragments, Rect)> {
519    let mut fragments = LayoutFragments {
520        nodes: IndexMap::new(),
521        edges: Vec::new(),
522    };
523
524    let extracted_ids: Vec<String> = prepared.extracted.keys().cloned().collect();
525    let mut extracted_fragments: BTreeMap<String, (LayoutFragments, Rect)> = BTreeMap::new();
526    for id in extracted_ids {
527        let sub = prepared.extracted.get_mut(&id).expect("exists");
528        let (sub_frag, sub_bounds) = layout_prepared(sub, node_label_metrics_by_id)?;
529
530        // Mermaid sizes extracted cluster placeholders using the rendered SVG bbox of the
531        // recursively rendered cluster (`updateNodeBounds`). That bbox includes Dagre's implicit
532        // "cluster padding" around the laid out content. In Dagre, this padding is effectively
533        // `ranksep` (after `makeSpaceForEdgeLabels`), split across both sides.
534        //
535        // Our headless pipeline does not render extracted clusters as a separate SVG subtree, so
536        // inflate the computed bounds to match the padding that Mermaid's renderer bakes into the
537        // placeholder node size.
538        let pad = sub.graph.graph().ranksep.max(0.0);
539        let sub_bounds = Rect::from_min_max(
540            sub_bounds.min_x() - pad,
541            sub_bounds.min_y() - pad,
542            sub_bounds.max_x() + pad,
543            sub_bounds.max_y() + pad,
544        );
545
546        extracted_fragments.insert(id, (sub_frag, sub_bounds));
547    }
548
549    for (id, (_sub_frag, bounds)) in &extracted_fragments {
550        let Some(n) = prepared.graph.node_mut(id) else {
551            return Err(Error::InvalidModel {
552                message: format!("missing cluster placeholder node: {id}"),
553            });
554        };
555        n.width = bounds.width().max(1.0);
556        n.height = bounds.height().max(1.0);
557    }
558
559    // Mermaid's dagre wrapper always sets `compound: true`, and Dagre's ranker expects a connected
560    // graph. `dugong::layout_dagreish` mirrors Dagre's full pipeline (including `nestingGraph`)
561    // and should be used for class diagrams even when there are no explicit clusters.
562    dugong::layout_dagreish(&mut prepared.graph);
563
564    // Mermaid does not render Dagre's internal dummy nodes/edges (border nodes, edge label nodes,
565    // nesting artifacts). Filter them out before computing bounds and before merging extracted
566    // layouts back into the parent.
567    let mut dummy_nodes: HashSet<String> = HashSet::new();
568    for id in prepared.graph.node_ids() {
569        let Some(n) = prepared.graph.node(&id) else {
570            continue;
571        };
572        if n.dummy.is_some() {
573            dummy_nodes.insert(id);
574            continue;
575        }
576        let is_cluster =
577            !prepared.graph.children(&id).is_empty() || prepared.extracted.contains_key(&id);
578        let (label_width, label_height) = node_label_metrics_by_id
579            .get(id.as_str())
580            .copied()
581            .map(|(w, h)| (Some(w), Some(h)))
582            .unwrap_or((None, None));
583        fragments.nodes.insert(
584            id.clone(),
585            LayoutNode {
586                id: id.clone(),
587                x: n.x.unwrap_or(0.0),
588                y: n.y.unwrap_or(0.0),
589                width: n.width,
590                height: n.height,
591                is_cluster,
592                label_width,
593                label_height,
594            },
595        );
596    }
597
598    for key in prepared.graph.edge_keys() {
599        let Some(e) = prepared.graph.edge_by_key(&key) else {
600            continue;
601        };
602        if e.nesting_edge {
603            continue;
604        }
605        if dummy_nodes.contains(&key.v) || dummy_nodes.contains(&key.w) {
606            continue;
607        }
608        if !fragments.nodes.contains_key(&key.v) || !fragments.nodes.contains_key(&key.w) {
609            continue;
610        }
611        let id = key
612            .name
613            .clone()
614            .unwrap_or_else(|| format!("edge:{}:{}", key.v, key.w));
615
616        let label = if e.width > 0.0 && e.height > 0.0 {
617            Some(LayoutLabel {
618                x: e.x.unwrap_or(0.0),
619                y: e.y.unwrap_or(0.0),
620                width: e.width,
621                height: e.height,
622            })
623        } else {
624            None
625        };
626
627        let points = e
628            .points
629            .iter()
630            .map(|p| LayoutPoint { x: p.x, y: p.y })
631            .collect::<Vec<_>>();
632
633        let edge = LayoutEdge {
634            id,
635            from: key.v.clone(),
636            to: key.w.clone(),
637            from_cluster: None,
638            to_cluster: None,
639            points,
640            label,
641            start_label_left: None,
642            start_label_right: None,
643            end_label_left: None,
644            end_label_right: None,
645            start_marker: None,
646            end_marker: None,
647            stroke_dasharray: None,
648        };
649
650        let terminals = edge_terminal_metrics_from_extras(e);
651        let has_terminals = terminals.start_left.is_some()
652            || terminals.start_right.is_some()
653            || terminals.end_left.is_some()
654            || terminals.end_right.is_some();
655        let terminal_meta = if has_terminals { Some(terminals) } else { None };
656
657        fragments.edges.push((edge, terminal_meta));
658    }
659
660    for (cluster_id, (mut sub_frag, sub_bounds)) in extracted_fragments {
661        let Some(cluster_node) = fragments.nodes.get(&cluster_id).cloned() else {
662            return Err(Error::InvalidModel {
663                message: format!("missing cluster placeholder layout: {cluster_id}"),
664            });
665        };
666        let (sub_cx, sub_cy) = sub_bounds.center();
667        let dx = cluster_node.x - sub_cx;
668        let dy = cluster_node.y - sub_cy;
669
670        for n in sub_frag.nodes.values_mut() {
671            n.x += dx;
672            n.y += dy;
673        }
674        for (e, _t) in &mut sub_frag.edges {
675            for p in &mut e.points {
676                p.x += dx;
677                p.y += dy;
678            }
679            if let Some(l) = e.label.as_mut() {
680                l.x += dx;
681                l.y += dy;
682            }
683        }
684
685        // The extracted subgraph includes its own copy of the cluster root node so bounds match
686        // Mermaid's `updateNodeBounds(...)`. Do not merge that node back into the parent layout,
687        // otherwise we'd overwrite the placeholder position computed by the parent graph layout.
688        let _ = sub_frag.nodes.swap_remove(&cluster_id);
689
690        fragments.nodes.extend(sub_frag.nodes);
691        fragments.edges.extend(sub_frag.edges);
692    }
693
694    let mut points: Vec<(f64, f64)> = Vec::new();
695    for n in fragments.nodes.values() {
696        let r = Rect::from_center(n.x, n.y, n.width, n.height);
697        points.push((r.min_x(), r.min_y()));
698        points.push((r.max_x(), r.max_y()));
699    }
700    for (e, _t) in &fragments.edges {
701        for p in &e.points {
702            points.push((p.x, p.y));
703        }
704        if let Some(l) = &e.label {
705            let r = Rect::from_center(l.x, l.y, l.width, l.height);
706            points.push((r.min_x(), r.min_y()));
707            points.push((r.max_x(), r.max_y()));
708        }
709    }
710    let bounds = Bounds::from_points(points)
711        .map(|b| Rect::from_min_max(b.min_x, b.min_y, b.max_x, b.max_y))
712        .unwrap_or_else(|| Rect::from_min_max(0.0, 0.0, 0.0, 0.0));
713
714    Ok((fragments, bounds))
715}
716
717fn class_text_style(effective_config: &Value) -> TextStyle {
718    // Mermaid defaults to `"trebuchet ms", verdana, arial, sans-serif`. Class diagram labels are
719    // rendered via HTML `<foreignObject>` and inherit the global font family.
720    let font_family = config_string(effective_config, &["fontFamily"])
721        .or_else(|| config_string(effective_config, &["themeVariables", "fontFamily"]))
722        .or_else(|| Some("\"trebuchet ms\", verdana, arial, sans-serif".to_string()));
723    // Mermaid class diagram node labels inherit the global `fontSize` (via the root `#id{font-size}` rule)
724    // and render via HTML labels (`foreignObject`). Prefer the global value for sizing/layout parity.
725    let font_size = config_f64(effective_config, &["fontSize"])
726        .or_else(|| config_f64(effective_config, &["class", "fontSize"]))
727        .unwrap_or(16.0)
728        .max(1.0);
729    TextStyle {
730        font_family,
731        font_size,
732        font_weight: None,
733    }
734}
735
736fn class_box_dimensions(
737    node: &ClassNode,
738    measurer: &dyn TextMeasurer,
739    text_style: &TextStyle,
740    wrap_mode: WrapMode,
741    padding: f64,
742    hide_empty_members_box: bool,
743    capture_row_metrics: bool,
744) -> (f64, f64, Option<ClassNodeRowMetrics>) {
745    // Mermaid class nodes are sized by rendering the label groups (`textHelper(...)`) and taking
746    // the resulting SVG bbox (`getBBox()`), then expanding by class padding (see upstream:
747    // `rendering-elements/shapes/classBox.ts` + `diagrams/class/shapeUtil.ts`).
748    //
749    // Emulate that sizing logic deterministically using the same text measurer.
750    let use_html_labels = matches!(wrap_mode, WrapMode::HtmlLike);
751    let padding = padding.max(0.0);
752    let gap = padding;
753    let text_padding = if use_html_labels { 0.0 } else { 3.0 };
754
755    fn measure_label(
756        measurer: &dyn TextMeasurer,
757        text: &str,
758        style: &TextStyle,
759        wrap_mode: WrapMode,
760    ) -> crate::text::TextMetrics {
761        measurer.measure_wrapped(text, style, None, wrap_mode)
762    }
763
764    fn label_rect(m: crate::text::TextMetrics, y_offset: f64) -> Option<Rect> {
765        if !(m.width.is_finite() && m.height.is_finite()) {
766            return None;
767        }
768        let w = m.width.max(0.0);
769        let h = m.height.max(0.0);
770        if w <= 0.0 || h <= 0.0 {
771            return None;
772        }
773        let lines = m.line_count.max(1) as f64;
774        let y = y_offset - (h / (2.0 * lines));
775        Some(Rect::from_min_max(0.0, y, w, y + h))
776    }
777
778    let mut label_style_bold = text_style.clone();
779    label_style_bold.font_weight = Some("bolder".to_string());
780
781    // Annotation group: Mermaid only renders the first annotation.
782    let mut annotation_rect: Option<Rect> = None;
783    let mut annotation_group_height = 0.0;
784    if let Some(a) = node.annotations.first() {
785        let t = format!("\u{00AB}{}\u{00BB}", decode_entities_minimal(a.trim()));
786        let m = measure_label(measurer, &t, text_style, wrap_mode);
787        annotation_rect = label_rect(m, 0.0);
788        if let Some(r) = annotation_rect {
789            annotation_group_height = r.height().max(0.0);
790        }
791    }
792
793    // Title label group (bold).
794    let mut title_text = decode_entities_minimal(&node.text);
795    if !use_html_labels && title_text.starts_with('\\') {
796        title_text = title_text.trim_start_matches('\\').to_string();
797    }
798    let title_metrics = measure_label(measurer, &title_text, &label_style_bold, wrap_mode);
799    let title_rect = label_rect(title_metrics, 0.0);
800    let title_group_height = title_rect.map(|r| r.height()).unwrap_or(0.0);
801
802    // Members group.
803    let mut members_rect: Option<Rect> = None;
804    let mut members_metrics_out: Option<Vec<crate::text::TextMetrics>> =
805        capture_row_metrics.then(|| Vec::with_capacity(node.members.len()));
806    {
807        let mut y_offset = 0.0;
808        for m in &node.members {
809            let mut t = decode_entities_minimal(m.display_text.trim());
810            if !use_html_labels && t.starts_with('\\') {
811                t = t.trim_start_matches('\\').to_string();
812            }
813            let metrics = measure_label(measurer, &t, text_style, wrap_mode);
814            if let Some(out) = members_metrics_out.as_mut() {
815                out.push(metrics);
816            }
817            if let Some(r) = label_rect(metrics, y_offset) {
818                if let Some(ref mut cur) = members_rect {
819                    cur.union(r);
820                } else {
821                    members_rect = Some(r);
822                }
823            }
824            y_offset += metrics.height.max(0.0) + text_padding;
825        }
826    }
827    let mut members_group_height = members_rect.map(|r| r.height()).unwrap_or(0.0);
828    if members_group_height <= 0.0 {
829        // Mermaid reserves half a gap when the members group is empty.
830        members_group_height = (gap / 2.0).max(0.0);
831    }
832
833    // Methods group.
834    let mut methods_rect: Option<Rect> = None;
835    let mut methods_metrics_out: Option<Vec<crate::text::TextMetrics>> =
836        capture_row_metrics.then(|| Vec::with_capacity(node.methods.len()));
837    {
838        let mut y_offset = 0.0;
839        for m in &node.methods {
840            let mut t = decode_entities_minimal(m.display_text.trim());
841            if !use_html_labels && t.starts_with('\\') {
842                t = t.trim_start_matches('\\').to_string();
843            }
844            let metrics = measure_label(measurer, &t, text_style, wrap_mode);
845            if let Some(out) = methods_metrics_out.as_mut() {
846                out.push(metrics);
847            }
848            if let Some(r) = label_rect(metrics, y_offset) {
849                if let Some(ref mut cur) = methods_rect {
850                    cur.union(r);
851                } else {
852                    methods_rect = Some(r);
853                }
854            }
855            y_offset += metrics.height.max(0.0) + text_padding;
856        }
857    }
858
859    // Combine into the bbox returned by `textHelper(...)`.
860    let mut bbox_opt: Option<Rect> = None;
861
862    // annotation-group: centered horizontally (`translate(-w/2, 0)`).
863    if let Some(mut r) = annotation_rect {
864        let w = r.width();
865        r.translate(-w / 2.0, 0.0);
866        bbox_opt = Some(if let Some(mut cur) = bbox_opt {
867            cur.union(r);
868            cur
869        } else {
870            r
871        });
872    }
873
874    // label-group: centered and shifted down by annotation height.
875    if let Some(mut r) = title_rect {
876        let w = r.width();
877        r.translate(-w / 2.0, annotation_group_height);
878        bbox_opt = Some(if let Some(mut cur) = bbox_opt {
879            cur.union(r);
880            cur
881        } else {
882            r
883        });
884    }
885
886    // members-group: left-aligned, shifted down by label height + gap*2.
887    if let Some(mut r) = members_rect {
888        let dy = annotation_group_height + title_group_height + gap * 2.0;
889        r.translate(0.0, dy);
890        bbox_opt = Some(if let Some(mut cur) = bbox_opt {
891            cur.union(r);
892            cur
893        } else {
894            r
895        });
896    }
897
898    // methods-group: left-aligned, shifted down by label height + members height + gap*4.
899    if let Some(mut r) = methods_rect {
900        let dy = annotation_group_height + title_group_height + (members_group_height + gap * 4.0);
901        r.translate(0.0, dy);
902        bbox_opt = Some(if let Some(mut cur) = bbox_opt {
903            cur.union(r);
904            cur
905        } else {
906            r
907        });
908    }
909
910    let bbox = bbox_opt.unwrap_or_else(|| Rect::from_min_max(0.0, 0.0, 0.0, 0.0));
911    let w = bbox.width().max(0.0);
912    let mut h = bbox.height().max(0.0);
913
914    // Mermaid adjusts bbox height depending on which compartments exist.
915    if node.members.is_empty() && node.methods.is_empty() {
916        h += gap;
917    } else if !node.members.is_empty() && node.methods.is_empty() {
918        h += gap * 2.0;
919    }
920
921    let render_extra_box =
922        node.members.is_empty() && node.methods.is_empty() && !hide_empty_members_box;
923
924    // The Dagre node bounds come from the rectangle passed to `updateNodeBounds`.
925    let mut rect_w = w + 2.0 * padding;
926    let mut rect_h = h + 2.0 * padding;
927    if render_extra_box {
928        rect_h += padding * 2.0;
929    } else if node.members.is_empty() && node.methods.is_empty() {
930        rect_h -= padding;
931    }
932
933    if node.type_param == "group" {
934        rect_w = rect_w.max(500.0);
935    }
936
937    let row_metrics = capture_row_metrics.then(|| ClassNodeRowMetrics {
938        members: members_metrics_out.unwrap_or_default(),
939        methods: methods_metrics_out.unwrap_or_default(),
940    });
941
942    (rect_w.max(1.0), rect_h.max(1.0), row_metrics)
943}
944
945fn note_dimensions(
946    text: &str,
947    measurer: &dyn TextMeasurer,
948    text_style: &TextStyle,
949    wrap_mode: WrapMode,
950    padding: f64,
951) -> (f64, f64, crate::text::TextMetrics) {
952    let p = padding.max(0.0);
953    let label = decode_entities_minimal(text);
954    let m = measurer.measure_wrapped(&label, text_style, None, wrap_mode);
955    (m.width + p, m.height + p, m)
956}
957
958fn label_metrics(
959    text: &str,
960    measurer: &dyn TextMeasurer,
961    text_style: &TextStyle,
962    wrap_mode: WrapMode,
963) -> (f64, f64) {
964    if text.trim().is_empty() {
965        return (0.0, 0.0);
966    }
967    let t = decode_entities_minimal(text);
968    let m = measurer.measure_wrapped(&t, text_style, None, wrap_mode);
969    (m.width.max(0.0), m.height.max(0.0))
970}
971
972fn set_extras_label_metrics(extras: &mut BTreeMap<String, Value>, key: &str, w: f64, h: f64) {
973    let obj = Value::Object(
974        [
975            ("width".to_string(), Value::from(w)),
976            ("height".to_string(), Value::from(h)),
977        ]
978        .into_iter()
979        .collect(),
980    );
981    extras.insert(key.to_string(), obj);
982}
983
984pub fn layout_class_diagram_v2(
985    semantic: &Value,
986    effective_config: &Value,
987    measurer: &dyn TextMeasurer,
988) -> Result<ClassDiagramV2Layout> {
989    let model: ClassDiagramModel = crate::json::from_value_ref(semantic)?;
990    layout_class_diagram_v2_typed(&model, effective_config, measurer)
991}
992
993pub fn layout_class_diagram_v2_typed(
994    model: &ClassDiagramModel,
995    effective_config: &Value,
996    measurer: &dyn TextMeasurer,
997) -> Result<ClassDiagramV2Layout> {
998    let diagram_dir = rank_dir_from(&model.direction);
999    let conf = effective_config
1000        .get("flowchart")
1001        .or_else(|| effective_config.get("class"))
1002        .unwrap_or(effective_config);
1003    let nodesep = config_f64(conf, &["nodeSpacing"]).unwrap_or(50.0);
1004    let ranksep = config_f64(conf, &["rankSpacing"]).unwrap_or(50.0);
1005
1006    let global_html_labels = config_bool(effective_config, &["htmlLabels"]).unwrap_or(true);
1007    let flowchart_html_labels =
1008        config_bool(effective_config, &["flowchart", "htmlLabels"]).unwrap_or(true);
1009    let wrap_mode_node = if global_html_labels {
1010        WrapMode::HtmlLike
1011    } else {
1012        WrapMode::SvgLike
1013    };
1014    let wrap_mode_label = if flowchart_html_labels {
1015        WrapMode::HtmlLike
1016    } else {
1017        WrapMode::SvgLike
1018    };
1019
1020    // Mermaid defaults `config.class.padding` to 12.
1021    let class_padding = config_f64(effective_config, &["class", "padding"]).unwrap_or(12.0);
1022    let namespace_padding = config_f64(effective_config, &["flowchart", "padding"]).unwrap_or(15.0);
1023    let hide_empty_members_box =
1024        config_bool(effective_config, &["class", "hideEmptyMembersBox"]).unwrap_or(false);
1025
1026    let text_style = class_text_style(effective_config);
1027    let capture_row_metrics = matches!(wrap_mode_node, WrapMode::HtmlLike);
1028    let capture_label_metrics = matches!(wrap_mode_label, WrapMode::HtmlLike);
1029    let mut class_row_metrics_by_id: FxHashMap<String, Arc<ClassNodeRowMetrics>> =
1030        FxHashMap::default();
1031    let mut node_label_metrics_by_id: HashMap<String, (f64, f64)> = HashMap::new();
1032
1033    let mut g = Graph::<NodeLabel, EdgeLabel, GraphLabel>::new(GraphOptions {
1034        directed: true,
1035        multigraph: true,
1036        compound: true,
1037    });
1038    g.set_graph(GraphLabel {
1039        rankdir: diagram_dir,
1040        nodesep,
1041        ranksep,
1042        // Mermaid uses fixed graph margins in its Dagre wrapper for class diagrams, but our SVG
1043        // renderer re-introduces that margin when computing the viewport. Keep layout coordinates
1044        // margin-free here to avoid double counting.
1045        marginx: 0.0,
1046        marginy: 0.0,
1047        ..Default::default()
1048    });
1049
1050    for id in model.namespaces.keys() {
1051        // Mermaid's dagre-wrapper assigns a concrete size to namespace nodes (cluster placeholders)
1052        // based on the rendered label bbox plus padding. Doing the same here helps compound
1053        // constraints match upstream (especially for LR layouts).
1054        let title = id.clone();
1055        let (tw, th) = label_metrics(&title, measurer, &text_style, wrap_mode_label);
1056        let w = (tw + 2.0 * namespace_padding).max(1.0);
1057        let h = (th + 2.0 * namespace_padding).max(1.0);
1058        g.set_node(
1059            id.clone(),
1060            NodeLabel {
1061                width: w,
1062                height: h,
1063                ..Default::default()
1064            },
1065        );
1066    }
1067
1068    for c in model.classes.values() {
1069        let (w, h, row_metrics) = class_box_dimensions(
1070            c,
1071            measurer,
1072            &text_style,
1073            wrap_mode_node,
1074            class_padding,
1075            hide_empty_members_box,
1076            capture_row_metrics,
1077        );
1078        if let Some(rm) = row_metrics {
1079            class_row_metrics_by_id.insert(c.id.clone(), Arc::new(rm));
1080        }
1081        g.set_node(
1082            c.id.clone(),
1083            NodeLabel {
1084                width: w,
1085                height: h,
1086                ..Default::default()
1087            },
1088        );
1089    }
1090
1091    // Interface nodes (lollipop syntax).
1092    for iface in &model.interfaces {
1093        let label = decode_entities_minimal(iface.label.trim());
1094        let (tw, th) = label_metrics(&label, measurer, &text_style, wrap_mode_label);
1095        if capture_label_metrics {
1096            node_label_metrics_by_id.insert(iface.id.clone(), (tw, th));
1097        }
1098        g.set_node(
1099            iface.id.clone(),
1100            NodeLabel {
1101                width: tw.max(1.0),
1102                height: th.max(1.0),
1103                ..Default::default()
1104            },
1105        );
1106    }
1107
1108    for n in &model.notes {
1109        let (w, h, metrics) = note_dimensions(
1110            &n.text,
1111            measurer,
1112            &text_style,
1113            wrap_mode_label,
1114            namespace_padding,
1115        );
1116        if capture_label_metrics {
1117            node_label_metrics_by_id.insert(
1118                n.id.clone(),
1119                (metrics.width.max(0.0), metrics.height.max(0.0)),
1120            );
1121        }
1122        g.set_node(
1123            n.id.clone(),
1124            NodeLabel {
1125                width: w.max(1.0),
1126                height: h.max(1.0),
1127                ..Default::default()
1128            },
1129        );
1130    }
1131
1132    if g.options().compound {
1133        // Mermaid assigns parents based on the class' `parent` field (see upstream
1134        // `addClasses(..., parent)` + `g.setParent(vertex.id, parent)`).
1135        for c in model.classes.values() {
1136            if let Some(parent) = c
1137                .parent
1138                .as_ref()
1139                .map(|s| s.trim())
1140                .filter(|s| !s.is_empty())
1141            {
1142                if model.namespaces.contains_key(parent) {
1143                    g.set_parent(c.id.clone(), parent.to_string());
1144                }
1145            }
1146        }
1147
1148        // Keep interface nodes inside the same namespace cluster as their owning class.
1149        for iface in &model.interfaces {
1150            let Some(cls) = model.classes.get(iface.class_id.as_str()) else {
1151                continue;
1152            };
1153            let Some(parent) = cls
1154                .parent
1155                .as_ref()
1156                .map(|s| s.trim())
1157                .filter(|s| !s.is_empty())
1158            else {
1159                continue;
1160            };
1161            if model.namespaces.contains_key(parent) {
1162                g.set_parent(iface.id.clone(), parent.to_string());
1163            }
1164        }
1165    }
1166
1167    for rel in &model.relations {
1168        let (lw, lh) = label_metrics(&rel.title, measurer, &text_style, wrap_mode_label);
1169        let start_text = if rel.relation_title_1 == "none" {
1170            String::new()
1171        } else {
1172            rel.relation_title_1.clone()
1173        };
1174        let end_text = if rel.relation_title_2 == "none" {
1175            String::new()
1176        } else {
1177            rel.relation_title_2.clone()
1178        };
1179
1180        let (srw, srh) = label_metrics(&start_text, measurer, &text_style, wrap_mode_label);
1181        let (elw, elh) = label_metrics(&end_text, measurer, &text_style, wrap_mode_label);
1182
1183        let start_marker = if rel.relation.type1 == -1 { 0.0 } else { 10.0 };
1184        let end_marker = if rel.relation.type2 == -1 { 0.0 } else { 10.0 };
1185
1186        let mut el = EdgeLabel {
1187            width: lw,
1188            height: lh,
1189            labelpos: LabelPos::C,
1190            labeloffset: 10.0,
1191            minlen: 1,
1192            weight: 1.0,
1193            ..Default::default()
1194        };
1195        if srw > 0.0 && srh > 0.0 {
1196            set_extras_label_metrics(&mut el.extras, "startRight", srw, srh);
1197        }
1198        if elw > 0.0 && elh > 0.0 {
1199            set_extras_label_metrics(&mut el.extras, "endLeft", elw, elh);
1200        }
1201        el.extras
1202            .insert("startMarker".to_string(), Value::from(start_marker));
1203        el.extras
1204            .insert("endMarker".to_string(), Value::from(end_marker));
1205
1206        g.set_edge_named(
1207            rel.id1.clone(),
1208            rel.id2.clone(),
1209            Some(rel.id.clone()),
1210            Some(el),
1211        );
1212    }
1213
1214    let start_note_edge_id = model.relations.len() + 1;
1215    for (i, note) in model.notes.iter().enumerate() {
1216        let Some(class_id) = note.class_id.as_ref() else {
1217            continue;
1218        };
1219        if !model.classes.contains_key(class_id) {
1220            continue;
1221        }
1222        let edge_id = format!("edgeNote{}", start_note_edge_id + i);
1223        let el = EdgeLabel {
1224            width: 0.0,
1225            height: 0.0,
1226            labelpos: LabelPos::C,
1227            labeloffset: 10.0,
1228            minlen: 1,
1229            weight: 1.0,
1230            ..Default::default()
1231        };
1232        g.set_edge_named(note.id.clone(), class_id.clone(), Some(edge_id), Some(el));
1233    }
1234
1235    let prefer_dagreish_disconnected = !model.interfaces.is_empty();
1236    let mut prepared = prepare_graph(g, 0, prefer_dagreish_disconnected)?;
1237    let (mut fragments, _bounds) = layout_prepared(&mut prepared, &node_label_metrics_by_id)?;
1238
1239    let mut node_rect_by_id: HashMap<String, Rect> = HashMap::new();
1240    for n in fragments.nodes.values() {
1241        node_rect_by_id.insert(n.id.clone(), Rect::from_center(n.x, n.y, n.width, n.height));
1242    }
1243
1244    for (edge, terminal_meta) in fragments.edges.iter_mut() {
1245        let Some(meta) = terminal_meta.clone() else {
1246            continue;
1247        };
1248        let (from_rect, to_rect, points) = if let (Some(from), Some(to)) = (
1249            node_rect_by_id.get(edge.from.as_str()).copied(),
1250            node_rect_by_id.get(edge.to.as_str()).copied(),
1251        ) {
1252            (
1253                Some(from),
1254                Some(to),
1255                terminal_path_for_edge(&edge.points, from, to),
1256            )
1257        } else {
1258            (None, None, edge.points.clone())
1259        };
1260
1261        if let Some((w, h)) = meta.start_left {
1262            if let Some((x, y)) =
1263                calc_terminal_label_position(meta.start_marker, TerminalPos::StartLeft, &points)
1264            {
1265                let (x, y) = from_rect
1266                    .map(|r| nudge_point_outside_rect(x, y, r))
1267                    .unwrap_or((x, y));
1268                edge.start_label_left = Some(LayoutLabel {
1269                    x,
1270                    y,
1271                    width: w,
1272                    height: h,
1273                });
1274            }
1275        }
1276        if let Some((w, h)) = meta.start_right {
1277            if let Some((x, y)) =
1278                calc_terminal_label_position(meta.start_marker, TerminalPos::StartRight, &points)
1279            {
1280                let (x, y) = from_rect
1281                    .map(|r| nudge_point_outside_rect(x, y, r))
1282                    .unwrap_or((x, y));
1283                edge.start_label_right = Some(LayoutLabel {
1284                    x,
1285                    y,
1286                    width: w,
1287                    height: h,
1288                });
1289            }
1290        }
1291        if let Some((w, h)) = meta.end_left {
1292            if let Some((x, y)) =
1293                calc_terminal_label_position(meta.end_marker, TerminalPos::EndLeft, &points)
1294            {
1295                let (x, y) = to_rect
1296                    .map(|r| nudge_point_outside_rect(x, y, r))
1297                    .unwrap_or((x, y));
1298                edge.end_label_left = Some(LayoutLabel {
1299                    x,
1300                    y,
1301                    width: w,
1302                    height: h,
1303                });
1304            }
1305        }
1306        if let Some((w, h)) = meta.end_right {
1307            if let Some((x, y)) =
1308                calc_terminal_label_position(meta.end_marker, TerminalPos::EndRight, &points)
1309            {
1310                let (x, y) = to_rect
1311                    .map(|r| nudge_point_outside_rect(x, y, r))
1312                    .unwrap_or((x, y));
1313                edge.end_label_right = Some(LayoutLabel {
1314                    x,
1315                    y,
1316                    width: w,
1317                    height: h,
1318                });
1319            }
1320        }
1321    }
1322
1323    let title_margin_top = config_f64(
1324        effective_config,
1325        &["flowchart", "subGraphTitleMargin", "top"],
1326    )
1327    .unwrap_or(0.0);
1328    let title_margin_bottom = config_f64(
1329        effective_config,
1330        &["flowchart", "subGraphTitleMargin", "bottom"],
1331    )
1332    .unwrap_or(0.0);
1333
1334    let mut clusters: Vec<LayoutCluster> = Vec::new();
1335    // Mermaid renders namespaces as Dagre clusters. The cluster geometry comes from the Dagre
1336    // compound layout (not a post-hoc union of class-node bboxes). Use the computed namespace
1337    // node x/y/width/height and mirror `clusters.js` sizing tweaks for title width.
1338    for id in model.namespaces.keys() {
1339        let Some(ns_node) = fragments.nodes.get(id.as_str()) else {
1340            continue;
1341        };
1342        let cx = ns_node.x;
1343        let cy = ns_node.y;
1344        let base_w = ns_node.width.max(1.0);
1345        let base_h = ns_node.height.max(1.0);
1346
1347        let title = id.clone();
1348        let (tw, th) = label_metrics(&title, measurer, &text_style, wrap_mode_label);
1349        let min_title_w = (tw + namespace_padding).max(1.0);
1350        let width = if base_w <= min_title_w {
1351            min_title_w
1352        } else {
1353            base_w
1354        };
1355        let diff = if base_w <= min_title_w {
1356            (width - base_w) / 2.0 - namespace_padding
1357        } else {
1358            -namespace_padding
1359        };
1360        let offset_y = th - namespace_padding / 2.0;
1361        let title_label = LayoutLabel {
1362            x: cx,
1363            y: (cy - base_h / 2.0) + title_margin_top + th / 2.0,
1364            width: tw,
1365            height: th,
1366        };
1367
1368        clusters.push(LayoutCluster {
1369            id: id.clone(),
1370            x: cx,
1371            y: cy,
1372            width,
1373            height: base_h,
1374            diff,
1375            offset_y,
1376            title: title.clone(),
1377            title_label,
1378            requested_dir: None,
1379            effective_dir: normalize_dir(&model.direction),
1380            padding: namespace_padding,
1381            title_margin_top,
1382            title_margin_bottom,
1383        });
1384    }
1385
1386    // Keep snapshots deterministic. The Dagre-ish pipeline may insert dummy nodes/edges in
1387    // iteration-dependent order, so sort the emitted layout lists by stable identifiers.
1388    let mut nodes: Vec<LayoutNode> = fragments.nodes.into_values().collect();
1389    nodes.sort_by(|a, b| a.id.cmp(&b.id));
1390
1391    let mut edges: Vec<LayoutEdge> = fragments.edges.into_iter().map(|(e, _)| e).collect();
1392    edges.sort_by(|a, b| a.id.cmp(&b.id));
1393
1394    clusters.sort_by(|a, b| a.id.cmp(&b.id));
1395
1396    let bounds = compute_bounds(&nodes, &edges, &clusters);
1397
1398    Ok(ClassDiagramV2Layout {
1399        nodes,
1400        edges,
1401        clusters,
1402        bounds,
1403        class_row_metrics_by_id,
1404    })
1405}
1406
1407fn compute_bounds(
1408    nodes: &[LayoutNode],
1409    edges: &[LayoutEdge],
1410    clusters: &[LayoutCluster],
1411) -> Option<Bounds> {
1412    let mut points: Vec<(f64, f64)> = Vec::new();
1413
1414    for c in clusters {
1415        let r = Rect::from_center(c.x, c.y, c.width, c.height);
1416        points.push((r.min_x(), r.min_y()));
1417        points.push((r.max_x(), r.max_y()));
1418        let lr = Rect::from_center(
1419            c.title_label.x,
1420            c.title_label.y,
1421            c.title_label.width,
1422            c.title_label.height,
1423        );
1424        points.push((lr.min_x(), lr.min_y()));
1425        points.push((lr.max_x(), lr.max_y()));
1426    }
1427
1428    for n in nodes {
1429        let r = Rect::from_center(n.x, n.y, n.width, n.height);
1430        points.push((r.min_x(), r.min_y()));
1431        points.push((r.max_x(), r.max_y()));
1432    }
1433
1434    for e in edges {
1435        for p in &e.points {
1436            points.push((p.x, p.y));
1437        }
1438        for l in [
1439            e.label.as_ref(),
1440            e.start_label_left.as_ref(),
1441            e.start_label_right.as_ref(),
1442            e.end_label_left.as_ref(),
1443            e.end_label_right.as_ref(),
1444        ]
1445        .into_iter()
1446        .flatten()
1447        {
1448            let r = Rect::from_center(l.x, l.y, l.width, l.height);
1449            points.push((r.min_x(), r.min_y()));
1450            points.push((r.max_x(), r.max_y()));
1451        }
1452    }
1453
1454    Bounds::from_points(points)
1455}