use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ClippingPlane {
pub origin: [f64; 3],
pub normal: [f64; 3],
}
impl ClippingPlane {
pub fn signed_distance(&self, point: [f64; 3]) -> f64 {
let dx = point[0] - self.origin[0];
let dy = point[1] - self.origin[1];
let dz = point[2] - self.origin[2];
dx * self.normal[0] + dy * self.normal[1] + dz * self.normal[2]
}
pub fn contains(&self, point: [f64; 3]) -> bool {
self.signed_distance(point) >= 0.0
}
pub fn horizontal_cut(z_feet: f64) -> Self {
Self {
origin: [0.0, 0.0, z_feet],
normal: [0.0, 0.0, 1.0],
}
}
pub fn vertical_east(x_origin: f64) -> Self {
Self {
origin: [x_origin, 0.0, 0.0],
normal: [1.0, 0.0, 0.0],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SectionBox {
pub min: [f64; 3],
pub max: [f64; 3],
}
impl SectionBox {
pub fn new(a: [f64; 3], b: [f64; 3]) -> Self {
Self {
min: [a[0].min(b[0]), a[1].min(b[1]), a[2].min(b[2])],
max: [a[0].max(b[0]), a[1].max(b[1]), a[2].max(b[2])],
}
}
pub fn infinite() -> Self {
Self {
min: [f64::NEG_INFINITY; 3],
max: [f64::INFINITY; 3],
}
}
pub fn contains(&self, point: [f64; 3]) -> bool {
point
.iter()
.zip(self.min.iter().zip(self.max.iter()))
.all(|(p, (lo, hi))| p >= lo && p <= hi)
}
pub fn expand_to(&mut self, point: [f64; 3]) {
for (i, p) in point.iter().enumerate() {
if *p < self.min[i] {
self.min[i] = *p;
}
if *p > self.max[i] {
self.max[i] = *p;
}
}
}
pub fn size(&self) -> [f64; 3] {
[
(self.max[0] - self.min[0]).max(0.0),
(self.max[1] - self.min[1]).max(0.0),
(self.max[2] - self.min[2]).max(0.0),
]
}
pub fn center(&self) -> [f64; 3] {
[
(self.min[0] + self.max[0]) * 0.5,
(self.min[1] + self.max[1]) * 0.5,
(self.min[2] + self.max[2]) * 0.5,
]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ViewMode {
Plan,
ThreeD,
Section,
}
impl ViewMode {
pub fn default_section_box(
self,
storey_elevation_feet: f64,
model_bbox: SectionBox,
) -> SectionBox {
match self {
ViewMode::Plan => {
let z = storey_elevation_feet;
SectionBox {
min: [model_bbox.min[0], model_bbox.min[1], z - 0.5],
max: [model_bbox.max[0], model_bbox.max[1], z + 10.0],
}
}
ViewMode::Section => SectionBox {
min: [model_bbox.min[0], -0.5, model_bbox.min[2]],
max: [model_bbox.max[0], 0.5, model_bbox.max[2]],
},
ViewMode::ThreeD => SectionBox::infinite(),
}
}
pub fn is_orthographic(self) -> bool {
matches!(self, ViewMode::Plan | ViewMode::Section)
}
pub fn label(self) -> &'static str {
match self {
ViewMode::Plan => "Plan",
ViewMode::ThreeD => "3D",
ViewMode::Section => "Section",
}
}
}
impl Default for ViewMode {
fn default() -> Self {
Self::ThreeD
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn horizontal_cut_keeps_points_above() {
let p = ClippingPlane::horizontal_cut(10.0);
assert!(p.contains([0.0, 0.0, 15.0]));
assert!(p.contains([0.0, 0.0, 10.0])); assert!(!p.contains([0.0, 0.0, 5.0]));
}
#[test]
fn vertical_east_keeps_points_east() {
let p = ClippingPlane::vertical_east(5.0);
assert!(p.contains([10.0, 0.0, 0.0]));
assert!(p.contains([5.0, 0.0, 0.0])); assert!(!p.contains([0.0, 0.0, 0.0]));
}
#[test]
fn signed_distance_matches_normal_sign() {
let p = ClippingPlane::horizontal_cut(0.0);
assert!((p.signed_distance([0.0, 0.0, 3.0]) - 3.0).abs() < 1e-9);
assert!((p.signed_distance([0.0, 0.0, -2.0]) + 2.0).abs() < 1e-9);
}
#[test]
fn signed_distance_with_arbitrary_normal() {
let n = 1.0_f64 / 2.0_f64.sqrt();
let p = ClippingPlane {
origin: [0.0, 0.0, 0.0],
normal: [n, n, 0.0],
};
assert!((p.signed_distance([1.0, 1.0, 0.0]) - 2.0_f64.sqrt()).abs() < 1e-9);
}
#[test]
fn section_box_new_normalises_corners() {
let b = SectionBox::new([10.0, 0.0, 20.0], [-5.0, 15.0, -3.0]);
assert_eq!(b.min, [-5.0, 0.0, -3.0]);
assert_eq!(b.max, [10.0, 15.0, 20.0]);
}
#[test]
fn section_box_contains_interior_point() {
let b = SectionBox::new([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
assert!(b.contains([5.0, 5.0, 5.0]));
assert!(b.contains([0.0, 0.0, 0.0])); assert!(b.contains([10.0, 10.0, 10.0])); assert!(!b.contains([11.0, 0.0, 0.0]));
}
#[test]
fn section_box_infinite_contains_everything() {
let b = SectionBox::infinite();
assert!(b.contains([1e100, -1e100, 0.0]));
assert!(b.contains([0.0, 0.0, 0.0]));
}
#[test]
fn section_box_expand_to_grows_monotonically() {
let mut b = SectionBox::new([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
b.expand_to([5.0, -2.0, 0.5]);
assert_eq!(b.min, [0.0, -2.0, 0.0]);
assert_eq!(b.max, [5.0, 1.0, 1.0]);
b.expand_to([0.5, 0.0, 0.5]);
assert_eq!(b.min, [0.0, -2.0, 0.0]);
assert_eq!(b.max, [5.0, 1.0, 1.0]);
}
#[test]
fn section_box_size_matches_extents() {
let b = SectionBox::new([0.0, 0.0, 0.0], [10.0, 5.0, 3.0]);
assert_eq!(b.size(), [10.0, 5.0, 3.0]);
}
#[test]
fn section_box_size_zero_for_degenerate_axis() {
let b = SectionBox::new([5.0, 0.0, 0.0], [5.0, 10.0, 10.0]);
assert_eq!(b.size()[0], 0.0);
}
#[test]
fn section_box_center_is_midpoint() {
let b = SectionBox::new([-4.0, 0.0, 0.0], [4.0, 6.0, 2.0]);
assert_eq!(b.center(), [0.0, 3.0, 1.0]);
}
#[test]
fn clipping_plane_is_serializable() {
let p = ClippingPlane::horizontal_cut(5.0);
let json = serde_json::to_string(&p).unwrap();
let back: ClippingPlane = serde_json::from_str(&json).unwrap();
assert_eq!(back, p);
}
#[test]
fn section_box_is_serializable() {
let b = SectionBox::new([0.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
let json = serde_json::to_string(&b).unwrap();
let back: SectionBox = serde_json::from_str(&json).unwrap();
assert_eq!(back, b);
}
#[test]
fn section_box_infinite_size_is_inf() {
let b = SectionBox::infinite();
assert!(b.size()[0].is_infinite());
}
#[test]
fn view_mode_default_is_3d() {
assert_eq!(ViewMode::default(), ViewMode::ThreeD);
}
#[test]
fn view_mode_labels() {
assert_eq!(ViewMode::Plan.label(), "Plan");
assert_eq!(ViewMode::ThreeD.label(), "3D");
assert_eq!(ViewMode::Section.label(), "Section");
}
#[test]
fn view_mode_orthographic_flag() {
assert!(ViewMode::Plan.is_orthographic());
assert!(ViewMode::Section.is_orthographic());
assert!(!ViewMode::ThreeD.is_orthographic());
}
#[test]
fn plan_mode_clips_to_storey_band() {
let model_bbox = SectionBox::new([0.0, 0.0, 0.0], [100.0, 100.0, 50.0]);
let box_ = ViewMode::Plan.default_section_box(10.0, model_bbox);
assert_eq!(box_.min[2], 9.5);
assert_eq!(box_.max[2], 20.0);
assert_eq!(box_.min[0], 0.0);
assert_eq!(box_.max[0], 100.0);
}
#[test]
fn section_mode_clips_to_vertical_strip() {
let model_bbox = SectionBox::new([-50.0, -30.0, 0.0], [50.0, 30.0, 20.0]);
let box_ = ViewMode::Section.default_section_box(0.0, model_bbox);
assert_eq!(box_.min[1], -0.5);
assert_eq!(box_.max[1], 0.5);
assert_eq!(box_.min[0], -50.0);
assert_eq!(box_.max[2], 20.0);
}
#[test]
fn threed_mode_returns_infinite_box() {
let model_bbox = SectionBox::new([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
let box_ = ViewMode::ThreeD.default_section_box(0.0, model_bbox);
assert!(box_.min[0].is_infinite());
assert!(box_.max[2].is_infinite());
}
#[test]
fn view_mode_is_serde_roundtrippable() {
for m in [ViewMode::Plan, ViewMode::ThreeD, ViewMode::Section] {
let json = serde_json::to_string(&m).unwrap();
let back: ViewMode = serde_json::from_str(&json).unwrap();
assert_eq!(back, m);
}
}
#[test]
fn view_mode_serialize_uses_snake_case() {
let json = serde_json::to_string(&ViewMode::ThreeD).unwrap();
assert_eq!(json, "\"three_d\"");
}
}