Skip to main content

jellyflow_runtime/runtime/
measurement.rs

1//! Renderer-neutral measurement facts reported by adapters.
2//!
3//! The graph document remains the persisted source of truth. Measurements live in runtime lookups
4//! so adapters can report layout facts once and reuse shared rendering, endpoint, and connection
5//! target behavior without copying geometry rules.
6
7use serde::{Deserialize, Serialize};
8
9use crate::runtime::connection::{
10    ConnectionHandleRef, ConnectionTargetCandidate, ResolvedConnectionTarget,
11};
12use crate::runtime::geometry::{EdgePosition, EdgeRouteFacts, HandleBounds, HandlePosition};
13use crate::runtime::lookups::NodeGraphLookups;
14use crate::runtime::rendering::RenderingQueryResult;
15use crate::runtime::store::NodeGraphStore;
16use crate::schema::NodeSurfaceSlotVisibility;
17use crate::schema::kit::NodeKitContentDensity;
18use jellyflow_core::core::{
19    CanvasPoint, CanvasRect, CanvasSize, EdgeId, Graph, NodeId, PortDirection, PortId, PortKey,
20};
21
22fn default_slot_visibility() -> NodeSurfaceSlotVisibility {
23    NodeSurfaceSlotVisibility::Visible
24}
25
26/// One measured handle attached to a node.
27#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
28pub struct MeasuredHandle {
29    pub handle: ConnectionHandleRef,
30    pub bounds: HandleBounds,
31}
32
33impl MeasuredHandle {
34    pub fn new(handle: ConnectionHandleRef, bounds: HandleBounds) -> Self {
35        Self { handle, bounds }
36    }
37}
38
39/// Measured rectangle for one semantic slot inside a node surface.
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct MeasuredSurfaceSlot {
42    pub key: String,
43    pub rect: CanvasRect,
44    #[serde(default = "default_slot_visibility")]
45    pub visibility: NodeSurfaceSlotVisibility,
46}
47
48impl MeasuredSurfaceSlot {
49    pub fn new(key: impl Into<String>, rect: CanvasRect) -> Self {
50        Self {
51            key: key.into(),
52            rect,
53            visibility: NodeSurfaceSlotVisibility::Visible,
54        }
55    }
56
57    pub fn with_visibility(mut self, visibility: NodeSurfaceSlotVisibility) -> Self {
58        self.visibility = visibility;
59        self
60    }
61
62    pub fn is_visible(&self) -> bool {
63        matches!(self.visibility, NodeSurfaceSlotVisibility::Visible)
64    }
65}
66
67/// Measured rectangle for one placement anchor inside a node surface.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct MeasuredSurfaceAnchor {
70    pub anchor: String,
71    pub rect: CanvasRect,
72    pub position: HandlePosition,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub port: Option<PortId>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub port_key: Option<PortKey>,
77    #[serde(default = "default_slot_visibility")]
78    pub visibility: NodeSurfaceSlotVisibility,
79}
80
81impl MeasuredSurfaceAnchor {
82    pub fn new(anchor: impl Into<String>, rect: CanvasRect, position: HandlePosition) -> Self {
83        Self {
84            anchor: anchor.into(),
85            rect,
86            position,
87            port: None,
88            port_key: None,
89            visibility: NodeSurfaceSlotVisibility::Visible,
90        }
91    }
92
93    pub fn with_port(mut self, port: PortId) -> Self {
94        self.port = Some(port);
95        self
96    }
97
98    pub fn with_port_key(mut self, port_key: impl Into<PortKey>) -> Self {
99        self.port_key = Some(port_key.into());
100        self
101    }
102
103    pub fn with_visibility(mut self, visibility: NodeSurfaceSlotVisibility) -> Self {
104        self.visibility = visibility;
105        self
106    }
107
108    pub fn is_visible(&self) -> bool {
109        matches!(self.visibility, NodeSurfaceSlotVisibility::Visible)
110    }
111
112    pub fn bounds(&self) -> HandleBounds {
113        HandleBounds {
114            rect: self.rect,
115            position: self.position,
116        }
117    }
118}
119
120/// Renderer-neutral measurement facts for one node.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122pub struct NodeMeasurement {
123    pub node: NodeId,
124    #[serde(default)]
125    pub revision: u64,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub density: Option<NodeKitContentDensity>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub size: Option<CanvasSize>,
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub handles: Vec<MeasuredHandle>,
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub slots: Vec<MeasuredSurfaceSlot>,
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub anchors: Vec<MeasuredSurfaceAnchor>,
136}
137
138impl NodeMeasurement {
139    pub fn new(node: NodeId) -> Self {
140        Self {
141            node,
142            revision: 0,
143            density: None,
144            size: None,
145            handles: Vec::new(),
146            slots: Vec::new(),
147            anchors: Vec::new(),
148        }
149    }
150
151    pub fn with_revision(mut self, revision: u64) -> Self {
152        self.revision = revision;
153        self
154    }
155
156    pub fn with_density(mut self, density: Option<NodeKitContentDensity>) -> Self {
157        self.density = density;
158        self
159    }
160
161    pub fn with_size(mut self, size: Option<CanvasSize>) -> Self {
162        self.size = size;
163        self
164    }
165
166    pub fn with_handles(mut self, handles: impl IntoIterator<Item = MeasuredHandle>) -> Self {
167        self.handles = handles.into_iter().collect();
168        self
169    }
170
171    pub fn with_slots(mut self, slots: impl IntoIterator<Item = MeasuredSurfaceSlot>) -> Self {
172        self.slots = slots.into_iter().collect();
173        self
174    }
175
176    pub fn with_anchors(
177        mut self,
178        anchors: impl IntoIterator<Item = MeasuredSurfaceAnchor>,
179    ) -> Self {
180        self.anchors = anchors.into_iter().collect();
181        self
182    }
183}
184
185/// Reason an adapter asks runtime to treat node-internal measurements as stale.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
187#[serde(rename_all = "snake_case")]
188pub enum NodeInternalsInvalidationReason {
189    DataChanged,
190    ComponentStateChanged,
191    ZoomChanged,
192    SizeChanged,
193    DensityChanged,
194    AdapterRequest,
195}
196
197/// Adapter-facing request to mark one or more nodes for remeasurement.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct NodeInternalsInvalidation {
200    pub nodes: Vec<NodeId>,
201    pub reason: NodeInternalsInvalidationReason,
202}
203
204impl NodeInternalsInvalidation {
205    pub fn new(
206        nodes: impl IntoIterator<Item = NodeId>,
207        reason: NodeInternalsInvalidationReason,
208    ) -> Self {
209        Self {
210            nodes: nodes.into_iter().collect(),
211            reason,
212        }
213    }
214
215    pub fn one(node: NodeId, reason: NodeInternalsInvalidationReason) -> Self {
216        Self::new([node], reason)
217    }
218}
219
220/// Freshness state for the latest node-internal measurement facts.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
222#[serde(rename_all = "snake_case", tag = "state")]
223pub enum NodeMeasurementStatus {
224    Missing,
225    Fresh {
226        revision: u64,
227    },
228    Dirty {
229        revision: u64,
230        reason: NodeInternalsInvalidationReason,
231    },
232}
233
234impl NodeMeasurementStatus {
235    pub fn is_fresh(self) -> bool {
236        matches!(self, Self::Fresh { .. })
237    }
238
239    pub fn is_dirty(self) -> bool {
240        matches!(self, Self::Dirty { .. })
241    }
242}
243
244/// Why handle geometry fell back instead of using measured node internals.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case")]
247pub enum NodeHandleFallbackReason {
248    MissingMeasurement,
249    DirtyMeasurement,
250    MissingHandle,
251}
252
253/// Source used for resolving handle geometry inside a node.
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case", tag = "source")]
256pub enum NodeHandleMeasurementSource {
257    MeasuredHandle,
258    MeasuredAnchor { anchor: String },
259    Fallback { reason: NodeHandleFallbackReason },
260}
261
262/// Adapter/query-facing explanation of local handle geometry resolution.
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264pub struct NodeHandleMeasurementResolution {
265    pub handle: ConnectionHandleRef,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub bounds: Option<HandleBounds>,
268    pub source: NodeHandleMeasurementSource,
269    pub status: NodeMeasurementStatus,
270}
271
272/// Result of applying measurement facts to runtime lookups.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum NodeMeasurementOutcome {
275    Changed,
276    Unchanged,
277}
278
279impl NodeMeasurementOutcome {
280    pub fn changed(self) -> bool {
281        matches!(self, Self::Changed)
282    }
283}
284
285/// Adapter-facing facade for node-internal geometry lifecycle updates.
286///
287/// This is the headless equivalent of toolkits such as React Flow asking users to update node
288/// internals after handles or child widgets move. It keeps the runtime contract explicit while
289/// avoiding duplicated adapter code for "mark dirty, report real geometry, query status".
290pub struct NodeInternalsController<'a> {
291    store: &'a mut NodeGraphStore,
292}
293
294impl<'a> NodeInternalsController<'a> {
295    pub(crate) fn new(store: &'a mut NodeGraphStore) -> Self {
296        Self { store }
297    }
298
299    /// Marks one or more node internals dirty until the adapter reports fresh geometry.
300    pub fn invalidate(
301        &mut self,
302        invalidation: NodeInternalsInvalidation,
303    ) -> NodeMeasurementOutcome {
304        self.store.invalidate_node_internals(invalidation)
305    }
306
307    /// Marks a single node dirty until the adapter reports fresh geometry.
308    pub fn invalidate_one(
309        &mut self,
310        node: NodeId,
311        reason: NodeInternalsInvalidationReason,
312    ) -> NodeMeasurementOutcome {
313        self.invalidate(NodeInternalsInvalidation::one(node, reason))
314    }
315
316    /// Reports freshly measured node internals from adapter widget/layout geometry.
317    pub fn report(
318        &mut self,
319        measurement: NodeMeasurement,
320    ) -> Result<NodeMeasurementOutcome, NodeMeasurementError> {
321        self.store.report_node_measurement(measurement)
322    }
323
324    /// Returns whether the latest geometry facts are fresh, dirty, or missing.
325    pub fn status(&self, node: NodeId) -> NodeMeasurementStatus {
326        self.store.node_measurement_status(node)
327    }
328
329    /// Resolves local handle geometry from the latest fresh measurement facts.
330    pub fn resolve_handle(&self, handle: ConnectionHandleRef) -> NodeHandleMeasurementResolution {
331        self.store.resolve_node_handle_measurement(handle)
332    }
333
334    /// Reads the adapter-facing layout facts after dirty/fresh state has been applied.
335    pub fn layout_facts(&self, viewport_size: CanvasSize) -> LayoutFactsQueryResult {
336        self.store.layout_facts_query(viewport_size)
337    }
338}
339
340#[derive(Debug, thiserror::Error)]
341pub enum NodeMeasurementError {
342    #[error("measurement target node does not exist: {0:?}")]
343    MissingNode(NodeId),
344    #[error("measurement size is not positive and finite for node {node:?}: {size:?}")]
345    InvalidSize { node: NodeId, size: CanvasSize },
346    #[error("measurement handle does not belong to node {node:?}: {handle:?}")]
347    InvalidHandle {
348        node: NodeId,
349        handle: ConnectionHandleRef,
350    },
351    #[error("measurement handle bounds are not positive and finite for node {node:?}: {handle:?}")]
352    InvalidHandleBounds {
353        node: NodeId,
354        handle: ConnectionHandleRef,
355    },
356    #[error("measurement slot rect is not positive and finite for node {node:?}: {slot}")]
357    InvalidSlotRect { node: NodeId, slot: String },
358    #[error("measurement anchor rect is not positive and finite for node {node:?}: {anchor}")]
359    InvalidAnchorRect { node: NodeId, anchor: String },
360    #[error("measurement anchor target does not belong to node {node:?}: {anchor}")]
361    InvalidAnchorTarget { node: NodeId, anchor: String },
362}
363
364/// Resolved endpoint geometry for one visible edge in a layout-facts query.
365#[derive(Debug, Clone, Copy, PartialEq)]
366pub struct LayoutEdgePosition {
367    pub edge: EdgeId,
368    pub position: EdgePosition,
369}
370
371impl LayoutEdgePosition {
372    pub fn new(edge: EdgeId, position: EdgePosition) -> Self {
373        Self { edge, position }
374    }
375}
376
377/// Resolved route, path, and interaction facts for one visible edge.
378#[derive(Debug, Clone, PartialEq)]
379pub struct LayoutEdgeRouteFacts {
380    pub edge: EdgeId,
381    pub facts: EdgeRouteFacts,
382}
383
384impl LayoutEdgeRouteFacts {
385    pub fn new(edge: EdgeId, facts: EdgeRouteFacts) -> Self {
386        Self { edge, facts }
387    }
388}
389
390/// Measurement status for one visible node in a layout-facts query.
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
392pub struct LayoutNodeMeasurementStatus {
393    pub node: NodeId,
394    pub status: NodeMeasurementStatus,
395}
396
397impl LayoutNodeMeasurementStatus {
398    pub fn new(node: NodeId, status: NodeMeasurementStatus) -> Self {
399        Self { node, status }
400    }
401}
402
403/// Store-level layout facts derived from the graph, view state, and reported measurements.
404#[derive(Debug, Clone, PartialEq)]
405pub struct LayoutFactsQueryResult {
406    pub revision: u64,
407    pub rendering: RenderingQueryResult,
408    pub visible_edge_positions: Vec<LayoutEdgePosition>,
409    pub visible_edge_route_facts: Vec<LayoutEdgeRouteFacts>,
410    pub connection_target_candidates: Vec<ConnectionTargetCandidate>,
411    pub node_measurement_statuses: Vec<LayoutNodeMeasurementStatus>,
412}
413
414impl LayoutFactsQueryResult {
415    pub fn new(
416        revision: u64,
417        rendering: RenderingQueryResult,
418        visible_edge_positions: Vec<LayoutEdgePosition>,
419        connection_target_candidates: Vec<ConnectionTargetCandidate>,
420    ) -> Self {
421        Self {
422            revision,
423            rendering,
424            visible_edge_positions,
425            visible_edge_route_facts: Vec::new(),
426            connection_target_candidates,
427            node_measurement_statuses: Vec::new(),
428        }
429    }
430
431    pub fn with_edge_route_facts(
432        mut self,
433        facts: impl IntoIterator<Item = LayoutEdgeRouteFacts>,
434    ) -> Self {
435        self.visible_edge_route_facts = facts.into_iter().collect();
436        self
437    }
438
439    pub fn with_node_measurement_statuses(
440        mut self,
441        statuses: impl IntoIterator<Item = LayoutNodeMeasurementStatus>,
442    ) -> Self {
443        self.node_measurement_statuses = statuses.into_iter().collect();
444        self
445    }
446
447    pub fn visible_edge_position(&self, edge: EdgeId) -> Option<EdgePosition> {
448        self.visible_edge_positions
449            .iter()
450            .find(|position| position.edge == edge)
451            .map(|position| position.position)
452    }
453
454    pub fn visible_edge_route_facts(&self, edge: EdgeId) -> Option<&EdgeRouteFacts> {
455        self.visible_edge_route_facts
456            .iter()
457            .find(|facts| facts.edge == edge)
458            .map(|facts| &facts.facts)
459    }
460
461    pub fn node_measurement_status(&self, node: NodeId) -> NodeMeasurementStatus {
462        self.node_measurement_statuses
463            .iter()
464            .find(|status| status.node == node)
465            .map(|status| status.status)
466            .unwrap_or(NodeMeasurementStatus::Missing)
467    }
468}
469
470impl NodeGraphStore {
471    /// Returns a short-lived controller for adapter node-internal geometry lifecycle updates.
472    pub fn node_internals(&mut self) -> NodeInternalsController<'_> {
473        NodeInternalsController::new(self)
474    }
475
476    /// Applies non-persisted renderer measurements for one node.
477    pub fn report_node_measurement(
478        &mut self,
479        measurement: NodeMeasurement,
480    ) -> Result<NodeMeasurementOutcome, NodeMeasurementError> {
481        let measurement = self.validate_node_measurement(measurement)?;
482        let Some(entry) = self.lookups_mut().node_lookup.get_mut(&measurement.node) else {
483            return Err(NodeMeasurementError::MissingNode(measurement.node));
484        };
485
486        if entry.apply_measurement(&measurement) {
487            self.publish_layout_facts_changed();
488            Ok(NodeMeasurementOutcome::Changed)
489        } else {
490            Ok(NodeMeasurementOutcome::Unchanged)
491        }
492    }
493
494    /// Clears non-persisted measurements for one node.
495    pub fn clear_node_measurement(&mut self, node: NodeId) -> NodeMeasurementOutcome {
496        let Some(entry) = self.lookups_mut().node_lookup.get_mut(&node) else {
497            return NodeMeasurementOutcome::Unchanged;
498        };
499
500        if entry.clear_measurement() {
501            self.publish_layout_facts_changed();
502            NodeMeasurementOutcome::Changed
503        } else {
504            NodeMeasurementOutcome::Unchanged
505        }
506    }
507
508    /// Marks one or more nodes as requiring adapter remeasurement.
509    ///
510    /// Existing size facts remain available so unsized runtime-only nodes do not disappear between
511    /// render passes, but stale handles and anchors are not used for endpoints or hit targets.
512    pub fn invalidate_node_internals(
513        &mut self,
514        invalidation: NodeInternalsInvalidation,
515    ) -> NodeMeasurementOutcome {
516        let mut changed = false;
517        for node in invalidation.nodes {
518            let Some(entry) = self.lookups_mut().node_lookup.get_mut(&node) else {
519                continue;
520            };
521            changed |= entry.mark_measurement_dirty(invalidation.reason);
522        }
523
524        if changed {
525            self.publish_layout_facts_changed();
526            NodeMeasurementOutcome::Changed
527        } else {
528            NodeMeasurementOutcome::Unchanged
529        }
530    }
531
532    /// Reads the current non-persisted measurement facts for one node.
533    pub fn node_measurement(&self, node: NodeId) -> Option<NodeMeasurement> {
534        self.lookups()
535            .node_lookup
536            .get(&node)
537            .and_then(|entry| entry.measurement(node))
538    }
539
540    /// Reads whether the latest measurement facts for a node are fresh, dirty, or missing.
541    pub fn node_measurement_status(&self, node: NodeId) -> NodeMeasurementStatus {
542        self.lookups()
543            .node_lookup
544            .get(&node)
545            .map(|entry| entry.measurement_status())
546            .unwrap_or(NodeMeasurementStatus::Missing)
547    }
548
549    /// Resolves local handle geometry from fresh measured handles or semantic anchors.
550    pub fn resolve_node_handle_measurement(
551        &self,
552        handle: ConnectionHandleRef,
553    ) -> NodeHandleMeasurementResolution {
554        resolve_handle_measurement(self.graph(), self.lookups(), handle)
555    }
556
557    /// Reads the adapter-facing layout facts for the current store state.
558    pub fn layout_facts_query(&self, viewport_size: CanvasSize) -> LayoutFactsQueryResult {
559        crate::runtime::query::layout_facts_query(self, viewport_size)
560    }
561
562    /// Builds renderer-neutral connection target candidates from reported handle measurements.
563    pub fn connection_target_candidates_from_layout_facts(&self) -> Vec<ConnectionTargetCandidate> {
564        crate::runtime::query::connection_target_candidates_from_layout_facts(self)
565    }
566
567    /// Resolves a connection target using the handle inventory previously reported by adapters.
568    pub fn resolve_connection_target_from_layout_facts(
569        &self,
570        pointer: CanvasPoint,
571        from: ConnectionHandleRef,
572    ) -> ResolvedConnectionTarget {
573        crate::runtime::query::resolve_connection_target_from_layout_facts(self, pointer, from)
574    }
575
576    /// Resolves edge endpoint geometry from graph endpoints plus reported measurement facts.
577    pub fn edge_position_from_layout_facts(&self, edge: EdgeId) -> Option<EdgePosition> {
578        crate::runtime::query::edge_position_from_layout_facts(self, edge)
579    }
580
581    fn validate_node_measurement(
582        &self,
583        measurement: NodeMeasurement,
584    ) -> Result<NodeMeasurement, NodeMeasurementError> {
585        if !self.graph().nodes().contains_key(&measurement.node) {
586            return Err(NodeMeasurementError::MissingNode(measurement.node));
587        }
588        if let Some(size) = measurement.size
589            && !size.is_positive_finite()
590        {
591            return Err(NodeMeasurementError::InvalidSize {
592                node: measurement.node,
593                size,
594            });
595        }
596
597        for measured in &measurement.handles {
598            if measured.handle.node != measurement.node {
599                return Err(NodeMeasurementError::InvalidHandle {
600                    node: measurement.node,
601                    handle: measured.handle,
602                });
603            }
604            if !measured.bounds.rect.is_positive_finite() {
605                return Err(NodeMeasurementError::InvalidHandleBounds {
606                    node: measurement.node,
607                    handle: measured.handle,
608                });
609            }
610            let Some(port) = self.graph().ports().get(&measured.handle.port) else {
611                return Err(NodeMeasurementError::InvalidHandle {
612                    node: measurement.node,
613                    handle: measured.handle,
614                });
615            };
616            if port.node != measurement.node || port.dir != measured.handle.direction {
617                return Err(NodeMeasurementError::InvalidHandle {
618                    node: measurement.node,
619                    handle: measured.handle,
620                });
621            }
622        }
623
624        for slot in &measurement.slots {
625            if !slot.rect.is_positive_finite() {
626                return Err(NodeMeasurementError::InvalidSlotRect {
627                    node: measurement.node,
628                    slot: slot.key.clone(),
629                });
630            }
631        }
632
633        for anchor in &measurement.anchors {
634            if !anchor.rect.is_positive_finite() {
635                return Err(NodeMeasurementError::InvalidAnchorRect {
636                    node: measurement.node,
637                    anchor: anchor.anchor.clone(),
638                });
639            }
640            let Some(port) = anchor
641                .port
642                .and_then(|port| self.graph().ports().get(&port).map(|model| (port, model)))
643            else {
644                if anchor.port.is_some() {
645                    return Err(NodeMeasurementError::InvalidAnchorTarget {
646                        node: measurement.node,
647                        anchor: anchor.anchor.clone(),
648                    });
649                }
650                continue;
651            };
652            if port.1.node != measurement.node {
653                return Err(NodeMeasurementError::InvalidAnchorTarget {
654                    node: measurement.node,
655                    anchor: anchor.anchor.clone(),
656                });
657            }
658            if let Some(port_key) = &anchor.port_key
659                && port_key != &port.1.key
660            {
661                return Err(NodeMeasurementError::InvalidAnchorTarget {
662                    node: measurement.node,
663                    anchor: anchor.anchor.clone(),
664                });
665            }
666            if !anchor_position_matches_direction(anchor.position, port.1.dir) {
667                return Err(NodeMeasurementError::InvalidAnchorTarget {
668                    node: measurement.node,
669                    anchor: anchor.anchor.clone(),
670                });
671            }
672        }
673
674        Ok(measurement)
675    }
676}
677
678pub(crate) fn resolve_handle_measurement(
679    graph: &Graph,
680    lookups: &NodeGraphLookups,
681    handle: ConnectionHandleRef,
682) -> NodeHandleMeasurementResolution {
683    let Some(entry) = lookups.node_lookup.get(&handle.node) else {
684        return NodeHandleMeasurementResolution {
685            handle,
686            bounds: None,
687            source: NodeHandleMeasurementSource::Fallback {
688                reason: NodeHandleFallbackReason::MissingMeasurement,
689            },
690            status: NodeMeasurementStatus::Missing,
691        };
692    };
693    let status = entry.measurement_status();
694    if !status.is_fresh() {
695        let reason = match status {
696            NodeMeasurementStatus::Missing => NodeHandleFallbackReason::MissingMeasurement,
697            NodeMeasurementStatus::Dirty { .. } => NodeHandleFallbackReason::DirtyMeasurement,
698            NodeMeasurementStatus::Fresh { .. } => NodeHandleFallbackReason::MissingHandle,
699        };
700        return NodeHandleMeasurementResolution {
701            handle,
702            bounds: None,
703            source: NodeHandleMeasurementSource::Fallback { reason },
704            status,
705        };
706    }
707    if let Some(measured) = entry
708        .measured_handles
709        .iter()
710        .find(|measured| measured.handle == handle)
711    {
712        return NodeHandleMeasurementResolution {
713            handle,
714            bounds: Some(measured.bounds),
715            source: NodeHandleMeasurementSource::MeasuredHandle,
716            status,
717        };
718    }
719
720    let anchor = graph.ports().get(&handle.port).and_then(|port| {
721        if port.node != handle.node || port.dir != handle.direction {
722            return None;
723        }
724        entry.measured_anchors.iter().find(|anchor| {
725            anchor.is_visible()
726                && (anchor.port == Some(handle.port) || anchor.port_key.as_ref() == Some(&port.key))
727                && anchor_position_matches_direction(anchor.position, port.dir)
728        })
729    });
730    if let Some(anchor) = anchor {
731        return NodeHandleMeasurementResolution {
732            handle,
733            bounds: Some(anchor.bounds()),
734            source: NodeHandleMeasurementSource::MeasuredAnchor {
735                anchor: anchor.anchor.clone(),
736            },
737            status,
738        };
739    }
740
741    NodeHandleMeasurementResolution {
742        handle,
743        bounds: None,
744        source: NodeHandleMeasurementSource::Fallback {
745            reason: NodeHandleFallbackReason::MissingHandle,
746        },
747        status,
748    }
749}
750
751pub(crate) fn anchor_position_matches_direction(
752    position: HandlePosition,
753    direction: PortDirection,
754) -> bool {
755    match direction {
756        PortDirection::In => matches!(position, HandlePosition::Left | HandlePosition::Top),
757        PortDirection::Out => matches!(position, HandlePosition::Right | HandlePosition::Bottom),
758    }
759}