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 edit;
8pub mod fill;
9pub mod inspect;
10pub mod layers;
11pub mod machine;
12pub mod modifiers;
13pub mod path;
14pub mod pathfinder;
15pub mod snap;
16pub mod stroke;
17
18use glam::DVec2;
19use renamite_animation::Frame;
20use renamite_model::{CompId, Document, NodeId};
21use serde::{Deserialize, Serialize};
22
23#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
24pub struct Selection {
25    pub nodes: Vec<NodeId>,
26    /// Optional focus target when editing a group/precomp's contents.
27    pub comp: Option<CompId>,
28}
29
30impl Selection {
31    pub fn is_empty(&self) -> bool {
32        self.nodes.is_empty()
33    }
34
35    pub fn contains(&self, id: NodeId) -> bool {
36        self.nodes.contains(&id)
37    }
38}
39
40/// Screen ↔ world mapping. Px tolerance in world units.
41#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
42pub struct ViewTransform {
43    pub scale: f64,
44    pub offset: DVec2,
45}
46
47impl ViewTransform {
48    pub fn identity() -> Self {
49        Self {
50            scale: 1.0,
51            offset: DVec2::ZERO,
52        }
53    }
54
55    fn usable_scale(&self) -> f64 {
56        if self.scale.is_finite() && self.scale > 1e-12 {
57            self.scale
58        } else {
59            1.0
60        }
61    }
62
63    pub fn screen_to_world(&self, p: DVec2) -> DVec2 {
64        if !p.is_finite() {
65            return DVec2::ZERO;
66        }
67        let offset = if self.offset.is_finite() {
68            self.offset
69        } else {
70            DVec2::ZERO
71        };
72        (p - offset) / self.usable_scale()
73    }
74
75    pub fn world_to_screen(&self, p: DVec2) -> DVec2 {
76        if !p.is_finite() {
77            return DVec2::ZERO;
78        }
79        let offset = if self.offset.is_finite() {
80            self.offset
81        } else {
82            DVec2::ZERO
83        };
84        p * self.usable_scale() + offset
85    }
86
87    /// Tolerance in world units for a sub-pixel screen tolerance (0.25px).
88    pub fn world_tolerance(&self, px: f64) -> f64 {
89        if !px.is_finite() || px < 0.0 {
90            return 0.0;
91        }
92        px / self.usable_scale()
93    }
94
95    /// Zoom about `screen_pos` by `factor`, clamped to `[min, max]`.
96    /// Shared by canvas viewport and machine graph to stay DRY.
97    pub fn zoom_at(&mut self, screen_pos: DVec2, factor: f64, min: f64, max: f64) {
98        if !screen_pos.is_finite()
99            || !factor.is_finite()
100            || factor <= 0.0
101            || !min.is_finite()
102            || !max.is_finite()
103            || min <= 0.0
104            || max < min
105        {
106            return;
107        }
108        let current = self.usable_scale();
109        let next = (current * factor).clamp(min, max);
110        if !next.is_finite() || next <= 0.0 {
111            return;
112        }
113        let offset = if self.offset.is_finite() {
114            self.offset
115        } else {
116            DVec2::ZERO
117        };
118        let world = (screen_pos - offset) / current;
119        self.scale = next;
120        self.offset = screen_pos - world * next;
121    }
122
123    pub fn pan_by(&mut self, delta: DVec2) {
124        if delta.is_finite() && self.offset.is_finite() {
125            self.offset += delta;
126        }
127    }
128
129    /// Fit `artboard` inside `surface` with a margin, centering it.
130    /// Degenerate inputs are no-ops so an empty surface or composition
131    /// can never collapse the zoom.
132    pub fn fit(&mut self, surface: DVec2, artboard: DVec2) {
133        fit_view(self, surface, artboard);
134    }
135}
136
137/// Margin-fit shared by the editor viewport and the player embed:
138/// `artboard` inside `surface` with a 56 px margin, centered.
139/// No-op on degenerate inputs.
140pub fn fit_view(view: &mut ViewTransform, surface: DVec2, artboard: DVec2) {
141    if !surface.is_finite()
142        || !artboard.is_finite()
143        || surface.x <= 1.0
144        || surface.y <= 1.0
145        || artboard.x <= 0.0
146        || artboard.y <= 0.0
147    {
148        return;
149    }
150    let margin = 56.0;
151    let available = (surface - DVec2::splat(margin * 2.0)).max(DVec2::splat(1.0));
152    let scale = (available.x / artboard.x)
153        .min(available.y / artboard.y)
154        .clamp(0.05, 32.0);
155    view.scale = scale;
156    view.offset = (surface - artboard * scale) * 0.5;
157}
158
159/// Exact-fit shared by presentational embeds: `artboard` fills `surface`
160/// with no margin, letterboxing inside the surface when aspects differ.
161/// No-op on degenerate inputs.
162pub fn fit_exact_view(view: &mut ViewTransform, surface: DVec2, artboard: DVec2) {
163    if !surface.is_finite()
164        || !artboard.is_finite()
165        || surface.x <= 1.0
166        || surface.y <= 1.0
167        || artboard.x <= 0.0
168        || artboard.y <= 0.0
169    {
170        return;
171    }
172    let scale = (surface.x / artboard.x)
173        .min(surface.y / artboard.y)
174        .clamp(0.05, 64.0);
175    view.scale = scale;
176    view.offset = (surface - artboard * scale) * 0.5;
177}
178
179/// Fit-state tracker shared by the editor viewport and the player embed:
180/// remembers the last fitted surface (+ artboard) and only refits on
181/// resize or explicit invalidation, so interactive zoom survives redraws.
182#[derive(Clone, Copy, Debug, Default, PartialEq)]
183pub struct FitState {
184    surface: DVec2,
185    artboard: DVec2,
186    pub pending: bool,
187}
188
189impl FitState {
190    pub fn new() -> Self {
191        Self {
192            surface: DVec2::ZERO,
193            artboard: DVec2::ZERO,
194            pending: true,
195        }
196    }
197
198    /// Pre-seed the surface record (e.g. after an explicit `fit` call) so
199    /// zoom anchors work even when the artboard was degenerate.
200    pub fn with_surface(surface: DVec2) -> Self {
201        let surface = if surface.is_finite() {
202            surface
203        } else {
204            DVec2::ZERO
205        };
206        Self {
207            surface,
208            artboard: DVec2::ZERO,
209            pending: false,
210        }
211    }
212
213    /// Refit `view` when the surface/artboard changed beyond 0.5 px or a
214    /// fit was requested via [`FitState::request`]. Returns true when a
215    /// refit ran. Pan gestures opt out by skipping this call.
216    ///
217    /// `refit_on_resize`: the player embed refits on window resize
218    /// (`true`); the editor viewport keeps the user's zoom on resize
219    /// (`false`) and only refits on first layout, artboard change, or
220    /// explicit request.
221    pub fn ensure(
222        &mut self,
223        view: &mut ViewTransform,
224        surface: DVec2,
225        artboard: DVec2,
226        refit_on_resize: bool,
227    ) -> bool {
228        if !surface.is_finite() || !artboard.is_finite() {
229            return false;
230        }
231        let resized = (surface - self.surface).abs().max_element() > 0.5;
232        let art_changed = (artboard - self.artboard).abs().max_element() > 0.5;
233        let first_layout = self.surface == DVec2::ZERO && surface != DVec2::ZERO;
234        self.surface = surface;
235        if self.pending || first_layout || art_changed || (refit_on_resize && resized) {
236            view.fit(surface, artboard);
237            self.artboard = artboard;
238            self.pending = false;
239            true
240        } else {
241            false
242        }
243    }
244
245    /// Request a refit on the next [`FitState::ensure`] call
246    /// (e.g. after a zoom-to-fit shortcut).
247    pub fn request(&mut self) {
248        self.pending = true;
249    }
250
251    pub fn surface_size(&self) -> DVec2 {
252        self.surface
253    }
254
255    /// Zoom about the surface center; no-op before the first layout.
256    pub fn zoom_centered(&self, view: &mut ViewTransform, factor: f64) {
257        if self.surface == DVec2::ZERO {
258            return;
259        }
260        self.zoom_at(view, self.surface * 0.5, factor);
261    }
262
263    /// Zoom about `screen_pos`; no-op before the first layout.
264    pub fn zoom_at(&self, view: &mut ViewTransform, screen_pos: DVec2, factor: f64) {
265        if self.surface == DVec2::ZERO {
266            return;
267        }
268        view.zoom_at(screen_pos, factor, 0.05, 64.0);
269    }
270}
271
272/// Stable zoom anchor for one multi-touch transform.
273///
274/// The platform reports each frame's *live* gesture centroid. Scaling about a
275/// centroid that keeps moving makes the fixed point creep toward whichever
276/// finger is holding still: with two contacts the midpoint slides at half the
277/// moving finger's travel, so every frame re-anchors somewhere new and the
278/// content the user is pinching appears to slide out from under their fingers.
279///
280/// Anchoring instead at the centroid captured when the contact set settled
281/// keeps it put. Per frame the view scales about the anchor and then follows
282/// the centroid's own translation, which carries the anchored world point by
283/// exactly `delta` and by nothing else.
284///
285/// Each surface owns one and feeds it the gesture centre already converted
286/// into its own space; it must call [`GestureAnchor::end`] when the gesture
287/// ends so the next one latches its own point.
288#[derive(Clone, Copy, Debug, Default, PartialEq)]
289pub struct GestureAnchor {
290    anchored: Option<DVec2>,
291}
292
293impl GestureAnchor {
294    pub fn new() -> Self {
295        Self::default()
296    }
297
298    /// Latch `local` as this gesture's zoom anchor. Later calls are ignored
299    /// until [`GestureAnchor::end`], which is what stops a drifting centroid
300    /// from moving the fixed point.
301    pub fn begin(&mut self, local: DVec2) {
302        if self.anchored.is_none() {
303            self.anchored = Some(local);
304        }
305    }
306
307    /// The point to scale about, or `fallback` while no gesture is anchored
308    /// (a centre-less pinch, or a gesture that never reported a centre).
309    pub fn resolve(&self, fallback: DVec2) -> DVec2 {
310        self.anchored.unwrap_or(fallback)
311    }
312
313    /// True while a gesture is anchored.
314    pub fn is_anchored(&self) -> bool {
315        self.anchored.is_some()
316    }
317
318    /// Release the anchor, so the next gesture latches its own centre.
319    pub fn end(&mut self) {
320        self.anchored = None;
321    }
322}
323
324#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
325pub struct Modifiers {
326    pub shift: bool,
327    pub alt: bool,
328    pub ctrl: bool,
329}
330
331impl Modifiers {
332    pub fn none() -> Self {
333        Self {
334            shift: false,
335            alt: false,
336            ctrl: false,
337        }
338    }
339}
340
341#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
342pub struct SnapConfig {
343    pub grid: Option<f64>,
344    pub anchor: bool,
345    pub guide: bool,
346}
347
348pub struct ToolContext<'a> {
349    pub doc: &'a Document,
350    /// Evaluated frame currently on screen - the hit-test surface.
351    pub scene: &'a renamite_model::Scene,
352    pub comp: CompId,
353    pub selection: &'a Selection,
354    pub playhead: Frame,
355    pub record: bool,
356    pub view: ViewTransform,
357    pub snap: SnapConfig,
358    /// Live guide positions for snapping: `(horizontal, position)`.
359    /// `(true, y)` is a horizontal guide, `(false, x)` is vertical.
360    pub guides: &'a [(bool, f64)],
361    pub modifiers: Modifiers,
362    /// Current paint used by the Fill tool (set from Properties or a future picker).
363    pub current_paint: &'a renamite_model::StylePaint,
364}