Files
isokit/crates/isokit-core/src/proj.rs
T

182 lines
5.2 KiB
Rust

//! 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
}
}