use blinc_core::layer::{Affine2D, Point};
#[derive(Clone, Debug)]
pub struct CanvasViewport {
pub pan_x: f32,
pub pan_y: f32,
pub zoom: f32,
pub min_zoom: f32,
pub max_zoom: f32,
}
impl Default for CanvasViewport {
fn default() -> Self {
Self::new()
}
}
impl CanvasViewport {
pub fn new() -> Self {
Self {
pan_x: 0.0,
pan_y: 0.0,
zoom: 1.0,
min_zoom: 0.1,
max_zoom: 10.0,
}
}
pub fn transform(&self) -> Affine2D {
let translate = Affine2D::translation(self.pan_x, self.pan_y);
let scale = Affine2D::scale(self.zoom, self.zoom);
scale.then(&translate)
}
pub fn inverse_transform(&self) -> Affine2D {
affine_inverse(&self.transform()).unwrap_or(Affine2D::IDENTITY)
}
pub fn screen_to_content(&self, screen: Point) -> Point {
self.inverse_transform().transform_point(screen)
}
pub fn content_to_screen(&self, content: Point) -> Point {
self.transform().transform_point(content)
}
pub fn pan_by(&mut self, dx: f32, dy: f32) {
self.pan_x += dx / self.zoom;
self.pan_y += dy / self.zoom;
}
pub fn zoom_at(&mut self, screen_point: Point, factor: f32) {
let content_before = self.screen_to_content(screen_point);
self.zoom = (self.zoom * factor).clamp(self.min_zoom, self.max_zoom);
let content_after = self.screen_to_content(screen_point);
self.pan_x += content_after.x - content_before.x;
self.pan_y += content_after.y - content_before.y;
}
pub fn set_zoom(&mut self, zoom: f32) {
self.zoom = zoom.clamp(self.min_zoom, self.max_zoom);
}
pub fn reset(&mut self) {
self.pan_x = 0.0;
self.pan_y = 0.0;
self.zoom = 1.0;
}
pub fn is_visible(
&self,
content_x: f32,
content_y: f32,
content_w: f32,
content_h: f32,
screen_w: f32,
screen_h: f32,
) -> bool {
let tl = self.content_to_screen(Point::new(content_x, content_y));
let br = self.content_to_screen(Point::new(content_x + content_w, content_y + content_h));
br.x > 0.0 && tl.x < screen_w && br.y > 0.0 && tl.y < screen_h
}
}
pub fn affine_inverse(affine: &Affine2D) -> Option<Affine2D> {
let [a, b, c, d, tx, ty] = affine.elements;
let det = a * d - c * b;
if det.abs() < 1e-10 {
return None;
}
let inv_det = 1.0 / det;
Some(Affine2D {
elements: [
d * inv_det,
-b * inv_det,
-c * inv_det,
a * inv_det,
(c * ty - d * tx) * inv_det,
(b * tx - a * ty) * inv_det,
],
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_affine_inverse_identity() {
let inv = affine_inverse(&Affine2D::IDENTITY).unwrap();
assert!((inv.elements[0] - 1.0).abs() < 1e-6);
assert!((inv.elements[3] - 1.0).abs() < 1e-6);
}
#[test]
fn test_affine_inverse_roundtrip() {
let t = Affine2D::translation(50.0, -30.0);
let s = Affine2D::scale(2.0, 0.5);
let combined = s.then(&t);
let inv = affine_inverse(&combined).unwrap();
let result = combined.then(&inv);
for i in 0..6 {
let expected = Affine2D::IDENTITY.elements[i];
assert!(
(result.elements[i] - expected).abs() < 1e-4,
"element {i}: {} != {}",
result.elements[i],
expected
);
}
}
#[test]
fn test_affine_inverse_singular() {
let singular = Affine2D {
elements: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
};
assert!(affine_inverse(&singular).is_none());
}
#[test]
fn test_viewport_default() {
let vp = CanvasViewport::new();
assert_eq!(vp.pan_x, 0.0);
assert_eq!(vp.pan_y, 0.0);
assert_eq!(vp.zoom, 1.0);
}
#[test]
fn test_coordinate_roundtrip() {
let mut vp = CanvasViewport::new();
vp.pan_x = 100.0;
vp.pan_y = -50.0;
vp.zoom = 2.0;
let original = Point::new(42.0, 73.0);
let screen = vp.content_to_screen(original);
let back = vp.screen_to_content(screen);
assert!((back.x - original.x).abs() < 1e-3);
assert!((back.y - original.y).abs() < 1e-3);
}
#[test]
fn test_zoom_at_keeps_point_fixed() {
let mut vp = CanvasViewport::new();
vp.pan_x = 50.0;
vp.pan_y = 30.0;
vp.zoom = 1.5;
let cursor = Point::new(200.0, 150.0);
let content_before = vp.screen_to_content(cursor);
vp.zoom_at(cursor, 1.5);
let content_after = vp.screen_to_content(cursor);
assert!((content_after.x - content_before.x).abs() < 1e-2);
assert!((content_after.y - content_before.y).abs() < 1e-2);
}
#[test]
fn test_zoom_clamp() {
let mut vp = CanvasViewport::new();
vp.set_zoom(100.0);
assert_eq!(vp.zoom, vp.max_zoom);
vp.set_zoom(0.001);
assert_eq!(vp.zoom, vp.min_zoom);
}
#[test]
fn test_pan_by() {
let mut vp = CanvasViewport::new();
vp.zoom = 2.0;
vp.pan_by(10.0, 20.0);
assert!((vp.pan_x - 5.0).abs() < 1e-6);
assert!((vp.pan_y - 10.0).abs() < 1e-6);
}
#[test]
fn test_reset() {
let mut vp = CanvasViewport::new();
vp.pan_x = 100.0;
vp.pan_y = -50.0;
vp.zoom = 3.0;
vp.reset();
assert_eq!(vp.pan_x, 0.0);
assert_eq!(vp.pan_y, 0.0);
assert_eq!(vp.zoom, 1.0);
}
#[test]
fn test_is_visible() {
let vp = CanvasViewport::new();
assert!(vp.is_visible(100.0, 100.0, 200.0, 200.0, 800.0, 600.0));
assert!(!vp.is_visible(1000.0, 1000.0, 50.0, 50.0, 800.0, 600.0));
}
}