diff --git a/Cargo.lock b/Cargo.lock index eafb16f3..ffa15734 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,6 +290,14 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "camera" +version = "0.1.0" +dependencies = [ + "nalgebra", + "workspace-hack", +] + [[package]] name = "cast" version = "0.3.0" @@ -906,6 +914,7 @@ name = "fidget-viewer" version = "0.1.0" dependencies = [ "anyhow", + "camera", "clap", "crossbeam-channel", "eframe", diff --git a/Cargo.toml b/Cargo.toml index 91e853f5..0035b40c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "demos/constraints", "demos/cli", "demos/viewer", + "demos/camera", "workspace-hack", ] exclude = ["demos/web-editor/crate"] diff --git a/demos/camera/Cargo.toml b/demos/camera/Cargo.toml new file mode 100644 index 00000000..300abd63 --- /dev/null +++ b/demos/camera/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "camera" +version = "0.1.0" +edition = "2021" + +[dependencies] +nalgebra.workspace = true +workspace-hack = { version = "0.1", path = "../../workspace-hack" } diff --git a/demos/camera/src/lib.rs b/demos/camera/src/lib.rs new file mode 100644 index 00000000..4d7659ea --- /dev/null +++ b/demos/camera/src/lib.rs @@ -0,0 +1,212 @@ +use nalgebra::Vector2; + +#[derive(Copy, Clone, Debug)] +pub struct Camera2D { + /// Scale to translate between screen and world units + scale: f32, + + /// Offset in world units + offset: Vector2, + + /// Starting position for a drag, in world units + drag_start: Option>, + + /// Size of the viewport in screen units + viewport: Rect, +} + +#[derive(Copy, Clone, Debug)] +pub struct Rect { + pub min: Vector2, + pub max: Vector2, +} + +impl Default for Camera2D { + fn default() -> Self { + Camera2D { + drag_start: None, + scale: 1.0, + offset: Vector2::zeros(), + viewport: Rect { + min: Vector2::new(f32::NAN, f32::NAN), + max: Vector2::new(f32::NAN, f32::NAN), + }, + } + } +} + +impl Camera2D { + /// Builds a new camera with an empty viewport + pub fn new() -> Self { + Self::default() + } + + /// Returns the camera's current offset, in world units + pub fn offset(&self) -> Vector2 { + self.offset + } + + /// Returns the camera's current scale, mapping from screen to world units + pub fn scale(&self) -> f32 { + self.scale + } + + /// Returns the current viewport, in screen units + pub fn viewport(&self) -> Rect { + self.viewport + } + + /// Returns UV coordinates that cover the window + pub fn uv(&self) -> Rect { + let size = self.viewport.max - self.viewport.min; + if size.x > size.y { + let r = (1.0 - (size.y / size.x)) / 2.0; + Rect { + min: Vector2::new(0.0, r), + max: Vector2::new(1.0, 1.0 - r), + } + } else { + let r = (1.0 - (size.x / size.y)) / 2.0; + Rect { + min: Vector2::new(r, 0.0), + max: Vector2::new(1.0 - r, 1.0), + } + } + } + + /// Updates the screen viewport size + pub fn set_viewport(&mut self, viewport: Rect) { + self.viewport = viewport; + } + + /// Converts from mouse position to a UV position within the render window + fn screen_to_world(&self, p: Vector2) -> Vector2 { + let size = self.viewport.max - self.viewport.min; + let out = (p - (self.viewport.min + self.viewport.max) / 2.0) + * self.scale + / size.x.max(size.y); + self.offset + out.component_mul(&Vector2::new(2.0, -2.0)) + } + + /// Updates the camera position when the mouse is held and dragged + /// + /// Returns `true` if the camera has changed + pub fn drag(&mut self, pos: Vector2) -> bool { + if let Some(start) = self.drag_start { + let prev_offset = self.offset; + self.offset = Vector2::zeros(); + let pos = self.screen_to_world(pos); + let new_offset = start - pos; + let changed = prev_offset != new_offset; + self.offset = new_offset; + changed + } else { + let pos = self.screen_to_world(pos); + self.drag_start = Some(pos); + false + } + } + + /// Releases the drag + pub fn release(&mut self) { + self.drag_start = None + } + + /// Updates the camera zoom when the mouse is scrolled + /// + /// If the mouse cursor position is provided (in screen units), then the + /// camera offset is updated to keep the same point under the cursor. + /// + /// Returns `true` if the camera has changed + pub fn scroll(&mut self, pos: Option>, scroll: f32) -> bool { + if scroll != 0.0 { + let new_scale = self.scale / (scroll / 100.0).exp2(); + match pos { + Some(p) => { + let pos_before = self.screen_to_world(p); + self.scale = new_scale; + let pos_after = self.screen_to_world(p); + self.offset += pos_before - pos_after; + } + None => { + self.scale = new_scale; + } + } + true + } else { + false + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_drag() { + let mut c = Camera2D::new(); + c.set_viewport(Rect { + min: Vector2::new(0.0, 0.0), + max: Vector2::new(100.0, 100.0), + }); + let p = c.screen_to_world(Vector2::new(0.0, 0.0)); + assert_eq!(p, Vector2::new(-1.0, 1.0)); + let p = c.screen_to_world(Vector2::new(100.0, 0.0)); + assert_eq!(p, Vector2::new(1.0, 1.0)); + let p = c.screen_to_world(Vector2::new(0.0, 100.0)); + assert_eq!(p, Vector2::new(-1.0, -1.0)); + let p = c.screen_to_world(Vector2::new(100.0, 100.0)); + assert_eq!(p, Vector2::new(1.0, -1.0)); + + c.drag(Vector2::new(50.0, 50.0)); + c.drag(Vector2::new(100.0, 50.0)); + c.release(); + + let p = c.screen_to_world(Vector2::new(50.0, 50.0)); + assert_eq!(p, Vector2::new(-1.0, 0.0)); + + c.drag(Vector2::new(50.0, 50.0)); + c.drag(Vector2::new(50.0, 100.0)); + c.release(); + + let p = c.screen_to_world(Vector2::new(50.0, 50.0)); + assert_eq!(p, Vector2::new(-1.0, 1.0)); + } + + #[test] + fn test_zoom() { + let mut c = Camera2D::new(); + c.set_viewport(Rect { + min: Vector2::new(0.0, 0.0), + max: Vector2::new(100.0, 100.0), + }); + + let points = [ + (75.0, 75.0), + (75.0, 25.0), + (25.0, 75.0), + (25.0, 25.0), + (50.0, 50.0), + ] + .map(|(x, y)| Vector2::new(x, y)); + for &p in &points { + let corner = Vector2::zeros(); + let prev_p = c.screen_to_world(p); + let prev_corner = c.screen_to_world(corner); + c.scroll(Some(p), 100.0); + let next_p = c.screen_to_world(p); + let next_corner = c.screen_to_world(corner); + + assert_eq!(prev_p, next_p); + assert_ne!(prev_corner, next_corner); + } + + // Undo the zooming to put us back at the origin + assert_ne!(c.offset, Vector2::new(0.0, 0.0)); + for &p in points.iter().rev() { + c.scroll(Some(p), -100.0); + } + assert_eq!(c.offset, Vector2::new(0.0, 0.0)); + } +} diff --git a/demos/viewer/Cargo.toml b/demos/viewer/Cargo.toml index 79d9132b..12618500 100644 --- a/demos/viewer/Cargo.toml +++ b/demos/viewer/Cargo.toml @@ -16,6 +16,7 @@ notify.workspace = true rhai.workspace = true fidget = { path = "../../fidget", default-features = false, features = ["render", "rhai"] } +camera = { path = "../camera" } workspace-hack = { version = "0.1", path = "../../workspace-hack" } [features] diff --git a/demos/viewer/src/main.rs b/demos/viewer/src/main.rs index 5836be7c..c2246218 100644 --- a/demos/viewer/src/main.rs +++ b/demos/viewer/src/main.rs @@ -163,14 +163,14 @@ fn render( image_size, tile_sizes: F::tile_sizes_2d().to_vec(), bounds: fidget::shape::Bounds { - center: Vector2::new(camera.offset.x, camera.offset.y), - size: camera.scale, + center: camera.offset(), + size: camera.scale(), }, ..RenderConfig::default() }; match mode { - TwoDMode::Color => { + Mode2D::Color => { let image = fidget::render::render2d::< _, fidget::render::BitRenderMode, @@ -188,7 +188,7 @@ fn render( } } - TwoDMode::Sdf => { + Mode2D::Sdf => { let image = fidget::render::render2d::< _, fidget::render::SdfRenderMode, @@ -198,7 +198,7 @@ fn render( } } - TwoDMode::Debug => { + Mode2D::Debug => { let image = fidget::render::render2d::< _, fidget::render::DebugRenderMode, @@ -294,7 +294,7 @@ fn main() -> Result<(), Box> { // Automatically select the best implementation for your platform. let mut watcher = notify::recommended_watcher(move |res| match res { Ok(event) => { - println!("{event:?}"); + info!("file watcher: {event:?}"); file_watcher_tx.send(()).unwrap(); } Err(e) => panic!("watch error: {:?}", e), @@ -304,7 +304,10 @@ fn main() -> Result<(), Box> { .watch(Path::new(&args.target), notify::RecursiveMode::NonRecursive) .unwrap(); - let options = eframe::NativeOptions::default(); + let mut options = eframe::NativeOptions::default(); + let size = egui::Vec2::new(640.0, 480.0); + options.viewport.inner_size = Some(size); + eframe::run_native( "Fidget", options, @@ -328,42 +331,8 @@ fn main() -> Result<(), Box> { //////////////////////////////////////////////////////////////////////////////// -#[derive(Copy, Clone)] -struct TwoDCamera { - // 2D camera parameters - scale: f32, - offset: egui::Vec2, - drag_start: Option, -} - -impl TwoDCamera { - /// Converts from mouse position to a UV position within the render window - fn mouse_to_uv( - &self, - rect: egui::Rect, - uv: egui::Rect, - p: egui::Pos2, - ) -> egui::Vec2 { - let r = (p - rect.min) / (rect.max - rect.min); - const ONE: egui::Vec2 = egui::Vec2::new(1.0, 1.0); - let pos = uv.min.to_vec2() * (ONE - r) + uv.max.to_vec2() * r; - let out = ((pos * 2.0) - ONE) * self.scale; - egui::Vec2::new(out.x, -out.y) + self.offset - } -} - -impl Default for TwoDCamera { - fn default() -> Self { - TwoDCamera { - drag_start: None, - scale: 1.0, - offset: egui::Vec2::ZERO, - } - } -} - #[derive(Copy, Clone, Eq, PartialEq)] -enum TwoDMode { +enum Mode2D { Color, Sdf, Debug, @@ -412,12 +381,12 @@ enum ThreeDMode { #[derive(Copy, Clone)] enum RenderMode { - TwoD(TwoDCamera, TwoDMode), + TwoD(camera::Camera2D, Mode2D), ThreeD(ThreeDCamera, ThreeDMode), } impl RenderMode { - fn set_2d_mode(&mut self, mode: TwoDMode) -> bool { + fn set_2d_mode(&mut self, mode: Mode2D) -> bool { match self { RenderMode::TwoD(.., m) => { let changed = *m != mode; @@ -425,7 +394,11 @@ impl RenderMode { changed } RenderMode::ThreeD(..) => { - *self = RenderMode::TwoD(TwoDCamera::default(), mode); + *self = RenderMode::TwoD( + // TODO get parameters from 3D camera here? + camera::Camera2D::new(), + mode, + ); true } } @@ -479,7 +452,7 @@ impl ViewerApp { config_tx, image_rx, - mode: RenderMode::TwoD(TwoDCamera::default(), TwoDMode::Color), + mode: RenderMode::TwoD(camera::Camera2D::new(), Mode2D::Color), } } @@ -512,13 +485,13 @@ impl ViewerApp { }; ui.radio_value( &mut mode_2d, - Some(TwoDMode::Debug), + Some(Mode2D::Debug), "2D debug", ); - ui.radio_value(&mut mode_2d, Some(TwoDMode::Sdf), "2D SDF"); + ui.radio_value(&mut mode_2d, Some(Mode2D::Sdf), "2D SDF"); ui.radio_value( &mut mode_2d, - Some(TwoDMode::Color), + Some(Mode2D::Color), "2D Color", ); @@ -569,12 +542,7 @@ impl ViewerApp { } } - fn paint_image( - &self, - rect: egui::Rect, - uv: egui::Rect, - ui: &mut egui::Ui, - ) -> egui::Response { + fn paint_image(&self, ui: &mut egui::Ui) -> egui::Response { let pos = ui.next_widget_position(); let size = ui.available_size(); let painter = ui.painter_at(egui::Rect { @@ -583,10 +551,25 @@ impl ViewerApp { }); const PADDING: egui::Vec2 = egui::Vec2 { x: 10.0, y: 10.0 }; + let RenderMode::TwoD(camera, ..) = &self.mode else { + panic!("can't render in 3D"); + }; + let rect = camera.viewport(); + let rect = egui::Rect { + min: egui::Pos2::new(rect.min.x, rect.min.y), + max: egui::Pos2::new(rect.max.x, rect.max.y), + }; + let uv = camera.uv(); + let uv = egui::Rect { + min: egui::Pos2::new(uv.min.x, uv.min.y), + max: egui::Pos2::new(uv.max.x, uv.max.y), + }; + if let Some((dt, image_size)) = self.stats { // Only draw the image if we have valid stats (i.e. no error) if let Some(t) = self.texture.as_ref() { let mut mesh = egui::Mesh::with_texture(t.id()); + mesh.add_rect_with_uv(rect, uv, egui::Color32::WHITE); painter.add(mesh); } @@ -656,60 +639,36 @@ impl eframe::App for ViewerApp { render_changed = true; } - let uv = if size.x > size.y { - let r = (1.0 - (size.y / size.x)) / 2.0; - egui::Rect { - min: egui::Pos2::new(0.0, r), - max: egui::Pos2::new(1.0, 1.0 - r), - } - } else { - let r = (1.0 - (size.x / size.y)) / 2.0; - egui::Rect { - min: egui::Pos2::new(r, 0.0), - max: egui::Pos2::new(1.0 - r, 1.0), - } - }; - // Draw the current image and/or error let r = egui::CentralPanel::default() .frame(egui::Frame::none().fill(egui::Color32::BLACK)) - .show(ctx, |ui| self.paint_image(rect, uv, ui)) + .show(ctx, |ui| self.paint_image(ui)) .inner; // Handle pan and zoom match &mut self.mode { RenderMode::TwoD(camera, ..) => { + let rect = camera::Rect { + min: Vector2::new(rect.min.x, rect.min.y), + max: Vector2::new(rect.max.x, rect.max.y), + }; + + camera.set_viewport(rect); + if let Some(pos) = r.interact_pointer_pos() { - if let Some(start) = camera.drag_start { - camera.offset = egui::Vec2::ZERO; - let pos = camera.mouse_to_uv(rect, uv, pos); - camera.offset = start - pos; - render_changed = true; - } else { - let pos = camera.mouse_to_uv(rect, uv, pos); - camera.drag_start = Some(pos); - } + let pos = Vector2::new(pos.x, pos.y); + render_changed |= camera.drag(pos); } else { - camera.drag_start = None; + camera.release(); } if r.hovered() { let scroll = ctx.input(|i| i.smooth_scroll_delta.y); - if scroll != 0.0 { - let mouse_pos = ctx.input(|i| i.pointer.hover_pos()); - let pos_before = - mouse_pos.map(|p| camera.mouse_to_uv(rect, uv, p)); - render_changed = true; - camera.scale /= (scroll / 100.0).exp2(); - if let Some(pos_before) = pos_before { - let pos_after = camera.mouse_to_uv( - rect, - uv, - mouse_pos.unwrap(), - ); - camera.offset += pos_before - pos_after; - } - } + let mouse_pos = ctx.input(|i| i.pointer.hover_pos()); + render_changed |= camera.scroll( + mouse_pos.map(|p| Vector2::new(p.x, p.y)), + scroll, + ); } } RenderMode::ThreeD(..) => {