Skip to main content

renamite_behavior_common/
lib.rs

1//! Shared context for tool behaviors: selection, view transform, snapping.
2
3pub mod align;
4pub mod assets;
5pub mod color;
6pub mod context_menu;
7pub mod fill;
8pub mod inspect;
9pub mod layers;
10pub mod machine;
11pub mod modifiers;
12pub mod path;
13pub mod stroke;
14
15use glam::DVec2;
16use renamite_animation::Frame;
17use renamite_model::{CompId, Document, NodeId};
18use serde::{Deserialize, Serialize};
19
20#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
21pub struct Selection {
22    pub nodes: Vec<NodeId>,
23    /// Optional focus target when editing a group/precomp's contents.
24    pub comp: Option<CompId>,
25}
26
27impl Selection {
28    pub fn is_empty(&self) -> bool {
29        self.nodes.is_empty()
30    }
31
32    pub fn contains(&self, id: NodeId) -> bool {
33        self.nodes.contains(&id)
34    }
35}
36
37/// Screen ↔ world mapping. Px tolerance in world units.
38#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
39pub struct ViewTransform {
40    pub scale: f64,
41    pub offset: DVec2,
42}
43
44impl ViewTransform {
45    pub fn identity() -> Self {
46        Self {
47            scale: 1.0,
48            offset: DVec2::ZERO,
49        }
50    }
51
52    pub fn screen_to_world(&self, p: DVec2) -> DVec2 {
53        (p - self.offset) / self.scale
54    }
55
56    pub fn world_to_screen(&self, p: DVec2) -> DVec2 {
57        p * self.scale + self.offset
58    }
59
60    /// Tolerance in world units for a sub-pixel screen tolerance (0.25px).
61    pub fn world_tolerance(&self, px: f64) -> f64 {
62        px / self.scale
63    }
64
65    /// Zoom about `screen_pos` by `factor`, clamped to `[min, max]`.
66    /// Shared by canvas viewport and machine graph to stay DRY.
67    pub fn zoom_at(&mut self, screen_pos: DVec2, factor: f64, min: f64, max: f64) {
68        let world = self.screen_to_world(screen_pos);
69        self.scale = (self.scale * factor).clamp(min, max);
70        self.offset = screen_pos - world * self.scale;
71    }
72
73    pub fn pan_by(&mut self, delta: DVec2) {
74        self.offset += delta;
75    }
76
77    /// Fit `artboard` inside `surface` with a margin, centering it.
78    /// Degenerate inputs are no-ops so an empty surface or composition
79    /// can never collapse the zoom.
80    pub fn fit(&mut self, surface: DVec2, artboard: DVec2) {
81        fit_view(self, surface, artboard);
82    }
83}
84
85/// Margin-fit shared by the editor viewport and the player embed:
86/// `artboard` inside `surface` with a 56 px margin, centered.
87/// No-op on degenerate inputs.
88pub fn fit_view(view: &mut ViewTransform, surface: DVec2, artboard: DVec2) {
89    if surface.x <= 1.0 || surface.y <= 1.0 || artboard.x <= 0.0 || artboard.y <= 0.0 {
90        return;
91    }
92    let margin = 56.0;
93    let available = (surface - DVec2::splat(margin * 2.0)).max(DVec2::splat(1.0));
94    let scale = (available.x / artboard.x)
95        .min(available.y / artboard.y)
96        .clamp(0.05, 32.0);
97    view.scale = scale;
98    view.offset = (surface - artboard * scale) * 0.5;
99}
100
101/// Exact-fit shared by presentational embeds: `artboard` fills `surface`
102/// with no margin, letterboxing inside the surface when aspects differ.
103/// No-op on degenerate inputs.
104pub fn fit_exact_view(view: &mut ViewTransform, surface: DVec2, artboard: DVec2) {
105    if surface.x <= 1.0 || surface.y <= 1.0 || artboard.x <= 0.0 || artboard.y <= 0.0 {
106        return;
107    }
108    let scale = (surface.x / artboard.x)
109        .min(surface.y / artboard.y)
110        .clamp(0.05, 64.0);
111    view.scale = scale;
112    view.offset = (surface - artboard * scale) * 0.5;
113}
114
115/// Fit-state tracker shared by the editor viewport and the player embed:
116/// remembers the last fitted surface (+ artboard) and only refits on
117/// resize or explicit invalidation, so interactive zoom survives redraws.
118#[derive(Clone, Copy, Debug, Default, PartialEq)]
119pub struct FitState {
120    surface: DVec2,
121    artboard: DVec2,
122    pub pending: bool,
123}
124
125impl FitState {
126    pub fn new() -> Self {
127        Self {
128            surface: DVec2::ZERO,
129            artboard: DVec2::ZERO,
130            pending: true,
131        }
132    }
133
134    /// Pre-seed the surface record (e.g. after an explicit `fit` call) so
135    /// zoom anchors work even when the artboard was degenerate.
136    pub fn with_surface(surface: DVec2) -> Self {
137        Self {
138            surface,
139            artboard: DVec2::ZERO,
140            pending: false,
141        }
142    }
143
144    /// Refit `view` when the surface/artboard changed beyond 0.5 px or a
145    /// fit was requested via [`FitState::request`]. Returns true when a
146    /// refit ran. Pan gestures opt out by skipping this call.
147    ///
148    /// `refit_on_resize`: the player embed refits on window resize
149    /// (`true`); the editor viewport keeps the user's zoom on resize
150    /// (`false`) and only refits on first layout, artboard change, or
151    /// explicit request.
152    pub fn ensure(
153        &mut self,
154        view: &mut ViewTransform,
155        surface: DVec2,
156        artboard: DVec2,
157        refit_on_resize: bool,
158    ) -> bool {
159        let resized = (surface - self.surface).abs().max_element() > 0.5;
160        let art_changed = (artboard - self.artboard).abs().max_element() > 0.5;
161        let first_layout = self.surface == DVec2::ZERO && surface != DVec2::ZERO;
162        self.surface = surface;
163        if self.pending || first_layout || art_changed || (refit_on_resize && resized) {
164            view.fit(surface, artboard);
165            self.artboard = artboard;
166            self.pending = false;
167            true
168        } else {
169            false
170        }
171    }
172
173    /// Request a refit on the next [`FitState::ensure`] call
174    /// (e.g. after a zoom-to-fit shortcut).
175    pub fn request(&mut self) {
176        self.pending = true;
177    }
178
179    pub fn surface_size(&self) -> DVec2 {
180        self.surface
181    }
182
183    /// Zoom about the surface center; no-op before the first layout.
184    pub fn zoom_centered(&self, view: &mut ViewTransform, factor: f64) {
185        if self.surface == DVec2::ZERO {
186            return;
187        }
188        self.zoom_at(view, self.surface * 0.5, factor);
189    }
190
191    /// Zoom about `screen_pos`; no-op before the first layout.
192    pub fn zoom_at(&self, view: &mut ViewTransform, screen_pos: DVec2, factor: f64) {
193        if self.surface == DVec2::ZERO {
194            return;
195        }
196        view.zoom_at(screen_pos, factor, 0.05, 64.0);
197    }
198}
199
200#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
201pub struct Modifiers {
202    pub shift: bool,
203    pub alt: bool,
204    pub ctrl: bool,
205}
206
207impl Modifiers {
208    pub fn none() -> Self {
209        Self {
210            shift: false,
211            alt: false,
212            ctrl: false,
213        }
214    }
215}
216
217#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
218pub struct SnapConfig {
219    pub grid: Option<f64>,
220    pub anchor: bool,
221    pub guide: bool,
222}
223
224pub struct ToolContext<'a> {
225    pub doc: &'a Document,
226    /// Evaluated frame currently on screen - the hit-test surface.
227    pub scene: &'a renamite_model::Scene,
228    pub comp: CompId,
229    pub selection: &'a Selection,
230    pub playhead: Frame,
231    pub record: bool,
232    pub view: ViewTransform,
233    pub snap: SnapConfig,
234    pub modifiers: Modifiers,
235    /// Current paint used by the Fill tool (set from Properties or a future picker).
236    pub current_paint: &'a renamite_model::StylePaint,
237}