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 }