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

src/viewport_3d.rs (14.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_cube: bool,
 26     pub show_origin: bool,
 27     pub show_camera_pivot: bool,
 28     /// Path-traced preview: the pane renders through `cce_ui::vk`'s compute
 29     /// tracer instead of the raster 3D pass.
 30     pub rt_mode: bool,
 31 
 32     // Pending rotation to be consumed by the application when not using the default camera
 33     pub pending_yaw: f32,
 34     pub pending_pitch: f32,
 35 
 36     // Modifiers state
 37     ctrl_pressed: bool,
 38     shift_pressed: bool,
 39     alt_pressed: bool,
 40 
 41     // Drag & scroll state tracking
 42     is_rotating: bool,
 43     is_zooming: bool,
 44     scroll_lock: u8,
 45     rotate_accum_yaw: f32,
 46     rotate_accum_pitch: f32,
 47     zoom_accum: f32,
 48     rotate_velocity_yaw: f32,
 49     rotate_velocity_pitch: f32,
 50     zoom_velocity: f32,
 51     last_rotate_time: std::time::Instant,
 52     last_zoom_time: std::time::Instant,
 53     pub scroll_speed: f32,
 54     pub inertial_scroll: bool,
 55     pub scroll_friction: f32,
 56 }
 57 
 58 impl Viewport3D {
 59     /// Hard pitch limit for the orbit: just short of the poles. Past ±90° the
 60     /// up-vector flips and the view rolls — from there orbiting reads as the
 61     /// geometry tumbling with the camera instead of the camera moving around it.
 62     pub const MAX_PITCH: f32 = 89.9 * std::f32::consts::PI / 180.0;
 63 
 64     /// The default camera's base pitch above the horizon (position (2.5,1.8,2.5)
 65     /// looking at the origin — the `get_matrices` defaults).
 66     fn default_pitch0() -> f32 {
 67         (1.8f32 / Vec3::new(2.5, 1.8, 2.5).length()).asin()
 68     }
 69 
 70     /// Clamp the default-camera scroll orbit short of the poles
 71     /// (total pitch = pitch0 - rotation_x).
 72     pub(crate) fn clamp_orbit_pitch(&mut self) {
 73         let p0 = Self::default_pitch0();
 74         self.rotation_x = self.rotation_x.clamp(p0 - Self::MAX_PITCH, p0 + Self::MAX_PITCH);
 75     }
 76 
 77     pub fn new() -> Adapted<Viewport3D> {
 78         Adapted::new(Self {
 79             rotation_x: 0.0,
 80             rotation_y: 0.0,
 81             zoom: 1.0,
 82             pivot: Vec3::ZERO,
 83             active_camera: "Default Camera".to_string(),
 84             bg_color: [0.10, 0.10, 0.13],
 85             grid_color: [0.18, 0.18, 0.22],
 86             show_grid: true,
 87             show_cube: true,
 88             show_origin: true,
 89             show_camera_pivot: true,
 90             rt_mode: false,
 91             pending_yaw: 0.0,
 92             pending_pitch: 0.0,
 93             ctrl_pressed: false,
 94             shift_pressed: false,
 95             alt_pressed: false,
 96             is_rotating: false,
 97             is_zooming: false,
 98             scroll_lock: 0,
 99             rotate_accum_yaw: 0.0,
100             rotate_accum_pitch: 0.0,
101             zoom_accum: 0.0,
102             rotate_velocity_yaw: 0.0,
103             rotate_velocity_pitch: 0.0,
104             zoom_velocity: 0.0,
105             last_rotate_time: std::time::Instant::now(),
106             last_zoom_time: std::time::Instant::now(),
107             scroll_speed: 1.0,
108             inertial_scroll: true,
109             scroll_friction: 0.90,
110         })
111     }
112 
113     pub fn with_scroll_speed(mut self, speed: f32) -> Self {
114         self.scroll_speed = speed;
115         self
116     }
117 
118     pub fn with_inertial_scroll(mut self, enabled: bool) -> Self {
119         self.inertial_scroll = enabled;
120         self
121     }
122 
123     pub fn with_scroll_friction(mut self, friction: f32) -> Self {
124         self.scroll_friction = friction;
125         self
126     }
127 
128     pub fn reset_velocity(&mut self) {
129         self.rotate_velocity_yaw = 0.0;
130         self.rotate_velocity_pitch = 0.0;
131         self.zoom_velocity = 0.0;
132         self.is_rotating = false;
133         self.is_zooming = false;
134         self.scroll_lock = 0;
135     }
136 
137     /// Trackpad pinch: direct-manipulation camera zoom. `factor` is the
138     /// scale change since the last gesture update (engine `handle_pinch`
139     /// semantics), applied 1:1 — spreading fingers 2x halves the camera
140     /// distance. Feeds the same accumulator as the ctrl-wheel zoom so the
141     /// release inertia matches.
142     /// The zoom range. The top is generous because `View 1:1` on a small
143     /// world unit needs the camera far out; the far plane follows it.
144     pub const MAX_ZOOM: f32 = 400.0;
145 
146     pub fn pinch_zoom(&mut self, factor: f32) {
147         if factor <= 0.0 {
148             return;
149         }
150         let dy = factor.ln();
151         self.zoom = (self.zoom * (-dy).exp()).clamp(0.05, Self::MAX_ZOOM);
152         self.is_zooming = true;
153         self.last_zoom_time = std::time::Instant::now();
154         self.zoom_accum += dy;
155     }
156 
157     pub fn get_matrices(&self, aspect: f32, custom_camera_pos: Option<Vec3>, custom_camera_rot: Option<Vec3>, custom_pivot: Option<Vec3>) -> (Mat4, Mat4, Mat4) {
158         let pivot = custom_pivot.unwrap_or(self.pivot);
159         let camera_pos = custom_camera_pos.unwrap_or(pivot + Vec3::new(2.5, 1.8, 2.5));
160         let rot = custom_camera_rot.unwrap_or(Vec3::ZERO);
161         let rx = rot.x;
162         let ry = rot.y;
163         let rz = rot.z;
164 
165         let base_offset = camera_pos - pivot;
166         let distance = base_offset.length();
167         let yaw0 = base_offset.x.atan2(base_offset.z);
168         let pitch0 = (base_offset.y / distance.max(1e-5)).asin();
169 
170         // The default-camera scroll orbit (`rotation_x`/`rotation_y`) folds into
171         // the CAMERA's orbit around the pivot — subtracted, because moving the
172         // camera one way spins the view the way rotating the world the other way
173         // used to. The old path put these angles in the model matrix, which
174         // rotated the geometry within world space (visible against the pivot
175         // marker, and it swung the shading) instead of moving the camera.
176         let total_ry = ry.to_radians() + yaw0 - self.rotation_y;
177         // Safety clamp short of the poles regardless of what the stored camera
178         // state says: past ±90° the up-vector flips and the whole view rolls.
179         let total_rx =
180             (rx.to_radians() + pitch0 - self.rotation_x).clamp(-Self::MAX_PITCH, Self::MAX_PITCH);
181 
182         let view_rot_pos = Mat4::from_rotation_y(total_ry) * Mat4::from_rotation_x(-total_rx);
183         let camera_up = view_rot_pos.transform_vector3(Vec3::Y);
184         let camera_world_pos = pivot + view_rot_pos.transform_vector3(Vec3::new(0.0, 0.0, distance) * self.zoom);
185         let view_mat = Mat4::from_rotation_z(rz.to_radians()) * Mat4::look_at_rh(camera_world_pos, pivot, camera_up);
186 
187         // Geometry stays stationary in world space; the camera does the moving.
188         let model = Mat4::IDENTITY;
189         // The far plane follows the camera out: `View 1:1` on a millimetre
190         // world unit parks the camera a few hundred units away, and a fixed
191         // 100 would clip the pivot itself.
192         let far = (distance * self.zoom * 4.0).max(100.0);
193         let proj = Mat4::perspective_rh(0.9, aspect, 0.1, far);
194 
195         (proj, view_mat, model)
196     }
197 }
198 
199 impl cce_ui::widget::Layout for Viewport3D {}
200 
201 impl cce_ui::widget::Paint for Viewport3D {
202     fn color(&self) -> [f32; 4] {
203         colors::VIEWPORT_BG
204     }
205 }
206 
207 impl cce_ui::widget::Input for Viewport3D {
208     fn set_modifiers(&mut self, ctrl: bool, shift: bool, alt: bool) {
209         self.ctrl_pressed = ctrl;
210         self.shift_pressed = shift;
211         self.alt_pressed = alt;
212     }
213 
214     fn on_event(&mut self, event: &Event, _ectx: &mut cce_ui::widget::EventCtx) -> bool {
215         // Wheel arrives hit-gated to the pane rect (the adapter's gate replaces the old
216         // leading self.hit_test); the rotate/zoom handling is the legacy body verbatim.
217         let Event::MouseWheel { delta, .. } = event else { return false };
218 
219         let scale = cce_ui::scale::scale_factor();
220         if self.ctrl_pressed {
221             match delta {
222                 MouseScrollDelta::LineDelta(_x, y) => {
223                     let dy = *y * 0.15 * self.scroll_speed;
224                     self.zoom *= (-dy).exp();
225                     self.zoom = self.zoom.clamp(0.05, Self::MAX_ZOOM);
226                     self.is_zooming = false;
227                     
228                     let dt = 0.016;
229                     self.zoom_velocity = self.zoom_velocity * 0.4 + (dy / dt) * 0.6;
230                     true
231                 }
232                 MouseScrollDelta::PixelDelta(pos) => {
233                     let dy = (pos.y as f32 / scale) * 0.005 * self.scroll_speed;
234                     self.zoom *= (-dy).exp();
235                     self.zoom = self.zoom.clamp(0.05, Self::MAX_ZOOM);
236                     self.is_zooming = true;
237                     self.last_zoom_time = std::time::Instant::now();
238                     self.zoom_accum += dy;
239                     true
240                 }
241             }
242         } else {
243             match delta {
244                 MouseScrollDelta::LineDelta(x, y) => {
245                     self.scroll_lock = 0;
246                     let dx = *x * 0.05 * self.scroll_speed;
247                     let dy = *y * 0.05 * self.scroll_speed;
248                     
249                     if self.active_camera != "Default Camera" {
250                         self.pending_yaw += dx;
251                         self.pending_pitch += -dy;
252                     } else {
253                         self.rotation_y += dx;
254                         self.rotation_x -= dy;
255                         self.clamp_orbit_pitch();
256                     }
257 
258                     self.is_rotating = false;
259                     let dt = 0.016;
260                     self.rotate_velocity_yaw = self.rotate_velocity_yaw * 0.4 + (dx / dt) * 0.6;
261                     self.rotate_velocity_pitch = self.rotate_velocity_pitch * 0.4 + (-dy / dt) * 0.6;
262                     true
263                 }
264                 MouseScrollDelta::PixelDelta(pos) => {
265                     let mut dx = (pos.x as f32 / scale) * 0.005 * self.scroll_speed;
266                     let mut dy = (pos.y as f32 / scale) * 0.005 * self.scroll_speed;
267 
268                     self.rotate_accum_yaw += dx;
269                     self.rotate_accum_pitch -= dy;
270                     if self.scroll_lock == 0 {
271                         if self.rotate_accum_yaw.abs() > 0.002 || self.rotate_accum_pitch.abs() > 0.002 {
272                             if self.rotate_accum_pitch.abs() > 1.2 * self.rotate_accum_yaw.abs() {
273                                 self.scroll_lock = 2;
274                             } else if self.rotate_accum_yaw.abs() > 1.2 * self.rotate_accum_pitch.abs() {
275                                 self.scroll_lock = 1;
276                             }
277                         }
278                     } else {
279                         if self.scroll_lock == 1 {
280                             dy = 0.0;
281                         } else {
282                             dx = 0.0;
283                         }
284                     }
285 
286                     if self.active_camera != "Default Camera" {
287                         self.pending_yaw += dx;
288                         self.pending_pitch += -dy;
289                     } else {
290                         self.rotation_y += dx;
291                         self.rotation_x -= dy;
292                         self.clamp_orbit_pitch();
293                     }
294 
295                     self.is_rotating = true;
296                     self.last_rotate_time = std::time::Instant::now();
297                     true
298                 }
299             }
300         }
301     
302     }
303 
304     fn tick(&mut self, dt: f32, _rect: cce_ui::scene::layout::Rect) -> bool {
305 
306         let now = std::time::Instant::now();
307         let mut changed = false;
308 
309         if self.is_rotating {
310             if now.duration_since(self.last_rotate_time).as_secs_f32() > 0.05 {
311                 self.is_rotating = false;
312                 self.scroll_lock = 0;
313             } else if dt > 1e-5 {
314                 let vel_yaw = self.rotate_accum_yaw / dt;
315                 let vel_pitch = self.rotate_accum_pitch / dt;
316                 self.rotate_velocity_yaw = self.rotate_velocity_yaw * 0.4 + vel_yaw * 0.6;
317                 self.rotate_velocity_pitch = self.rotate_velocity_pitch * 0.4 + vel_pitch * 0.6;
318             }
319             self.rotate_accum_yaw = 0.0;
320             self.rotate_accum_pitch = 0.0;
321         }
322 
323         if self.is_zooming {
324             if now.duration_since(self.last_zoom_time).as_secs_f32() > 0.05 {
325                 self.is_zooming = false;
326             } else if dt > 1e-5 {
327                 let vel_zoom = self.zoom_accum / dt;
328                 self.zoom_velocity = self.zoom_velocity * 0.4 + vel_zoom * 0.6;
329             }
330             self.zoom_accum = 0.0;
331         }
332 
333         if !self.is_rotating && (self.rotate_velocity_yaw.abs() > 0.001 || self.rotate_velocity_pitch.abs() > 0.001) {
334             if !self.inertial_scroll {
335                 self.rotate_velocity_yaw = 0.0;
336                 self.rotate_velocity_pitch = 0.0;
337             } else {
338                 let dx = self.rotate_velocity_yaw * dt;
339                 let dy = self.rotate_velocity_pitch * dt;
340                 
341                 if self.active_camera != "Default Camera" {
342                     self.pending_yaw += dx;
343                     self.pending_pitch += dy;
344                 } else {
345                     self.rotation_y += dx;
346                     self.rotation_x += dy;
347                     self.clamp_orbit_pitch();
348                 }
349 
350                 let decay = self.scroll_friction.powf(dt * 60.0);
351                 self.rotate_velocity_yaw *= decay;
352                 self.rotate_velocity_pitch *= decay;
353 
354                 if self.rotate_velocity_yaw.abs() < 0.01 { self.rotate_velocity_yaw = 0.0; }
355                 if self.rotate_velocity_pitch.abs() < 0.01 { self.rotate_velocity_pitch = 0.0; }
356                 changed = true;
357             }
358         }
359 
360         if !self.is_zooming && self.zoom_velocity.abs() > 0.001 {
361             if !self.inertial_scroll {
362                 self.zoom_velocity = 0.0;
363             } else {
364                 let d_zoom = self.zoom_velocity * dt;
365                 self.zoom *= (-d_zoom).exp();
366                 self.zoom = self.zoom.clamp(0.05, Self::MAX_ZOOM);
367 
368                 let decay = self.scroll_friction.powf(dt * 60.0);
369                 self.zoom_velocity *= decay;
370                 if self.zoom_velocity.abs() < 0.01 { self.zoom_velocity = 0.0; }
371                 changed = true;
372             }
373         }
374 
375         changed
376     
377     }
378 }