git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitb31758b45ec29c3b23716d83ecba127df9c265c0
parent776b879e79
authorLucas Galante <[email protected]>
date2026-07-31 13:01
feat: pinch-to-zoom in the 3D viewport

Trackpad pinches now zoom the viewport camera 1:1 via the engine's new
Application::handle_pinch hook (spreading fingers 2x halves the camera
distance), feeding the existing zoom-inertia accumulator so the release
glide matches ctrl-wheel zooms. Pinches outside the viewport fall back
to the engine's ctrl+wheel synthesis, so the graph zooms as before.

Also routes ctrl+wheel over the viewport to the widget's zoom branch —
the routed wheel pass skips ctrl'd events, so that branch was
unreachable (ctrl+scroll zoom in the viewport did nothing).

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/app.rs         | 35 +++++++++++++++++++++++++++++++----
 src/application.rs | 13 +++++++++++++
 src/viewport_3d.rs | 16 ++++++++++++++++
 3 files changed, 60 insertions(+), 4 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 4c9e385..9d38793 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1282,6 +1282,16 @@ impl State {
         }
     }
 
+    /// Whether the cursor sits over the 3D viewport pane — the wheel arm's
+    /// routing test, shared with `handle_pinch`.
+    pub fn cursor_in_viewport(&self) -> bool {
+        let node_area_y = self.positions[CONTENT_IDX].1;
+        self.cursor_x >= self.content_right_x()
+            && self.cursor_x < self.splitter_layout.splitter2_x
+            && self.cursor_y >= node_area_y
+            && self.cursor_y < self.height - STATUS_H
+    }
+
     // --- Pane edge-resize hotspots. Each is the single source of truth for its zone:
     // the press handlers arm the matching `AppDrag` off it, and `pane_resize_cursor`
     // shows the resize cursor over it, so the two can't drift apart.
@@ -3765,10 +3775,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 // eprintln!("DEBUG MOUSEWHEEL: delta={:?}, phase={:?}, cursor=({}, {}), in_network_pane={}", delta, phase, self.cursor_x, self.cursor_y, in_network_pane);
                 let node_area_y = self.positions[CONTENT_IDX].1;
 
-                let in_viewport = self.cursor_x >= self.content_right_x()
-                    && self.cursor_x < self.splitter_layout.splitter2_x
-                    && self.cursor_y >= node_area_y
-                    && self.cursor_y < self.height - STATUS_H;
+                let in_viewport = self.cursor_in_viewport();
 
                 let mut focus_changed = false;
                 let mut new_pane = None;
@@ -3923,6 +3930,26 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                             }
                         }
                     }
+                } else if in_viewport && self.modifiers.control_key() {
+                    // Ctrl+wheel zoom over the 3D viewport. The routed pass
+                    // above deliberately skips wheels while ctrl is held, so
+                    // hand the event to the viewport widget here — its ctrl
+                    // branch is the camera zoom. (Trackpad pinches take the
+                    // direct `handle_pinch` path and never reach this.)
+                    self.slots.get_dyn_mut(VIEWPORT_IDX).set_modifiers(
+                        self.modifiers.control_key(),
+                        self.modifiers.shift_key(),
+                        self.modifiers.alt_key(),
+                    );
+                    let wheel_ev = cce_ui::widget::Event::MouseWheel {
+                        delta: *delta,
+                        x: self.cursor_x,
+                        y: self.cursor_y,
+                        local_x: self.cursor_x,
+                        local_y: self.cursor_y,
+                    };
+                    let vp = self.slots.viewport.id();
+                    self.ui_context.propagate_event(&wheel_ev, vp)
                 } else {
                     false
                 };
diff --git a/src/application.rs b/src/application.rs
index f67e350..d0a873c 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -276,6 +276,19 @@ impl Application for State {
         }
     }
 
+    fn handle_pinch(&mut self, factor: f32, pos: LogicalPosition, needs_rebuild: &mut bool) -> bool {
+        self.cursor_x = pos.x;
+        self.cursor_y = pos.y;
+        // 1:1 camera zoom over the 3D viewport; anywhere else falls back to
+        // the engine's ctrl+wheel synthesis (which is what zooms the graph).
+        if self.is_detached_network || !self.cursor_in_viewport() {
+            return false;
+        }
+        self.viewport_mut().pinch_zoom(factor);
+        *needs_rebuild = true;
+        true
+    }
+
     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<CustomEvent> {
         self.sync_modifiers_from_ctx();
         let ev = WindowEvent::KeyboardInput { event: event.clone() };
diff --git a/src/viewport_3d.rs b/src/viewport_3d.rs
index b3399ff..7ab6538 100644
--- a/src/viewport_3d.rs
+++ b/src/viewport_3d.rs
@@ -127,6 +127,22 @@ impl Viewport3D {
         self.scroll_lock = 0;
     }
 
+    /// Trackpad pinch: direct-manipulation camera zoom. `factor` is the
+    /// scale change since the last gesture update (engine `handle_pinch`
+    /// semantics), applied 1:1 — spreading fingers 2x halves the camera
+    /// distance. Feeds the same accumulator as the ctrl-wheel zoom so the
+    /// release inertia matches.
+    pub fn pinch_zoom(&mut self, factor: f32) {
+        if factor <= 0.0 {
+            return;
+        }
+        let dy = factor.ln();
+        self.zoom = (self.zoom * (-dy).exp()).clamp(0.05, 20.0);
+        self.is_zooming = true;
+        self.last_zoom_time = std::time::Instant::now();
+        self.zoom_accum += dy;
+    }
+
     pub fn get_matrices(&self, aspect: f32, custom_camera_pos: Option<Vec3>, custom_camera_rot: Option<Vec3>, custom_pivot: Option<Vec3>) -> (Mat4, Mat4, Mat4) {
         let camera_pos = custom_camera_pos.unwrap_or(Vec3::new(2.5, 1.8, 2.5));
         let pivot = custom_pivot.unwrap_or(Vec3::ZERO);