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