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

src/viewport_3d.rs (13.8K)

  1 //! App-owned copy of the dissolved cce-ui `Viewport3D` (Phase 6ay part 2): the designer
  2 //! is the only consumer — the 3D preview pane of the roster, on the narrow traits
  3 //! wrapped in `Adapted<Viewport3D>` (Phase 6az). The roster keeps it as
  4 //! `Box<dyn WidgetHost>`; `as_any` downcasts reach this model.
  5 
  6 use cce_ui::colors;
  7 use cce_ui::widget::*;
  8 use glam::{Mat4, Vec3};
  9 
 10 #[derive(Debug, Clone)]
 11 pub struct Viewport3D {
 12     pub rotation_x: f32,
 13     pub rotation_y: f32,
 14     pub zoom: f32,
 15     /// The point the Default Camera view orbits and looks at — the pivot a
 16     /// camera NODE carries as its "Pivot" param, for the view that has no
 17     /// node. The origin until Frame All moves it to the displayed geometry's
 18     /// centre; the fixed eye ray (2.5, 1.8, 2.5) is taken FROM here, so the
 19     /// view's direction never changes, only what it is centred on.
 20     pub pivot: Vec3,
 21     pub active_camera: String,
 22     pub bg_color: [f32; 3],
 23     pub grid_color: [f32; 3],
 24     pub show_grid: bool,
 25     pub show_origin: bool,
 26     pub show_camera_pivot: bool,
 27     /// Path-traced preview: the pane renders through `cce_ui::vk`'s compute
 28     /// tracer instead of the raster 3D pass.
 29     pub rt_mode: bool,
 30 
 31     // Pending rotation to be consumed by the application when not using the default camera
 32     pub pending_yaw: f32,
 33     pub pending_pitch: f32,
 34 
 35     // Modifiers state
 36     ctrl_pressed: bool,
 37     shift_pressed: bool,
 38     alt_pressed: bool,
 39 
 40     /// The scroll orbit as cce-ui's `ScrollMotion`, in wheel px — x turns
 41     /// the yaw, y the pitch — the model the network pane's pan runs on: a
 42     /// finger tracks 1:1, the lift coasts on the velocity of the finger's
 43     /// own events, a wheel notch glides. The positions are accumulators;
 44     /// what moves the camera is how far they moved (`orbit_by_px`).
 45     orbit: ScrollMotion,
 46     /// Ctrl-scroll zoom, the same way, on its y axis.
 47     zoom_motion: ScrollMotion,
 48     /// A trackpad orbit keeps to one axis once it clearly favours it:
 49     /// 0 undecided, 1 yaw only, 2 pitch only. Decided per gesture.
 50     scroll_lock: u8,
 51     lock_accum: (f32, f32),
 52     pub scroll_speed: f32,
 53     /// `input.inertial.inertial_scroll` in config.kdl: off, a lift stops
 54     /// the orbit dead. How long a coast runs is cce-ui's `scroll_friction`
 55     /// in input.kdl, as it is for every pane that coasts.
 56     pub inertial_scroll: bool,
 57 }
 58 
 59 /// Radians of orbit per wheel px, and px per wheel notch (a notch turns
 60 /// 0.05 rad, as it always did).
 61 const ORBIT_RAD_PER_PX: f32 = 0.005;
 62 const ORBIT_PX_PER_LINE: f32 = 10.0;
 63 /// Log-zoom per wheel px, and px per notch (0.15 a notch).
 64 const ZOOM_PER_PX: f32 = 0.005;
 65 const ZOOM_PX_PER_LINE: f32 = 30.0;
 66 /// How far a trackpad orbit runs before its axis lock is decided, in px.
 67 const LOCK_DECIDE_PX: f32 = 0.4;
 68 
 69 impl Viewport3D {
 70     /// Hard pitch limit for the orbit: just short of the poles. Past ±90° the
 71     /// up-vector flips and the view rolls — from there orbiting reads as the
 72     /// geometry tumbling with the camera instead of the camera moving around it.
 73     pub const MAX_PITCH: f32 = 89.9 * std::f32::consts::PI / 180.0;
 74 
 75     /// The default camera's base pitch above the horizon (position (2.5,1.8,2.5)
 76     /// looking at the origin — the `get_matrices` defaults).
 77     fn default_pitch0() -> f32 {
 78         (1.8f32 / Vec3::new(2.5, 1.8, 2.5).length()).asin()
 79     }
 80 
 81     /// Clamp the default-camera scroll orbit short of the poles
 82     /// (total pitch = pitch0 - rotation_x).
 83     pub(crate) fn clamp_orbit_pitch(&mut self) {
 84         let p0 = Self::default_pitch0();
 85         self.rotation_x = self.rotation_x.clamp(p0 - Self::MAX_PITCH, p0 + Self::MAX_PITCH);
 86     }
 87 
 88     pub fn new() -> Adapted<Viewport3D> {
 89         Adapted::new(Self {
 90             rotation_x: 0.0,
 91             rotation_y: 0.0,
 92             zoom: 1.0,
 93             pivot: Vec3::ZERO,
 94             active_camera: "Default Camera".to_string(),
 95             bg_color: [0.10, 0.10, 0.13],
 96             grid_color: [0.18, 0.18, 0.22],
 97             show_grid: true,
 98             show_origin: true,
 99             show_camera_pivot: true,
100             rt_mode: false,
101             pending_yaw: 0.0,
102             pending_pitch: 0.0,
103             ctrl_pressed: false,
104             shift_pressed: false,
105             alt_pressed: false,
106             orbit: ScrollMotion::new(),
107             zoom_motion: ScrollMotion::new(),
108             scroll_lock: 0,
109             lock_accum: (0.0, 0.0),
110             scroll_speed: 1.0,
111             inertial_scroll: true,
112         })
113     }
114 
115     pub fn with_scroll_speed(mut self, speed: f32) -> Self {
116         self.scroll_speed = speed;
117         self
118     }
119 
120     pub fn with_inertial_scroll(mut self, enabled: bool) -> Self {
121         self.inertial_scroll = enabled;
122         self
123     }
124 
125     /// Stop every scroll motion in flight: a coast, a notch's glide. What
126     /// takes the camera over (a drag, Frame All, a load) calls this.
127     pub fn reset_velocity(&mut self) {
128         self.orbit = ScrollMotion::new();
129         self.zoom_motion = ScrollMotion::new();
130         self.scroll_lock = 0;
131         self.lock_accum = (0.0, 0.0);
132     }
133 
134     /// Whether a scroll is still moving the camera on its own — a coast
135     /// after the lift, or a wheel notch's glide.
136     pub fn is_coasting(&self) -> bool {
137         self.orbit.is_animating() || self.zoom_motion.is_animating()
138     }
139 
140     /// The zoom range. The top is generous because `View 1:1` on a small
141     /// world unit needs the camera far out; the far plane follows it.
142     pub const MAX_ZOOM: f32 = 400.0;
143 
144     /// Trackpad pinch: direct-manipulation camera zoom. `factor` is the
145     /// scale change since the last gesture update (engine `handle_pinch`
146     /// semantics), applied 1:1 — spreading fingers 2x halves the camera
147     /// distance. It does not coast: the runner keeps a pinch's end to
148     /// itself. It does stop a ctrl-scroll zoom that is.
149     pub fn pinch_zoom(&mut self, factor: f32) {
150         if factor <= 0.0 {
151             return;
152         }
153         self.zoom_motion = ScrollMotion::new();
154         self.zoom = (self.zoom / factor).clamp(0.05, Self::MAX_ZOOM);
155     }
156 
157     /// Turn the camera by a scroll's worth of wheel px: the Default Camera's
158     /// own orbit, or a camera node's pending one for `tick_frame` to write.
159     fn orbit_by_px(&mut self, dx: f32, dy: f32) {
160         let (yaw, pitch) = (dx * ORBIT_RAD_PER_PX, dy * ORBIT_RAD_PER_PX);
161         if yaw == 0.0 && pitch == 0.0 {
162             return;
163         }
164         if self.active_camera != "Default Camera" {
165             self.pending_yaw += yaw;
166             self.pending_pitch -= pitch;
167         } else {
168             self.rotation_y += yaw;
169             self.rotation_x -= pitch;
170             self.clamp_orbit_pitch();
171         }
172     }
173 
174     /// Zoom by a scroll's worth of wheel px.
175     fn zoom_by_px(&mut self, dy: f32) {
176         if dy != 0.0 {
177             self.zoom = (self.zoom * (-dy * ZOOM_PER_PX).exp()).clamp(0.05, Self::MAX_ZOOM);
178         }
179     }
180 
181     pub fn get_matrices(&self, aspect: f32, custom_camera_pos: Option<Vec3>, custom_camera_rot: Option<Vec3>, custom_pivot: Option<Vec3>) -> (Mat4, Mat4, Mat4) {
182         let pivot = custom_pivot.unwrap_or(self.pivot);
183         let camera_pos = custom_camera_pos.unwrap_or(pivot + Vec3::new(2.5, 1.8, 2.5));
184         let rot = custom_camera_rot.unwrap_or(Vec3::ZERO);
185         let rx = rot.x;
186         let ry = rot.y;
187         let rz = rot.z;
188 
189         let base_offset = camera_pos - pivot;
190         let distance = base_offset.length();
191         let yaw0 = base_offset.x.atan2(base_offset.z);
192         let pitch0 = (base_offset.y / distance.max(1e-5)).asin();
193 
194         // The default-camera scroll orbit (`rotation_x`/`rotation_y`) folds into
195         // the CAMERA's orbit around the pivot — subtracted, because moving the
196         // camera one way spins the view the way rotating the world the other way
197         // used to. The old path put these angles in the model matrix, which
198         // rotated the geometry within world space (visible against the pivot
199         // marker, and it swung the shading) instead of moving the camera.
200         let total_ry = ry.to_radians() + yaw0 - self.rotation_y;
201         // Safety clamp short of the poles regardless of what the stored camera
202         // state says: past ±90° the up-vector flips and the whole view rolls.
203         let total_rx =
204             (rx.to_radians() + pitch0 - self.rotation_x).clamp(-Self::MAX_PITCH, Self::MAX_PITCH);
205 
206         let view_rot_pos = Mat4::from_rotation_y(total_ry) * Mat4::from_rotation_x(-total_rx);
207         let camera_up = view_rot_pos.transform_vector3(Vec3::Y);
208         let camera_world_pos = pivot + view_rot_pos.transform_vector3(Vec3::new(0.0, 0.0, distance) * self.zoom);
209         let view_mat = Mat4::from_rotation_z(rz.to_radians()) * Mat4::look_at_rh(camera_world_pos, pivot, camera_up);
210 
211         // Geometry stays stationary in world space; the camera does the moving.
212         let model = Mat4::IDENTITY;
213         // The far plane follows the camera out: `View 1:1` on a millimetre
214         // world unit parks the camera a few hundred units away, and a fixed
215         // 100 would clip the pivot itself.
216         let far = (distance * self.zoom * 4.0).max(100.0);
217         let proj = Mat4::perspective_rh(0.9, aspect, 0.1, far);
218 
219         (proj, view_mat, model)
220     }
221 }
222 
223 impl cce_ui::widget::Layout for Viewport3D {}
224 
225 impl cce_ui::widget::Paint for Viewport3D {
226     fn color(&self) -> [f32; 4] {
227         colors::VIEWPORT_BG
228     }
229 }
230 
231 impl cce_ui::widget::Input for Viewport3D {
232     fn set_modifiers(&mut self, ctrl: bool, shift: bool, alt: bool) {
233         self.ctrl_pressed = ctrl;
234         self.shift_pressed = shift;
235         self.alt_pressed = alt;
236     }
237 
238     fn on_event(&mut self, event: &Event, _ectx: &mut cce_ui::widget::EventCtx) -> bool {
239         // Wheel arrives hit-gated to the pane rect. Every wheel goes through
240         // a ScrollMotion, whose phase (published by the runner from the
241         // Wayland axis source and stop) decides what it is: a finger's
242         // motion, the finger's lift, or a notch. The lift is a zero delta
243         // and must reach `apply_px` as one — the hand-rolled coast this
244         // replaced read it as more motion and killed its own velocity on
245         // every lift, so the viewport never coasted.
246         let Event::MouseWheel { delta, .. } = event else { return false };
247         self.wheel(delta, cce_ui::widget::scroll_motion::current_scroll_phase())
248     }
249 
250     /// Advance a coast or a notch's glide. True while either still moves,
251     /// which keeps the frames coming.
252     fn tick(&mut self, dt: f32, _rect: cce_ui::scene::layout::Rect) -> bool {
253         let free = Bounds::UNBOUNDED;
254         let (ox, oy) = (self.orbit.x.pos(), self.orbit.y.pos());
255         let mut changed = self.orbit.tick(dt, free, free);
256         if changed {
257             self.orbit_by_px(self.orbit.x.pos() - ox, self.orbit.y.pos() - oy);
258         }
259         let z = self.zoom_motion.y.pos();
260         if self.zoom_motion.tick(dt, free, free) {
261             self.zoom_by_px(self.zoom_motion.y.pos() - z);
262             changed = true;
263         }
264         changed || self.is_coasting()
265     }
266 }
267 
268 impl Viewport3D {
269     /// One wheel event in the gesture phase `phase` (the runner's, for a
270     /// real event — see `on_event`): ctrl zooms, otherwise it orbits. A
271     /// notch is always `Wheel`, whatever the phase says.
272     pub fn wheel(&mut self, delta: &MouseScrollDelta, phase: ScrollPhase) -> bool {
273         let scale = cce_ui::scale::scale_factor().max(0.001);
274         let discrete = matches!(delta, MouseScrollDelta::LineDelta(..));
275         let phase = if discrete { ScrollPhase::Wheel } else { phase };
276         let free = Bounds::UNBOUNDED;
277         let speed = self.scroll_speed;
278         if self.ctrl_pressed {
279             let dy = match delta {
280                 MouseScrollDelta::LineDelta(_x, y) => *y * ZOOM_PX_PER_LINE,
281                 MouseScrollDelta::PixelDelta(pos) => pos.y as f32 / scale,
282             } * speed;
283             let before = self.zoom_motion.y.pos();
284             self.zoom_motion.apply_phase(phase, 0.0, dy, discrete, free, free);
285             if phase == ScrollPhase::FingerEnd && !self.inertial_scroll {
286                 self.zoom_motion = ScrollMotion::new();
287                 return true;
288             }
289             self.zoom_by_px(self.zoom_motion.y.pos() - before);
290             return true;
291         }
292         let (mut dx, mut dy) = match delta {
293             MouseScrollDelta::LineDelta(x, y) => (*x * ORBIT_PX_PER_LINE, *y * ORBIT_PX_PER_LINE),
294             MouseScrollDelta::PixelDelta(pos) => (pos.x as f32 / scale, pos.y as f32 / scale),
295         };
296         dx *= speed;
297         dy *= speed;
298         match phase {
299             ScrollPhase::Finger => {
300                 // Once a trackpad orbit clearly favours one axis it keeps to
301                 // it, so a vertical swipe does not wander in yaw.
302                 if self.scroll_lock == 0 {
303                     self.lock_accum.0 += dx;
304                     self.lock_accum.1 += dy;
305                     let (ax, ay) = (self.lock_accum.0.abs(), self.lock_accum.1.abs());
306                     if ax > LOCK_DECIDE_PX || ay > LOCK_DECIDE_PX {
307                         if ay > 1.2 * ax {
308                             self.scroll_lock = 2;
309                         } else if ax > 1.2 * ay {
310                             self.scroll_lock = 1;
311                         }
312                     }
313                 }
314                 match self.scroll_lock {
315                     1 => dy = 0.0,
316                     2 => dx = 0.0,
317                     _ => {}
318                 }
319             }
320             ScrollPhase::FingerEnd | ScrollPhase::Wheel => {
321                 self.scroll_lock = 0;
322                 self.lock_accum = (0.0, 0.0);
323             }
324         }
325         let before = (self.orbit.x.pos(), self.orbit.y.pos());
326         self.orbit.apply_phase(phase, dx, dy, discrete, free, free);
327         if phase == ScrollPhase::FingerEnd && !self.inertial_scroll {
328             self.orbit = ScrollMotion::new();
329             return true;
330         }
331         self.orbit_by_px(self.orbit.x.pos() - before.0, self.orbit.y.pos() - before.1);
332         true
333     }
334 }