//! 2:1 isometric projection with four 90 degree view rotations. //! //! View coordinates (vx, vy): the viewer sits at +vx +vy, so faces facing //! +vy show on the left (mid tone), faces facing +vx on the right (dark). use crate::fmt::r1; /// Screen units per metre along a ground axis. pub const C: f64 = 4.0; /// Screen units per metre of height. pub const CZ: f64 = 4.0; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Proj { /// 0: from the south-east, 1: south-west, 2: north-west, 3: north-east. pub rot: u8, } impl Proj { pub fn new(rot: i64) -> Self { Proj { rot: rot.rem_euclid(4) as u8 } } /// World plan point to view coordinates. #[inline] pub fn r(&self, x: f64, y: f64) -> [f64; 2] { match self.rot { 0 => [x, -y], 1 => [-y, -x], 2 => [-x, y], _ => [y, x], } } /// View coordinates back to the world plan. #[inline] pub fn r_inv(&self, vx: f64, vy: f64) -> [f64; 2] { match self.rot { 0 => [vx, -vy], 1 => [-vy, -vx], 2 => [-vx, vy], _ => [vy, vx], } } /// World point to screen. #[inline] pub fn s(&self, x: f64, y: f64, z: f64) -> [f64; 2] { let v = self.r(x, y); [(v[0] - v[1]) * C, (v[0] + v[1]) * C / 2.0 - z * CZ] } /// Screen point to the world plan at height z. pub fn unproject(&self, sx: f64, sy: f64, z: f64) -> [f64; 2] { let a = sx / C; // vx - vy let b = (sy + z * CZ) * 2.0 / C; // vx + vy self.r_inv((a + b) / 2.0, (b - a) / 2.0) } /// The view vector of a face normal if the face is turned towards the /// viewer, else None. #[inline] pub fn vis(&self, nx: f64, ny: f64) -> Option<[f64; 2]> { let v = self.r(nx, ny); if v[0] + v[1] > 1e-6 { Some(v) } else { None } } /// Screen displacement of a world displacement (for CSS motion). pub fn dv(&self, dx: f64, dy: f64, dz: f64) -> [f64; 2] { let a = self.s(dx, dy, dz); let b = self.s(0.0, 0.0, 0.0); [a[0] - b[0], a[1] - b[1]] } /// Distance towards the viewer of a plan point (larger = nearer). #[inline] pub fn depth(&self, x: f64, y: f64) -> f64 { let v = self.r(x, y); v[0] + v[1] } /// True when world x maps onto view y in this rotation: a mover should /// then run along x (the axis the depth sort compares second). pub fn along_x(&self) -> bool { self.r(1.0, 0.0)[0] == 0.0 } /// Append an SVG path `M..L..Z` through the projected points. pub fn d(&self, out: &mut String, pts: &[[f64; 3]], open: bool) { for (i, p) in pts.iter().enumerate() { let s = self.s(p[0], p[1], p[2]); out.push(if i == 0 { 'M' } else { 'L' }); r1(out, s[0]); out.push(' '); r1(out, s[1]); } if !open && !pts.is_empty() { out.push('Z'); } } /// Angle of north on screen in degrees (for the compass needle). pub fn north_deg(&self) -> f64 { let n = self.r(0.0, 1.0); ((n[0] + n[1]) / 2.0).atan2(n[0] - n[1]).to_degrees() + 90.0 } } /// Face class for a visible side face from its view-space normal. pub fn shade(v: [f64; 2]) -> &'static str { let a = v[1].atan2(v[0]).to_degrees(); if a > 67.5 { "iso-l" } else if a < 22.5 { "iso-r" } else { "iso-c" } } #[cfg(test)] mod tests { use super::*; #[test] fn rotation_zero_matches_js() { let p = Proj::new(0); // JS: ROT[0](x, y) = [x, -y]; s = [(vx - vy) * 4, (vx + vy) * 2 - z * 4] assert_eq!(p.s(1.0, 0.0, 0.0), [4.0, 2.0]); assert_eq!(p.s(0.0, 1.0, 0.0), [4.0, -2.0]); assert_eq!(p.s(0.0, 0.0, 1.0), [0.0, -4.0]); } #[test] fn rotations_wrap() { assert_eq!(Proj::new(5).rot, 1); assert_eq!(Proj::new(-1).rot, 3); } #[test] fn unproject_round_trips() { for rot in 0..4 { let p = Proj::new(rot); let s = p.s(12.5, -3.25, 2.0); let w = p.unproject(s[0], s[1], 2.0); assert!((w[0] - 12.5).abs() < 1e-9 && (w[1] + 3.25).abs() < 1e-9, "rot {rot}: {w:?}"); } } #[test] fn exactly_two_of_four_walls_face_the_viewer() { for rot in 0..4 { let p = Proj::new(rot); let n = [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] .iter() .filter(|(x, y)| p.vis(*x, *y).is_some()) .count(); assert_eq!(n, 2, "rot {rot}"); } } #[test] fn faces_get_left_and_right_shades() { let p = Proj::new(0); // rotation 0 looks from the south-east: the south face (normal -y) is on the left assert_eq!(shade(p.vis(0.0, -1.0).unwrap()), "iso-l"); assert_eq!(shade(p.vis(1.0, 0.0).unwrap()), "iso-r"); assert_eq!(shade(p.vis(1.0, -1.0).unwrap()), "iso-c"); } #[test] fn depth_grows_towards_viewer() { let p = Proj::new(0); assert!(p.depth(10.0, 0.0) > p.depth(0.0, 0.0)); // east is nearer from the south-east assert!(p.depth(0.0, -10.0) > p.depth(0.0, 0.0)); // south is nearer } }