use indicatrix_cut_core::rough_plan::{BoxFace, RoughBase, RoughCut};
use slint::{ModelRc, SharedString, VecModel};
pub(super) const EDGES: [[BoxFace; 2]; 12] = [
[BoxFace::Top, BoxFace::Front],
[BoxFace::Top, BoxFace::Back],
[BoxFace::Top, BoxFace::Left],
[BoxFace::Top, BoxFace::Right],
[BoxFace::Bottom, BoxFace::Front],
[BoxFace::Bottom, BoxFace::Back],
[BoxFace::Bottom, BoxFace::Left],
[BoxFace::Bottom, BoxFace::Right],
[BoxFace::Front, BoxFace::Left],
[BoxFace::Front, BoxFace::Right],
[BoxFace::Back, BoxFace::Left],
[BoxFace::Back, BoxFace::Right],
];
pub(super) const CORNERS: [[BoxFace; 3]; 8] = [
[BoxFace::Top, BoxFace::Front, BoxFace::Left],
[BoxFace::Top, BoxFace::Front, BoxFace::Right],
[BoxFace::Top, BoxFace::Back, BoxFace::Left],
[BoxFace::Top, BoxFace::Back, BoxFace::Right],
[BoxFace::Bottom, BoxFace::Front, BoxFace::Left],
[BoxFace::Bottom, BoxFace::Front, BoxFace::Right],
[BoxFace::Bottom, BoxFace::Back, BoxFace::Left],
[BoxFace::Bottom, BoxFace::Back, BoxFace::Right],
];
pub(super) const DEFAULT_EDGE: usize = 0;
pub(super) const DEFAULT_CORNER: usize = 1;
const DEFAULT_SETBACK_SHARE: f64 = 0.15;
const DEFAULT_DEPTH_SHARE: f64 = 0.10;
const DEFAULT_STEP_MM: f64 = 0.1;
#[must_use]
pub(super) const fn face_label(face: BoxFace) -> &'static str {
match face {
BoxFace::Top => "Top",
BoxFace::Bottom => "Bottom",
BoxFace::Right => "Right",
BoxFace::Left => "Left",
BoxFace::Front => "Front",
BoxFace::Back => "Back",
}
}
#[must_use]
pub(super) fn faces_label(faces: &[BoxFace]) -> String {
faces
.iter()
.map(|&face| face_label(face))
.collect::<Vec<_>>()
.join("-")
}
#[must_use]
pub(super) fn edge_names() -> Vec<String> {
EDGES.iter().map(|faces| faces_label(faces)).collect()
}
#[must_use]
pub(super) fn corner_names() -> Vec<String> {
CORNERS.iter().map(|faces| faces_label(faces)).collect()
}
#[must_use]
pub(super) fn options_model(names: Vec<String>) -> ModelRc<SharedString> {
let items: Vec<SharedString> = names.into_iter().map(SharedString::from).collect();
ModelRc::new(VecModel::from(items))
}
fn same_faces(a: &[BoxFace], b: &[BoxFace]) -> bool {
a.len() == b.len() && a.iter().all(|f| b.contains(f)) && b.iter().all(|f| a.contains(f))
}
#[must_use]
pub(super) fn edge_index(faces: [BoxFace; 2]) -> Option<usize> {
EDGES.iter().position(|edge| same_faces(edge, &faces))
}
#[must_use]
pub(super) fn corner_index(faces: [BoxFace; 3]) -> Option<usize> {
CORNERS.iter().position(|corner| same_faces(corner, &faces))
}
fn round_tenth(value: f64) -> f64 {
(value * 10.0).round() / 10.0
}
#[must_use]
pub(super) fn default_setback(shortest_mm: f64) -> f64 {
round_tenth(DEFAULT_SETBACK_SHARE * shortest_mm)
.max(DEFAULT_STEP_MM)
.min(shortest_mm)
}
#[must_use]
pub(super) fn clamp_edge_setbacks(
faces: [BoxFace; 2],
setbacks: [f64; 2],
extents: [f64; 3],
) -> [f64; 2] {
let axis = faces.map(|face| face.axis_and_side().0);
[
setbacks[0].min(extents[axis[1]]),
setbacks[1].min(extents[axis[0]]),
]
}
#[must_use]
pub(super) fn clamp_corner_setbacks(
faces: [BoxFace; 3],
setbacks: [f64; 3],
extents: [f64; 3],
) -> [f64; 3] {
let mut clamped = setbacks;
for (value, face) in clamped.iter_mut().zip(faces) {
*value = value.min(extents[face.axis_and_side().0]);
}
clamped
}
#[must_use]
pub(super) fn default_edge(faces: [BoxFace; 2], extents: [f64; 3]) -> RoughCut {
let axis = faces.map(|face| face.axis_and_side().0);
let setback = default_setback(extents[axis[0]].min(extents[axis[1]]));
RoughCut::Edge {
faces,
setbacks_mm: clamp_edge_setbacks(faces, [setback; 2], extents),
}
}
#[must_use]
pub(super) fn default_corner(faces: [BoxFace; 3], extents: [f64; 3]) -> RoughCut {
let setback = default_setback(extents[0].min(extents[1]).min(extents[2]));
RoughCut::Corner {
faces,
setbacks_mm: clamp_corner_setbacks(faces, [setback; 3], extents),
}
}
#[must_use]
pub(super) fn extent_along(base: &RoughBase, normal: [f64; 3]) -> f64 {
let extents = base.bounding_box_extents();
normal
.iter()
.zip(extents)
.fold(0.0, |sum, (n, e)| n.abs().mul_add(e, sum))
}
#[must_use]
pub(super) fn default_face_depth(base: &RoughBase, normal: [f64; 3]) -> f64 {
let along = extent_along(base, normal);
let depth = round_tenth(DEFAULT_DEPTH_SHARE * along).max(DEFAULT_STEP_MM);
if depth < along { depth } else { along * 0.5 }
}
#[must_use]
pub(super) fn default_face(base: &RoughBase, normal: [f64; 3]) -> RoughCut {
RoughCut::Face {
normal,
depth_mm: default_face_depth(base, normal),
}
}
fn snap(component: f64) -> f64 {
if component.abs() < 1e-12 {
0.0
} else {
component
}
}
const NORMAL_GRID: f64 = 1e12;
#[must_use]
pub(super) fn canonical_normal(raw: [f64; 3]) -> [f64; 3] {
let gridded = raw.map(|c| snap((c * NORMAL_GRID).round() / NORMAL_GRID));
let length = gridded
.iter()
.fold(0.0_f64, |sum, c| c.mul_add(*c, sum))
.sqrt();
if length > 0.0 {
gridded.map(|c| snap(c / length))
} else {
[0.0, 1.0, 0.0]
}
}
#[must_use]
pub(super) fn normal_from_angles(azimuth_deg: f64, elevation_deg: f64) -> [f64; 3] {
let (sin_az, cos_az) = azimuth_deg.to_radians().sin_cos();
let (sin_el, cos_el) = elevation_deg.to_radians().sin_cos();
canonical_normal([cos_el * cos_az, sin_el, cos_el * sin_az])
}
#[must_use]
pub(super) fn is_vertical(normal: [f64; 3]) -> bool {
let length = normal
.iter()
.fold(0.0_f64, |sum, c| c.mul_add(*c, sum))
.sqrt();
if length <= 0.0 {
return true;
}
let [x, _, z] = normal.map(|c| c / length);
snap(x) == 0.0 && snap(z) == 0.0
}
#[must_use]
pub(super) fn angles_from_normal(normal: [f64; 3]) -> (f64, f64) {
let length = normal
.iter()
.fold(0.0_f64, |sum, c| c.mul_add(*c, sum))
.sqrt();
if length <= 0.0 {
return (0.0, 90.0);
}
let [x, y, z] = normal.map(|c| c / length);
let elevation = y.clamp(-1.0, 1.0).asin().to_degrees();
let azimuth = if snap(x) == 0.0 && snap(z) == 0.0 {
0.0
} else {
z.atan2(x).to_degrees()
};
(azimuth, elevation)
}
#[cfg(test)]
mod tests {
use super::*;
const NAMES_EDGES: [&str; 12] = [
"Top-Front",
"Top-Back",
"Top-Left",
"Top-Right",
"Bottom-Front",
"Bottom-Back",
"Bottom-Left",
"Bottom-Right",
"Front-Left",
"Front-Right",
"Back-Left",
"Back-Right",
];
#[test]
fn the_option_names_follow_the_contract_order() {
assert_eq!(edge_names(), NAMES_EDGES);
let corners = corner_names();
assert_eq!(corners.len(), 8);
assert_eq!(corners[0], "Top-Front-Left");
assert_eq!(corners[1], "Top-Front-Right");
assert_eq!(corners[7], "Bottom-Back-Right");
}
#[test]
fn every_table_entry_maps_back_to_its_own_index() {
for (i, faces) in EDGES.iter().enumerate() {
assert_eq!(edge_index(*faces), Some(i));
assert_eq!(edge_index([faces[1], faces[0]]), Some(i));
}
for (i, faces) in CORNERS.iter().enumerate() {
assert_eq!(corner_index(*faces), Some(i));
assert_eq!(corner_index([faces[2], faces[0], faces[1]]), Some(i));
}
assert_eq!(edge_index([BoxFace::Top, BoxFace::Bottom]), None);
assert_eq!(edge_index([BoxFace::Top, BoxFace::Top]), None);
assert_eq!(
corner_index([BoxFace::Top, BoxFace::Bottom, BoxFace::Left]),
None
);
}
#[test]
fn every_table_entry_is_a_valid_cut_for_the_core() {
let base = RoughBase::Block {
x_mm: 10.0,
y_mm: 8.0,
z_mm: 6.0,
};
let extents = base.bounding_box_extents();
for faces in EDGES {
let cut = default_edge(faces, extents);
assert!(cut.to_halfspace(0, &base, &[]).is_ok(), "{faces:?}");
}
for faces in CORNERS {
let cut = default_corner(faces, extents);
assert!(cut.to_halfspace(0, &base, &[]).is_ok(), "{faces:?}");
}
}
#[test]
fn default_setbacks_are_fifteen_percent_of_the_shorter_extent() {
let cut = default_edge([BoxFace::Top, BoxFace::Front], [10.0, 8.0, 6.0]);
assert_eq!(
cut,
RoughCut::Edge {
faces: [BoxFace::Top, BoxFace::Front],
setbacks_mm: [0.9, 0.9],
}
);
let corner = default_corner(CORNERS[DEFAULT_CORNER], [10.0, 8.0, 6.0]);
let RoughCut::Corner { setbacks_mm, .. } = corner else {
panic!("a corner default must be a corner cut");
};
assert_eq!(setbacks_mm, [0.9; 3]);
assert!((default_setback(0.4) - 0.1).abs() < 1e-12);
assert!((default_setback(0.05) - 0.05).abs() < 1e-12);
}
#[test]
fn changing_the_faces_keeps_the_setbacks_clamped_to_the_new_extents() {
let clamped = clamp_edge_setbacks(
[BoxFace::Top, BoxFace::Front],
[5.0, 12.0],
[20.0, 9.0, 2.0],
);
assert_eq!(clamped, [2.0, 9.0]);
let corner = clamp_corner_setbacks(
[BoxFace::Top, BoxFace::Front, BoxFace::Right],
[30.0, 30.0, 30.0],
[20.0, 9.0, 2.0],
);
assert_eq!(corner, [9.0, 2.0, 20.0]);
}
#[test]
fn the_default_face_depth_is_ten_percent_of_the_extent_along_the_normal() {
let base = RoughBase::Block {
x_mm: 10.0,
y_mm: 8.0,
z_mm: 6.0,
};
assert!((default_face_depth(&base, [0.0, 1.0, 0.0]) - 0.8).abs() < 1e-12);
assert!((default_face_depth(&base, [1.0, 0.0, 0.0]) - 1.0).abs() < 1e-12);
let cut = default_face(&base, [0.0, 1.0, 0.0]);
let base_verts = [
glam::DVec3::new(0.0, 0.0, 0.0),
glam::DVec3::new(10.0, 8.0, 6.0),
];
assert!(cut.to_halfspace(0, &base, &base_verts).is_ok());
}
#[test]
fn azimuth_and_elevation_give_the_documented_directions() {
let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-12);
assert!(close(normal_from_angles(0.0, 90.0), [0.0, 1.0, 0.0]));
assert!(close(normal_from_angles(0.0, 0.0), [1.0, 0.0, 0.0]));
assert!(close(normal_from_angles(90.0, 0.0), [0.0, 0.0, 1.0]));
assert!(close(normal_from_angles(180.0, 0.0), [-1.0, 0.0, 0.0]));
assert!(close(normal_from_angles(0.0, -90.0), [0.0, -1.0, 0.0]));
let slanted = normal_from_angles(30.0, 45.0);
let length = slanted.iter().map(|c| c * c).sum::<f64>().sqrt();
assert!((length - 1.0).abs() < 1e-12, "the normal is a unit vector");
assert!(slanted[1] > 0.0 && slanted[2] > 0.0);
}
#[test]
fn a_canonical_normal_carries_no_trigonometric_noise() {
assert_eq!(canonical_normal([3e-14, 1.0, -3e-14]), [0.0, 1.0, 0.0]);
assert_eq!(canonical_normal([3.0, 4.0, 0.0]), [0.6, 0.8, 0.0]);
assert_eq!(canonical_normal([0.0; 3]), [0.0, 1.0, 0.0]);
assert_eq!(canonical_normal([f64::NAN, 0.0, 0.0]), [0.0, 1.0, 0.0]);
assert_eq!(normal_from_angles(90.0, 0.0), [0.0, 0.0, 1.0]);
assert_eq!(normal_from_angles(0.0, 90.0), [0.0, 1.0, 0.0]);
assert_eq!(normal_from_angles(0.0, -90.0), [0.0, -1.0, 0.0]);
assert_eq!(normal_from_angles(123.0, 90.0), [0.0, 1.0, 0.0]);
}
#[test]
fn only_straight_up_or_down_is_vertical() {
assert!(is_vertical([0.0, 1.0, 0.0]));
assert!(is_vertical([0.0, -1.0, 0.0]));
assert!(is_vertical([0.0, 0.0, 0.0]));
assert!(is_vertical([1e-14, 1.0, 0.0]));
assert!(!is_vertical([1.0, 0.0, 0.0]));
assert!(!is_vertical(normal_from_angles(30.0, 89.0)));
}
#[test]
fn angles_and_normals_round_trip() {
for (az, el) in [
(0.0, 90.0),
(30.0, 45.0),
(-120.0, -10.0),
(90.0, 0.0),
(179.0, 60.0),
] {
let n = normal_from_angles(az, el);
let (az2, el2) = angles_from_normal(n);
assert!((el - el2).abs() < 1e-9, "elevation {el} came back as {el2}");
if el.abs() < 89.0 {
assert!((az - az2).abs() < 1e-9, "azimuth {az} came back as {az2}");
}
}
let (az, el) = angles_from_normal([0.0, 1.0, 0.0]);
assert!(az.abs() < 1e-12 && (el - 90.0).abs() < 1e-9);
}
}