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 }