Skip to main content

jellyflow_runtime/runtime/viewport/
transform.rs

1use serde::{Deserialize, Serialize};
2
3use crate::io::NodeGraphViewState;
4use jellyflow_core::core::{CanvasPoint, CanvasRect, CanvasSize};
5
6/// Current viewport transform.
7///
8/// `pan` is stored in canvas space and `zoom` is a positive scale factor. Screen projection follows
9/// `(canvas + pan) * zoom`, matching the existing fit-view helpers and persisted view-state.
10#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
11pub struct ViewportTransform {
12    /// Canvas-space pan.
13    pub pan: CanvasPoint,
14    /// Positive zoom factor.
15    pub zoom: f32,
16}
17
18impl ViewportTransform {
19    /// Creates a viewport transform when pan is finite and zoom is positive finite.
20    pub fn new(pan: CanvasPoint, zoom: f32) -> Option<Self> {
21        let transform = Self { pan, zoom };
22        if !transform.is_valid() {
23            return None;
24        }
25
26        Some(transform)
27    }
28
29    /// Returns true when the transform can safely participate in viewport math.
30    pub fn is_valid(self) -> bool {
31        self.pan.is_finite() && valid_zoom(self.zoom)
32    }
33
34    /// Reads the viewport transform from a persisted view-state.
35    pub fn from_view_state(view_state: &NodeGraphViewState) -> Option<Self> {
36        Self::new(view_state.pan, view_state.zoom)
37    }
38
39    /// Projects a canvas point into logical screen pixels.
40    pub fn screen_point_for_canvas(self, canvas: CanvasPoint) -> Option<CanvasPoint> {
41        if !self.is_valid() || !canvas.is_finite() {
42            return None;
43        }
44
45        let screen = CanvasPoint {
46            x: (canvas.x + self.pan.x) * self.zoom,
47            y: (canvas.y + self.pan.y) * self.zoom,
48        };
49        screen.is_finite().then_some(screen)
50    }
51
52    /// Converts a logical screen-pixel point to canvas space.
53    pub fn canvas_point_at_screen(self, screen: CanvasPoint) -> CanvasPoint {
54        CanvasPoint {
55            x: screen.x / self.zoom - self.pan.x,
56            y: screen.y / self.zoom - self.pan.y,
57        }
58    }
59}
60
61/// Optional pan constraints for a viewport transform.
62#[derive(Debug, Default, Clone, Copy, PartialEq)]
63pub struct ViewportConstraints {
64    pub viewport_size: Option<CanvasSize>,
65    pub translate_extent: Option<CanvasRect>,
66}
67
68impl ViewportConstraints {
69    pub fn unconstrained() -> Self {
70        Self::default()
71    }
72
73    pub fn with_translate_extent(viewport_size: CanvasSize, translate_extent: CanvasRect) -> Self {
74        Self {
75            viewport_size: Some(viewport_size),
76            translate_extent: Some(translate_extent),
77        }
78    }
79}
80
81/// Renderer-neutral drag-pan request.
82#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
83pub struct ViewportPanRequest {
84    /// Logical screen-pixel delta for the content movement.
85    pub screen_delta: CanvasPoint,
86}
87
88impl ViewportPanRequest {
89    pub fn new(screen_delta: CanvasPoint) -> Self {
90        Self { screen_delta }
91    }
92}
93
94/// Renderer-neutral zoom request anchored at a logical screen-pixel point.
95#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
96pub struct ViewportZoomRequest {
97    /// Logical screen-pixel point that should keep the same canvas coordinate while zooming.
98    pub anchor_screen: CanvasPoint,
99    /// Desired zoom before clamping.
100    pub target_zoom: f32,
101    /// Inclusive minimum zoom clamp.
102    pub min_zoom: f32,
103    /// Inclusive maximum zoom clamp.
104    pub max_zoom: f32,
105}
106
107impl ViewportZoomRequest {
108    pub fn new(anchor_screen: CanvasPoint, target_zoom: f32, min_zoom: f32, max_zoom: f32) -> Self {
109        Self {
110            anchor_screen,
111            target_zoom,
112            min_zoom,
113            max_zoom,
114        }
115    }
116}
117
118/// Applies a drag-pan request to the current transform.
119pub fn pan_viewport(
120    current: ViewportTransform,
121    request: ViewportPanRequest,
122) -> Option<ViewportTransform> {
123    if !current.is_valid() {
124        return None;
125    }
126    if !request.screen_delta.is_finite() {
127        return None;
128    }
129
130    ViewportTransform::new(
131        CanvasPoint {
132            x: current.pan.x + request.screen_delta.x / current.zoom,
133            y: current.pan.y + request.screen_delta.y / current.zoom,
134        },
135        current.zoom,
136    )
137    .and_then(|next| constrain_viewport(next, ViewportConstraints::unconstrained()))
138}
139
140/// Applies an anchored zoom request to the current transform.
141pub fn zoom_viewport(
142    current: ViewportTransform,
143    request: ViewportZoomRequest,
144) -> Option<ViewportTransform> {
145    if !current.is_valid() {
146        return None;
147    }
148    let target_zoom = clamped_target_zoom(request)?;
149    let anchor = request.anchor_screen;
150    if !anchor.is_finite() {
151        return None;
152    }
153
154    ViewportTransform::new(
155        CanvasPoint {
156            x: current.pan.x + anchor.x / target_zoom - anchor.x / current.zoom,
157            y: current.pan.y + anchor.y / target_zoom - anchor.y / current.zoom,
158        },
159        target_zoom,
160    )
161    .and_then(|next| constrain_viewport(next, ViewportConstraints::unconstrained()))
162}
163
164/// Applies optional translate-extent constraints to a viewport transform.
165pub fn constrain_viewport(
166    transform: ViewportTransform,
167    constraints: ViewportConstraints,
168) -> Option<ViewportTransform> {
169    if !transform.is_valid() {
170        return None;
171    }
172    let Some(translate_extent) = constraints.translate_extent else {
173        return Some(transform);
174    };
175    let viewport_size = constraints.viewport_size?;
176    if !viewport_size.is_positive_finite() || !translate_extent.is_positive_finite() {
177        return None;
178    }
179
180    let visible_width = viewport_size.width / transform.zoom;
181    let visible_height = viewport_size.height / transform.zoom;
182    let extent_min_x = translate_extent.origin.x;
183    let extent_min_y = translate_extent.origin.y;
184    let extent_max_x = translate_extent.origin.x + translate_extent.size.width;
185    let extent_max_y = translate_extent.origin.y + translate_extent.size.height;
186
187    ViewportTransform::new(
188        CanvasPoint {
189            x: constrain_pan_axis(transform.pan.x, visible_width, extent_min_x, extent_max_x),
190            y: constrain_pan_axis(transform.pan.y, visible_height, extent_min_y, extent_max_y),
191        },
192        transform.zoom,
193    )
194}
195
196fn clamped_target_zoom(request: ViewportZoomRequest) -> Option<f32> {
197    if !valid_zoom(request.target_zoom)
198        || !valid_zoom(request.min_zoom)
199        || !valid_zoom(request.max_zoom)
200    {
201        return None;
202    }
203
204    let (min_zoom, max_zoom) = if request.min_zoom <= request.max_zoom {
205        (request.min_zoom, request.max_zoom)
206    } else {
207        (request.max_zoom, request.min_zoom)
208    };
209
210    Some(request.target_zoom.clamp(min_zoom, max_zoom))
211}
212
213fn constrain_pan_axis(pan: f32, visible_size: f32, extent_min: f32, extent_max: f32) -> f32 {
214    let lower = visible_size - extent_max;
215    let upper = -extent_min;
216    if lower <= upper {
217        pan.clamp(lower, upper)
218    } else {
219        (lower + upper) * 0.5
220    }
221}
222
223pub(super) fn valid_zoom(zoom: f32) -> bool {
224    zoom.is_finite() && zoom > 0.0
225}