Skip to main content

jellyflow_layout/
engine.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::sync::Arc;
4
5use jellyflow_core::{
6    CanvasPoint, CanvasRect, CanvasSize, EdgeId, Graph, GraphOp, GraphTransaction, NodeId,
7};
8use serde::{Deserialize, Serialize};
9
10use crate::family::{LayoutEngineMetadata, LayoutFamilyId, LayoutFamilyMetadata};
11
12/// Stable engine id for the built-in Dagre-compatible `dugong` engine.
13pub const DUGONG_LAYOUT_ENGINE_ID: &str = "dugong";
14/// Stable engine id for the built-in tidy tree engine.
15pub const TIDY_TREE_LAYOUT_ENGINE_ID: &str = "tidy_tree";
16/// Stable engine id for the built-in radial mind-map engine.
17pub const MIND_MAP_RADIAL_LAYOUT_ENGINE_ID: &str = "mind_map_radial";
18/// Stable engine id for the built-in freeform mind-map engine.
19pub const MIND_MAP_FREEFORM_LAYOUT_ENGINE_ID: &str = "mind_map_freeform";
20
21/// Stable identifier for a layout engine.
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[serde(transparent)]
24pub struct LayoutEngineId(String);
25
26impl LayoutEngineId {
27    /// Creates a new engine id.
28    pub fn new(id: impl Into<String>) -> Self {
29        Self(id.into())
30    }
31
32    /// Returns the built-in `dugong` engine id.
33    pub fn dugong() -> Self {
34        Self::new(DUGONG_LAYOUT_ENGINE_ID)
35    }
36
37    /// Returns the built-in tidy tree engine id.
38    pub fn tidy_tree() -> Self {
39        Self::new(TIDY_TREE_LAYOUT_ENGINE_ID)
40    }
41
42    /// Returns the built-in radial mind-map engine id.
43    pub fn mind_map_radial() -> Self {
44        Self::new(MIND_MAP_RADIAL_LAYOUT_ENGINE_ID)
45    }
46
47    /// Returns the built-in freeform mind-map engine id.
48    pub fn mind_map_freeform() -> Self {
49        Self::new(MIND_MAP_FREEFORM_LAYOUT_ENGINE_ID)
50    }
51
52    /// Returns this id as a string slice.
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56}
57
58impl fmt::Display for LayoutEngineId {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.write_str(&self.0)
61    }
62}
63
64impl From<&str> for LayoutEngineId {
65    fn from(value: &str) -> Self {
66        Self::new(value)
67    }
68}
69
70impl From<String> for LayoutEngineId {
71    fn from(value: String) -> Self {
72        Self::new(value)
73    }
74}
75
76/// Direction for a layered graph layout.
77#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum LayoutDirection {
80    /// Top to bottom.
81    #[default]
82    TopToBottom,
83    /// Bottom to top.
84    BottomToTop,
85    /// Left to right.
86    LeftToRight,
87    /// Right to left.
88    RightToLeft,
89}
90
91/// Spacing knobs passed through to layout engines that support layered spacing.
92#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
93pub struct LayoutSpacing {
94    pub nodesep: f32,
95    pub ranksep: f32,
96    pub edgesep: f32,
97}
98
99impl Default for LayoutSpacing {
100    fn default() -> Self {
101        Self {
102            nodesep: 50.0,
103            ranksep: 50.0,
104            edgesep: 20.0,
105        }
106    }
107}
108
109/// Options shared by Jellyflow layout adapters.
110#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
111pub struct LayoutOptions {
112    pub direction: LayoutDirection,
113    pub spacing: LayoutSpacing,
114    pub margin: CanvasSize,
115    pub default_node_size: CanvasSize,
116    /// Fallback node origin used when a node has no per-node origin override.
117    pub node_origin: (f32, f32),
118}
119
120impl Default for LayoutOptions {
121    fn default() -> Self {
122        Self {
123            direction: LayoutDirection::TopToBottom,
124            spacing: LayoutSpacing::default(),
125            margin: CanvasSize {
126                width: 0.0,
127                height: 0.0,
128            },
129            default_node_size: CanvasSize {
130                width: 172.0,
131                height: 36.0,
132            },
133            node_origin: (0.0, 0.0),
134        }
135    }
136}
137
138impl LayoutOptions {
139    /// Uses a different fallback node size for nodes without explicit or measured size.
140    pub fn with_default_node_size(mut self, size: CanvasSize) -> Self {
141        self.default_node_size = size;
142        self
143    }
144
145    /// Uses a different layered layout direction.
146    pub fn with_direction(mut self, direction: LayoutDirection) -> Self {
147        self.direction = direction;
148        self
149    }
150
151    /// Uses a different fallback node origin.
152    pub fn with_node_origin(mut self, node_origin: (f32, f32)) -> Self {
153        self.node_origin = node_origin;
154        self
155    }
156}
157
158/// Which nodes a layout request should include.
159#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(tag = "kind", rename_all = "snake_case")]
161pub enum LayoutScope {
162    /// Include all non-hidden nodes.
163    #[default]
164    All,
165    /// Include only these nodes. Hidden nodes are still ignored.
166    Nodes { nodes: BTreeSet<NodeId> },
167}
168
169impl LayoutScope {
170    pub(crate) fn contains(&self, node: NodeId) -> bool {
171        match self {
172            Self::All => true,
173            Self::Nodes { nodes } => nodes.contains(&node),
174        }
175    }
176}
177
178/// A headless layout request.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct LayoutRequest {
181    pub options: LayoutOptions,
182    #[serde(default)]
183    pub scope: LayoutScope,
184    /// Adapter-reported node sizes. Graph node sizes win over these facts.
185    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
186    pub measured_node_sizes: BTreeMap<NodeId, CanvasSize>,
187}
188
189impl Default for LayoutRequest {
190    fn default() -> Self {
191        Self {
192            options: LayoutOptions::default(),
193            scope: LayoutScope::All,
194            measured_node_sizes: BTreeMap::new(),
195        }
196    }
197}
198
199impl LayoutRequest {
200    /// Creates a request for all visible nodes.
201    pub fn all() -> Self {
202        Self::default()
203    }
204
205    /// Creates a request for a selected set of nodes.
206    pub fn nodes(nodes: impl IntoIterator<Item = NodeId>) -> Self {
207        Self {
208            scope: LayoutScope::Nodes {
209                nodes: nodes.into_iter().collect(),
210            },
211            ..Self::default()
212        }
213    }
214
215    /// Adds adapter-reported node sizes.
216    pub fn with_measured_node_sizes(
217        mut self,
218        sizes: impl IntoIterator<Item = (NodeId, CanvasSize)>,
219    ) -> Self {
220        self.measured_node_sizes.extend(sizes);
221        self
222    }
223
224    /// Sets layout options.
225    pub fn with_options(mut self, options: LayoutOptions) -> Self {
226        self.options = options;
227        self
228    }
229}
230
231/// Request for one selected layout engine.
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct LayoutEngineRequest {
234    pub engine: LayoutEngineId,
235    pub layout: LayoutRequest,
236}
237
238impl Default for LayoutEngineRequest {
239    fn default() -> Self {
240        Self {
241            engine: LayoutEngineId::dugong(),
242            layout: LayoutRequest::default(),
243        }
244    }
245}
246
247impl LayoutEngineRequest {
248    /// Creates an engine request.
249    pub fn new(engine: impl Into<LayoutEngineId>, layout: LayoutRequest) -> Self {
250        Self {
251            engine: engine.into(),
252            layout,
253        }
254    }
255
256    /// Creates a request for the built-in `dugong` engine.
257    pub fn dugong(layout: LayoutRequest) -> Self {
258        Self::new(LayoutEngineId::dugong(), layout)
259    }
260
261    /// Creates a request for the built-in tidy tree engine.
262    pub fn tidy_tree(layout: LayoutRequest) -> Self {
263        Self::new(LayoutEngineId::tidy_tree(), layout)
264    }
265
266    /// Creates a request for the built-in radial mind-map engine.
267    pub fn mind_map_radial(layout: LayoutRequest) -> Self {
268        Self::new(LayoutEngineId::mind_map_radial(), layout)
269    }
270
271    /// Creates a request for the built-in freeform mind-map engine.
272    pub fn mind_map_freeform(layout: LayoutRequest) -> Self {
273        Self::new(LayoutEngineId::mind_map_freeform(), layout)
274    }
275
276    /// Uses a different engine id.
277    pub fn with_engine(mut self, engine: impl Into<LayoutEngineId>) -> Self {
278        self.engine = engine.into();
279        self
280    }
281
282    /// Sets the layout request.
283    pub fn with_layout(mut self, layout: LayoutRequest) -> Self {
284        self.layout = layout;
285        self
286    }
287}
288
289/// Runtime or host-owned context available to layout engines.
290#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
291pub struct LayoutContext {
292    /// Runtime-reported node sizes. Graph node sizes and request-local measured sizes win.
293    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
294    pub measured_node_sizes: BTreeMap<NodeId, CanvasSize>,
295    /// Nodes that engines should treat as fixed when they support pinning.
296    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
297    pub pinned_nodes: BTreeSet<NodeId>,
298    /// Runtime fallback node origin, usually resolved from interaction state.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub node_origin: Option<(f32, f32)>,
301}
302
303impl LayoutContext {
304    /// Creates an empty layout context.
305    pub fn new() -> Self {
306        Self::default()
307    }
308
309    /// Adds runtime-reported node sizes.
310    pub fn with_measured_node_sizes(
311        mut self,
312        sizes: impl IntoIterator<Item = (NodeId, CanvasSize)>,
313    ) -> Self {
314        self.measured_node_sizes.extend(sizes);
315        self
316    }
317
318    /// Adds nodes that engines should keep fixed when possible.
319    pub fn with_pinned_nodes(mut self, nodes: impl IntoIterator<Item = NodeId>) -> Self {
320        self.pinned_nodes.extend(nodes);
321        self
322    }
323
324    /// Sets the runtime fallback node origin.
325    pub fn with_node_origin(mut self, node_origin: (f32, f32)) -> Self {
326        self.node_origin = Some(node_origin);
327        self
328    }
329}
330
331/// A node position produced by a layout run.
332#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
333pub struct LayoutNodePosition {
334    pub node: NodeId,
335    pub pos: CanvasPoint,
336    pub center: CanvasPoint,
337    pub size: CanvasSize,
338}
339
340/// A layout-produced edge route.
341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
342pub struct LayoutEdgeRoute {
343    pub edge: EdgeId,
344    pub points: Vec<CanvasPoint>,
345}
346
347/// Result of a headless layout run.
348#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
349pub struct LayoutResult {
350    /// Engine-produced results contain at most one entry per node. If a caller manually constructs a
351    /// result with duplicates, lookup returns the first entry and transaction conversion fails.
352    pub nodes: Vec<LayoutNodePosition>,
353    #[serde(default, skip_serializing_if = "Vec::is_empty")]
354    pub edge_routes: Vec<LayoutEdgeRoute>,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub bounds: Option<CanvasRect>,
357}
358
359impl LayoutResult {
360    /// Finds one node position in this result.
361    pub fn node_position(&self, node: NodeId) -> Option<LayoutNodePosition> {
362        self.nodes
363            .iter()
364            .find(|position| position.node == node)
365            .copied()
366    }
367
368    /// Converts node position changes into a Jellyflow transaction.
369    pub fn to_transaction(&self, graph: &Graph) -> Result<GraphTransaction, LayoutError> {
370        let mut seen = BTreeSet::new();
371        let mut ops = Vec::new();
372
373        for node in &self.nodes {
374            if !seen.insert(node.node) {
375                return Err(LayoutError::DuplicateResultNode(node.node));
376            }
377
378            let from = graph
379                .nodes
380                .get(&node.node)
381                .ok_or(LayoutError::MissingTransactionNode(node.node))?
382                .pos;
383            if from != node.pos {
384                ops.push(GraphOp::SetNodePos {
385                    id: node.node,
386                    from,
387                    to: node.pos,
388                });
389            }
390        }
391
392        Ok(GraphTransaction::from_ops(ops).with_label("Layout graph"))
393    }
394}
395
396/// Errors reported by layout projection, registry lookup, or layout output conversion.
397#[derive(Debug, thiserror::Error, PartialEq)]
398pub enum LayoutError {
399    #[error("layout engine id is already registered: {0}")]
400    DuplicateLayoutEngine(LayoutEngineId),
401    #[error("layout family id is already registered: {0}")]
402    DuplicateLayoutFamily(LayoutFamilyId),
403    #[error("layout engine metadata is already registered: {0}")]
404    DuplicateLayoutEngineMetadata(LayoutEngineId),
405    #[error("layout engine is not registered: {0}")]
406    MissingLayoutEngine(LayoutEngineId),
407    #[error("layout default node size must be positive and finite: {0:?}")]
408    InvalidDefaultNodeSize(CanvasSize),
409    #[error("layout spacing values must be non-negative and finite: {0:?}")]
410    InvalidSpacing(LayoutSpacing),
411    #[error("layout margin must be non-negative and finite: {0:?}")]
412    InvalidMargin(CanvasSize),
413    #[error("layout node size must be positive and finite for node {node:?}: {size:?}")]
414    InvalidNodeSize { node: NodeId, size: CanvasSize },
415    #[error("layout scope references missing node: {0:?}")]
416    MissingScopeNode(NodeId),
417    #[error("layout edge references missing source port: {0:?}")]
418    MissingSourcePort(EdgeId),
419    #[error("layout edge references missing target port: {0:?}")]
420    MissingTargetPort(EdgeId),
421    #[error("layout edge source port references missing node: {edge:?}")]
422    MissingSourceNode { edge: EdgeId },
423    #[error("layout edge target port references missing node: {edge:?}")]
424    MissingTargetNode { edge: EdgeId },
425    #[error("layout engine did not return a node position for node {0:?}")]
426    MissingNodePosition(NodeId),
427    #[error("layout result contains a duplicate node position for node {0:?}")]
428    DuplicateResultNode(NodeId),
429    #[error("layout result references missing graph node: {0:?}")]
430    MissingTransactionNode(NodeId),
431    #[error("layout engine returned a non-finite node position for node {node:?}: ({x}, {y})")]
432    NonFiniteNodePosition { node: NodeId, x: f64, y: f64 },
433}
434
435/// Headless layout engine contract.
436pub trait LayoutEngine: Send + Sync {
437    /// Stable engine id.
438    fn id(&self) -> LayoutEngineId;
439
440    /// Runs this layout engine.
441    fn layout(
442        &self,
443        graph: &Graph,
444        request: &LayoutRequest,
445        context: &LayoutContext,
446    ) -> Result<LayoutResult, LayoutError>;
447}
448
449/// Caller-owned registry of layout engines.
450#[derive(Default, Clone)]
451pub struct LayoutEngineRegistry {
452    engines: BTreeMap<LayoutEngineId, Arc<dyn LayoutEngine>>,
453    families: BTreeMap<LayoutFamilyId, LayoutFamilyMetadata>,
454    metadata: BTreeMap<LayoutEngineId, LayoutEngineMetadata>,
455}
456
457impl fmt::Debug for LayoutEngineRegistry {
458    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459        f.debug_struct("LayoutEngineRegistry")
460            .field("engines", &self.engine_ids().collect::<Vec<_>>())
461            .field("families", &self.family_ids().collect::<Vec<_>>())
462            .finish()
463    }
464}
465
466impl LayoutEngineRegistry {
467    /// Creates an empty registry.
468    pub fn new() -> Self {
469        Self::default()
470    }
471
472    /// Registers an engine.
473    pub fn insert<E>(&mut self, engine: E) -> Result<(), LayoutError>
474    where
475        E: LayoutEngine + 'static,
476    {
477        self.insert_shared(Arc::new(engine))
478    }
479
480    /// Registers a shared engine object.
481    pub fn insert_shared(&mut self, engine: Arc<dyn LayoutEngine>) -> Result<(), LayoutError> {
482        let id = engine.id();
483        if self.engines.contains_key(&id) {
484            return Err(LayoutError::DuplicateLayoutEngine(id));
485        }
486        self.engines.insert(id, engine);
487        Ok(())
488    }
489
490    /// Registers layout family metadata.
491    pub fn insert_family(&mut self, family: LayoutFamilyMetadata) -> Result<(), LayoutError> {
492        let id = family.id.clone();
493        if self.families.contains_key(&id) {
494            return Err(LayoutError::DuplicateLayoutFamily(id));
495        }
496        self.families.insert(id, family);
497        Ok(())
498    }
499
500    /// Registers layout engine discovery metadata.
501    pub fn insert_metadata(&mut self, metadata: LayoutEngineMetadata) -> Result<(), LayoutError> {
502        let id = metadata.engine.clone();
503        if self.metadata.contains_key(&id) {
504            return Err(LayoutError::DuplicateLayoutEngineMetadata(id));
505        }
506        self.metadata.insert(id, metadata);
507        Ok(())
508    }
509
510    /// Returns an engine by id.
511    pub fn get(&self, id: &LayoutEngineId) -> Option<&dyn LayoutEngine> {
512        self.engines.get(id).map(Arc::as_ref)
513    }
514
515    /// Returns family metadata by id.
516    pub fn family(&self, id: &LayoutFamilyId) -> Option<&LayoutFamilyMetadata> {
517        self.families.get(id)
518    }
519
520    /// Returns engine discovery metadata by engine id.
521    pub fn metadata(&self, id: &LayoutEngineId) -> Option<&LayoutEngineMetadata> {
522        self.metadata.get(id)
523    }
524
525    /// Returns registered engine ids in deterministic order.
526    pub fn engine_ids(&self) -> impl Iterator<Item = &LayoutEngineId> {
527        self.engines.keys()
528    }
529
530    /// Returns registered family ids in deterministic order.
531    pub fn family_ids(&self) -> impl Iterator<Item = &LayoutFamilyId> {
532        self.families.keys()
533    }
534
535    /// Returns registered families in deterministic order.
536    pub fn families(&self) -> impl Iterator<Item = &LayoutFamilyMetadata> {
537        self.families.values()
538    }
539
540    /// Returns registered engine metadata in deterministic order.
541    pub fn engine_metadata(&self) -> impl Iterator<Item = &LayoutEngineMetadata> {
542        self.metadata.values()
543    }
544
545    /// Returns engine metadata for one family in deterministic engine-id order.
546    pub fn engines_for_family(
547        &self,
548        family: &LayoutFamilyId,
549    ) -> impl Iterator<Item = &LayoutEngineMetadata> {
550        self.metadata
551            .values()
552            .filter(move |metadata| &metadata.family == family)
553    }
554
555    /// Runs the engine named by the request.
556    pub fn layout(
557        &self,
558        graph: &Graph,
559        request: &LayoutEngineRequest,
560        context: &LayoutContext,
561    ) -> Result<LayoutResult, LayoutError> {
562        let engine = self
563            .get(&request.engine)
564            .ok_or_else(|| LayoutError::MissingLayoutEngine(request.engine.clone()))?;
565        engine.layout(graph, &request.layout, context)
566    }
567}
568
569/// Runs a layout engine by id.
570pub fn layout_graph_with_engine(
571    graph: &Graph,
572    request: &LayoutEngineRequest,
573    registry: &LayoutEngineRegistry,
574    context: &LayoutContext,
575) -> Result<LayoutResult, LayoutError> {
576    registry.layout(graph, request, context)
577}
578
579/// Runs a layout engine and converts the node positions into a Jellyflow transaction.
580pub fn layout_graph_to_transaction_with_engine(
581    graph: &Graph,
582    request: &LayoutEngineRequest,
583    registry: &LayoutEngineRegistry,
584    context: &LayoutContext,
585) -> Result<GraphTransaction, LayoutError> {
586    layout_graph_with_engine(graph, request, registry, context)?.to_transaction(graph)
587}
588
589pub(crate) fn validate_request(graph: &Graph, request: &LayoutRequest) -> Result<(), LayoutError> {
590    if !request.options.default_node_size.is_positive_finite() {
591        return Err(LayoutError::InvalidDefaultNodeSize(
592            request.options.default_node_size,
593        ));
594    }
595    if !is_non_negative_finite_spacing(request.options.spacing) {
596        return Err(LayoutError::InvalidSpacing(request.options.spacing));
597    }
598    if !is_non_negative_finite_size(request.options.margin) {
599        return Err(LayoutError::InvalidMargin(request.options.margin));
600    }
601
602    if let LayoutScope::Nodes { nodes } = &request.scope {
603        for node in nodes {
604            if !graph.nodes.contains_key(node) {
605                return Err(LayoutError::MissingScopeNode(*node));
606            }
607        }
608    }
609
610    Ok(())
611}
612
613pub(crate) fn resolve_node_size(
614    graph: &Graph,
615    request: &LayoutRequest,
616    context: &LayoutContext,
617    node: NodeId,
618) -> Result<CanvasSize, LayoutError> {
619    let size = graph
620        .nodes
621        .get(&node)
622        .and_then(|node| node.size)
623        .or_else(|| request.measured_node_sizes.get(&node).copied())
624        .or_else(|| context.measured_node_sizes.get(&node).copied())
625        .unwrap_or(request.options.default_node_size);
626
627    if size.is_positive_finite() {
628        Ok(size)
629    } else {
630        Err(LayoutError::InvalidNodeSize { node, size })
631    }
632}
633
634pub(crate) fn resolve_node_origin(
635    origin: Option<jellyflow_core::NodeOrigin>,
636    request_fallback: (f32, f32),
637    context: &LayoutContext,
638) -> (f32, f32) {
639    let fallback = context.node_origin.unwrap_or(request_fallback);
640    let (x, y) = origin.map(|origin| origin.as_tuple()).unwrap_or(fallback);
641    (normalize_origin_component(x), normalize_origin_component(y))
642}
643
644pub(crate) fn position_from_center(
645    center: CanvasPoint,
646    size: CanvasSize,
647    origin: (f32, f32),
648) -> CanvasPoint {
649    CanvasPoint {
650        x: center.x - size.width * (0.5 - origin.0),
651        y: center.y - size.height * (0.5 - origin.1),
652    }
653}
654
655pub(crate) fn node_rect_from_position(node: &LayoutNodePosition) -> CanvasRect {
656    CanvasRect {
657        origin: CanvasPoint {
658            x: node.center.x - node.size.width * 0.5,
659            y: node.center.y - node.size.height * 0.5,
660        },
661        size: node.size,
662    }
663}
664
665pub(crate) fn union_bounds(bounds: Option<CanvasRect>, next: CanvasRect) -> Option<CanvasRect> {
666    if !next.is_positive_finite() {
667        return bounds;
668    }
669
670    let Some(bounds) = bounds else {
671        return Some(next);
672    };
673
674    let min_x = bounds.origin.x.min(next.origin.x);
675    let min_y = bounds.origin.y.min(next.origin.y);
676    let max_x = (bounds.origin.x + bounds.size.width).max(next.origin.x + next.size.width);
677    let max_y = (bounds.origin.y + bounds.size.height).max(next.origin.y + next.size.height);
678
679    Some(CanvasRect {
680        origin: CanvasPoint { x: min_x, y: min_y },
681        size: CanvasSize {
682            width: max_x - min_x,
683            height: max_y - min_y,
684        },
685    })
686}
687
688fn is_non_negative_finite_spacing(spacing: LayoutSpacing) -> bool {
689    spacing.nodesep.is_finite()
690        && spacing.ranksep.is_finite()
691        && spacing.edgesep.is_finite()
692        && spacing.nodesep >= 0.0
693        && spacing.ranksep >= 0.0
694        && spacing.edgesep >= 0.0
695}
696
697fn is_non_negative_finite_size(size: CanvasSize) -> bool {
698    size.is_finite() && size.width >= 0.0 && size.height >= 0.0
699}
700
701fn normalize_origin_component(component: f32) -> f32 {
702    if component.is_finite() {
703        component.clamp(0.0, 1.0)
704    } else {
705        0.0
706    }
707}