graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat(viewport): dragging empty scene orbits the camera
The camera had no drag gesture at all: Viewport3D handles only
MouseWheel, so the scene turned by scrolling and by nothing else. That
suits a trackpad, which is what this DE is built around, and leaves a
mouse with no way to look around — and it is why a left-drag on the
scene did nothing when the network overlay started letting presses reach
it.
State::orbit_camera_by turns the camera by a drag delta, armed by a left
press that cursor_in_viewport says landed on scene.
ORBIT_RADIANS_PER_PX is the trackpad's own pixel-delta constant rather
than a number picked for drags, so a drag and a two-finger swipe turn the
scene at the same rate instead of feeling like two different cameras. The
default camera carries its orbit in rotation_x/rotation_y while a NAMED
camera accumulates into pending_yaw/pending_pitch for its node to pick
up — the same split the scroll path makes, so a dragged camera and a
scrolled one mean the same thing. A drag stops when the pointer does,
unlike a flicked scroll, which coasts.
Precedence is load-bearing, and the order is: the viewer state's press
hook first, so dragging a curve handle still edits it; then the node hit
tests, so a press on a node still moves the node; then this. "Empty"
means the scene really is what is under the cursor, which with the
network overlaying the whole window is exactly what in_network_pane's
node test decides.
Verified in a shadow rather than only in tests: a 140px drag turned the
active camera node's Rotation Y by 40.1 degrees, which is 140 * 0.005
radians, and the same drag started on a node moved the node and selected
it instead.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 36 +++++++++++++++++++++------
src/app.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.rs | 55 ++++++++++++++++++++++++++++++++++++++++
src/viewport_3d.rs | 2 +-
4 files changed, 157 insertions(+), 9 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 8034a94..1af162e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -491,14 +491,11 @@ That makes the pane's RECT useless as a hit test, and three things route off it:
- **`cursor_in_viewport`** becomes the complement: the body, minus what the
network holds, minus the floating panes.
-What changes for the user, and it is worth knowing: a plain click on empty
-space is no longer the network's. In the default docked layout nothing else
-claims it either (the scene is drawn full-bleed but the Viewport3D WIDGET's
-rect is only the centre column, which those layouts leave at zero width), so
-such a click does nothing. Deselecting by clicking empty space is the
-behaviour that costs. Giving the viewport that space was tried and reverted: it
-claimed the press and still did not orbit, and a half-working claim is worse
-than none.
+What changes for the user: a plain click on empty space is no longer the
+network's — so deselecting by clicking empty space is gone while the plate is
+off. It now ORBITS THE CAMERA instead (see below), which is what makes the
+overlay feel like a scene with a graph on it rather than a graph with a
+picture behind it.
`ViewportSettings::network_plate` persists it, beside the viewport toggles
rather than in the project's pane-state list: a pane's VISIBILITY belongs to
@@ -588,6 +585,29 @@ Houdini uses: bare hjkl is the cursor, and shift+hjkl is reserved for the
select family this app cannot implement until the Graph widget has
multi-selection, so taking `Shift+L` now would have to be given back later.
+### Dragging the scene orbits the camera
+
+`State::orbit_camera_by` turns the camera by a drag delta, armed by a left
+press that `cursor_in_viewport` says landed on scene. Before it the camera had
+NO drag gesture at all: `Viewport3D` handles only `MouseWheel`, so the scene
+turned by scrolling and by nothing else — which suits a trackpad and leaves a
+mouse with no way to look around.
+
+`ORBIT_RADIANS_PER_PX` is the trackpad's own pixel-delta constant, so a drag
+and a two-finger swipe turn the scene at the same rate rather than feeling like
+two different cameras. The default camera carries its orbit in
+`rotation_x`/`rotation_y`; a NAMED camera accumulates into
+`pending_yaw`/`pending_pitch` for its node to pick up — the same split the
+scroll path makes, so a dragged camera and a scrolled one mean the same thing.
+A drag stops when the pointer does (`reset_velocity`), unlike a flicked scroll,
+which coasts.
+
+Precedence matters and is load-bearing. The press arms AFTER the viewer state's
+own press hook, so dragging a curve handle still edits it, and after the node
+hit tests, so a press on a node still moves the node. "Empty" means the scene
+really is what is under the cursor — which, with the network overlaying the
+window, is exactly what `in_network_pane`'s node test decides.
+
### Commands, chords and the palette
`src/command.rs` is one list of everything the app can be asked to do. Each row
diff --git a/src/app.rs b/src/app.rs
index e85ee8b..a6f0253 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1125,6 +1125,9 @@ pub struct State {
/// first call is this process's own and every later one is a REPLACEMENT
/// after a reconnect. Remembering is the only way to tell them apart.
pub seen_renderer: bool,
+ /// An in-flight camera orbit drag: the cursor position the last motion was
+ /// measured from. `None` when no orbit drag is running.
+ pub orbit_drag: Option<(f32, f32)>,
/// Set when the page raster must be re-uploaded — after a replacement
/// renderer drops the old id. Consumed on the next tick rather than acted
/// on in `renderer_init`, which runs before the frame has settled and
@@ -1657,6 +1660,39 @@ impl State {
pub fn param_w(&self) -> f32 { self.get_col_geometries().5 }
+ /// Radians of camera rotation per logical pixel of drag.
+ ///
+ /// The same constant the trackpad's pixel-delta orbit uses, so a drag and a
+ /// two-finger swipe turn the scene at the same rate and the two gestures do
+ /// not feel like different cameras. A 300px drag is about 86 degrees.
+ pub const ORBIT_RADIANS_PER_PX: f32 = 0.005;
+
+ /// Turn the camera by a drag delta in logical pixels.
+ ///
+ /// Mirrors the scroll path's split: the default camera carries its own
+ /// orbit in `rotation_x`/`rotation_y`, while a named camera accumulates
+ /// into `pending_yaw`/`pending_pitch` for the node to pick up. Doing it any
+ /// other way would give a dragged camera a different meaning from a
+ /// scrolled one.
+ pub(crate) fn orbit_camera_by(&mut self, dx_px: f32, dy_px: f32) {
+ let dx = dx_px * Self::ORBIT_RADIANS_PER_PX;
+ let dy = dy_px * Self::ORBIT_RADIANS_PER_PX;
+ if self.active_camera != "Default Camera" {
+ let vp = self.viewport_mut();
+ vp.pending_yaw += dx;
+ vp.pending_pitch += -dy;
+ } else {
+ let vp = self.viewport_mut();
+ vp.rotation_y += dx;
+ vp.rotation_x -= dy;
+ vp.clamp_orbit_pitch();
+ }
+ // A drag is a direct gesture: the scene stops when the pointer does,
+ // rather than coasting the way a flicked scroll does.
+ self.viewport_mut().reset_velocity();
+ self.viewport_dirty = true;
+ }
+
/// Whether the network is drawn as an OVERLAY on the scene rather than on
/// its own plate: the plate switched off, in the ordinary docked layout.
///
@@ -4136,6 +4172,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
sim_cache: crate::geometry::SimCache::default(),
page_image: None,
seen_renderer: false,
+ orbit_drag: None,
page_dirty: false,
last_sim_frame: i32::MIN,
plate_menu_slot: None,
@@ -6011,6 +6048,17 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
return true;
}
+ // An in-flight camera orbit, likewise — it was armed by a press
+ // on empty scene, so nothing else is competing for the motion.
+ if let Some((lx, ly)) = self.orbit_drag {
+ let (dx, dy) = (self.cursor_x - lx, self.cursor_y - ly);
+ self.orbit_drag = Some((self.cursor_x, self.cursor_y));
+ if dx != 0.0 || dy != 0.0 {
+ self.orbit_camera_by(dx, dy);
+ }
+ return true;
+ }
+
// An armed corner-dot press becomes a layout drag once it
// moves; stubbed (collapsed/detached) panes stay click-only.
if let Some((idx, px, py)) = self.corner_press {
@@ -6466,6 +6514,28 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
}
}
+ // A left press on empty scene arms a camera orbit.
+ // AFTER the viewer state above, so dragging a handle
+ // still edits it, and after the node hit tests, so a
+ // press on a node is still the node's — "empty" means
+ // the scene really is what is under the cursor. The
+ // camera otherwise turns only by scrolling, which is
+ // the trackpad gesture; this is the mouse's.
+ if *button == MouseButton::Left
+ && self.cursor_in_viewport()
+ && !in_circle_network_pane
+ && self.app_drag.is_none()
+ {
+ self.orbit_drag = Some((self.cursor_x, self.cursor_y));
+ self.focused_pane = RIGHT_MENUBAR_IDX;
+ if let Some(old) = self.focused_widget {
+ self.slots.get_dyn_mut(old).unfocus();
+ self.focused_widget = None;
+ }
+ self.sync_pane_focus();
+ return true;
+ }
+
if *button == MouseButton::Right {
self.close_node_menu();
self.close_viewport_menu();
@@ -6690,6 +6760,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.sync_pane_focus();
}
ElementState::Released => {
+ if self.orbit_drag.take().is_some() {
+ return true;
+ }
if self.viewer_tool_release() {
changed = true;
}
diff --git a/src/main.rs b/src/main.rs
index e07237a..763c38b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4646,6 +4646,61 @@ mod tests {
assert!(state.page_dirty, "nothing would re-upload the page");
}
+ /// Dragging empty scene turns the camera, at the same rate the trackpad's
+ /// pixel-delta orbit does.
+ ///
+ /// The camera had no drag gesture at all before this: `Viewport3D` handles
+ /// only `MouseWheel`, so the scene could be turned by scrolling and by
+ /// nothing else. The rate is shared with that path deliberately — a drag
+ /// and a two-finger swipe should not feel like different cameras.
+ #[test]
+ fn test_dragging_empty_scene_turns_the_camera() {
+ let mut state = State::new(false);
+ let k = State::ORBIT_RADIANS_PER_PX;
+
+ // The default camera carries its own orbit. X drag yaws, Y drag
+ // pitches, and the pitch is inverted so dragging down looks down.
+ state.active_camera = "Default Camera".to_string();
+ let (y0, x0) = (state.viewport().rotation_y, state.viewport().rotation_x);
+ state.orbit_camera_by(100.0, 40.0);
+ assert!(
+ (state.viewport().rotation_y - (y0 + 100.0 * k)).abs() < 1e-5,
+ "yaw did not follow the drag"
+ );
+ assert!(
+ (state.viewport().rotation_x - (x0 - 40.0 * k)).abs() < 1e-5,
+ "pitch did not follow the drag, or is not inverted"
+ );
+
+ // A named camera accumulates instead, for the camera NODE to pick up —
+ // the same split the scroll path makes.
+ let mut state = State::new(false);
+ state.active_camera = "Camera 1".to_string();
+ let before = (state.viewport().rotation_y, state.viewport().rotation_x);
+ state.orbit_camera_by(100.0, 40.0);
+ assert!(
+ (state.viewport().pending_yaw - 100.0 * k).abs() < 1e-5,
+ "a named camera's yaw did not accumulate"
+ );
+ assert!(
+ (state.viewport().pending_pitch - (-40.0 * k)).abs() < 1e-5,
+ "a named camera's pitch did not accumulate"
+ );
+ assert_eq!(
+ (state.viewport().rotation_y, state.viewport().rotation_x),
+ before,
+ "a named camera must not move the default camera's orbit"
+ );
+
+ // Pitch is clamped, so a long downward drag cannot roll the scene over.
+ let mut state = State::new(false);
+ state.active_camera = "Default Camera".to_string();
+ state.orbit_camera_by(0.0, -100000.0);
+ let pitch = state.viewport().rotation_x;
+ assert!(pitch.abs() < std::f32::consts::PI, "pitch ran past vertical: {pitch}");
+
+ }
+
/// A page's raster is its physical size times its resolution — the
/// property that makes DPI a page parameter rather than an export one.
#[test]
diff --git a/src/viewport_3d.rs b/src/viewport_3d.rs
index a716338..240d8c1 100644
--- a/src/viewport_3d.rs
+++ b/src/viewport_3d.rs
@@ -63,7 +63,7 @@ impl Viewport3D {
/// Clamp the default-camera scroll orbit short of the poles
/// (total pitch = pitch0 - rotation_x).
- fn clamp_orbit_pitch(&mut self) {
+ pub(crate) fn clamp_orbit_pitch(&mut self) {
let p0 = Self::default_pitch0();
self.rotation_x = self.rotation_x.clamp(p0 - Self::MAX_PITCH, p0 + Self::MAX_PITCH);
}