use super::cut_faces::{
angles_from_normal, corner_index, edge_index, faces_label, is_vertical, normal_from_angles,
};
use crate::RoughCutRow;
use indicatrix_cut_core::rough_plan::{RoughCut, RoughModel};
use slint::{ModelRc, SharedString, VecModel};
use std::collections::BTreeMap;
pub(super) const POLE_HINT: &str = "azimuth has no effect at elevation \u{b1}90";
const AZIMUTH_LIMIT_DEG: f64 = 180.0;
const ELEVATION_LIMIT_DEG: f64 = 90.0;
#[must_use]
pub(super) fn fmt_num(value: f64, decimals: usize) -> String {
let text = format!("{value:.decimals$}");
let trimmed = if text.contains('.') {
text.trim_end_matches('0').trim_end_matches('.').to_string()
} else {
text
};
if trimmed == "-0" {
"0".to_string()
} else {
trimmed
}
}
#[must_use]
pub(super) fn cut_row(cut: &RoughCut, typed_azimuth: Option<f64>) -> RoughCutRow {
let mut row = RoughCutRow::default();
match cut {
RoughCut::Edge { faces, setbacks_mm } => {
row.kind = 0;
row.title = format!("Edge {}", faces_label(faces)).into();
row.a = fmt_num(setbacks_mm[0], 2).into();
row.b = fmt_num(setbacks_mm[1], 2).into();
row.face_index = edge_index(*faces).map_or(-1, super::format::to_i32);
}
RoughCut::Corner { faces, setbacks_mm } => {
row.kind = 1;
row.title = format!("Corner {}", faces_label(faces)).into();
row.a = fmt_num(setbacks_mm[0], 2).into();
row.b = fmt_num(setbacks_mm[1], 2).into();
row.c = fmt_num(setbacks_mm[2], 2).into();
row.face_index = corner_index(*faces).map_or(-1, super::format::to_i32);
}
RoughCut::Face { normal, depth_mm } => {
let (derived, elevation) = angles_from_normal(*normal);
let vertical = is_vertical(*normal);
let azimuth = if vertical {
typed_azimuth.unwrap_or(derived)
} else {
derived
};
row.kind = 2;
row.title = "Face".into();
if vertical {
row.hint = POLE_HINT.into();
}
row.azimuth = fmt_num(azimuth, 1).into();
row.elevation = fmt_num(elevation, 1).into();
row.depth = fmt_num(*depth_mm, 2).into();
row.face_index = -1;
}
}
row
}
#[must_use]
pub(super) fn cut_rows(
model: &RoughModel,
typed_azimuths: &BTreeMap<usize, f64>,
) -> ModelRc<RoughCutRow> {
let rows: Vec<RoughCutRow> = model
.cuts
.iter()
.enumerate()
.map(|(index, cut)| cut_row(cut, typed_azimuths.get(&index).copied()))
.collect();
ModelRc::new(VecModel::from(rows))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum CutField {
A,
B,
C,
Azimuth,
Elevation,
Depth,
}
impl CutField {
#[must_use]
pub(super) const fn from_index(index: i32) -> Option<Self> {
match index {
0 => Some(Self::A),
1 => Some(Self::B),
2 => Some(Self::C),
3 => Some(Self::Azimuth),
4 => Some(Self::Elevation),
5 => Some(Self::Depth),
_ => None,
}
}
#[must_use]
pub(super) const fn number(self) -> i32 {
match self {
Self::A => 0,
Self::B => 1,
Self::C => 2,
Self::Azimuth => 3,
Self::Elevation => 4,
Self::Depth => 5,
}
}
#[must_use]
pub(super) const fn label(self) -> &'static str {
match self {
Self::A => "Setback A",
Self::B => "Setback B",
Self::C => "Setback C",
Self::Azimuth => "Azimuth",
Self::Elevation => "Elevation",
Self::Depth => "Depth",
}
}
#[must_use]
pub(super) const fn unit(self) -> &'static str {
match self {
Self::Azimuth | Self::Elevation => "degrees",
Self::A | Self::B | Self::C | Self::Depth => "mm",
}
}
}
fn no_such_field(field: CutField) -> String {
format!("This cut has no {} field.", field.label())
}
fn apply_angle(
cut: &mut RoughCut,
field: CutField,
value: f64,
typed_azimuth: Option<f64>,
) -> Result<f64, String> {
let RoughCut::Face { normal, .. } = cut else {
return Err(no_such_field(field));
};
let limit = if field == CutField::Azimuth {
AZIMUTH_LIMIT_DEG
} else {
ELEVATION_LIMIT_DEG
};
if !(-limit..=limit).contains(&value) {
return Err(format!(
"{} must be between -{limit} and {limit} degrees.",
field.label()
));
}
let (derived, current_elevation) = angles_from_normal(*normal);
let current_azimuth = if is_vertical(*normal) {
typed_azimuth.unwrap_or(derived)
} else {
derived
};
let (azimuth, elevation) = if field == CutField::Azimuth {
(value, current_elevation)
} else {
(current_azimuth, value)
};
*normal = normal_from_angles(azimuth, elevation);
Ok(azimuth)
}
pub(super) fn apply_field(
cut: &mut RoughCut,
field: CutField,
value: f64,
typed_azimuth: Option<f64>,
) -> Result<Option<f64>, String> {
match field {
CutField::A | CutField::B | CutField::C => {
let slot = match field {
CutField::A => 0,
CutField::B => 1,
_ => 2,
};
let setback = setbacks_mut(cut)
.and_then(|setbacks| setbacks.get_mut(slot))
.ok_or_else(|| no_such_field(field))?;
*setback = value;
Ok(None)
}
CutField::Azimuth | CutField::Elevation => {
apply_angle(cut, field, value, typed_azimuth).map(Some)
}
CutField::Depth => {
if let RoughCut::Face { depth_mm, .. } = cut {
*depth_mm = value;
Ok(None)
} else {
Err(no_such_field(field))
}
}
}
}
#[must_use]
pub(super) fn field_text(row: &RoughCutRow, field: CutField) -> SharedString {
match field {
CutField::A => row.a.clone(),
CutField::B => row.b.clone(),
CutField::C => row.c.clone(),
CutField::Azimuth => row.azimuth.clone(),
CutField::Elevation => row.elevation.clone(),
CutField::Depth => row.depth.clone(),
}
}
pub(super) fn set_field_text(row: &mut RoughCutRow, field: CutField, text: &str) {
let slot = match field {
CutField::A => &mut row.a,
CutField::B => &mut row.b,
CutField::C => &mut row.c,
CutField::Azimuth => &mut row.azimuth,
CutField::Elevation => &mut row.elevation,
CutField::Depth => &mut row.depth,
};
*slot = text.into();
}
const fn setbacks_mut(cut: &mut RoughCut) -> Option<&mut [f64]> {
match cut {
RoughCut::Edge { setbacks_mm, .. } => Some(setbacks_mm.as_mut_slice()),
RoughCut::Corner { setbacks_mm, .. } => Some(setbacks_mm),
RoughCut::Face { .. } => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix_cut_core::rough_plan::{BoxFace, RoughBase};
use slint::Model;
fn model() -> RoughModel {
RoughModel::new(
RoughBase::Block {
x_mm: 10.0,
y_mm: 8.0,
z_mm: 6.0,
},
vec![
RoughCut::Edge {
faces: [BoxFace::Front, BoxFace::Top],
setbacks_mm: [1.5, 2.0],
},
RoughCut::Corner {
faces: [BoxFace::Top, BoxFace::Front, BoxFace::Right],
setbacks_mm: [0.9, 1.0, 1.25],
},
RoughCut::Face {
normal: [0.0, 1.0, 0.0],
depth_mm: 0.8,
},
],
)
}
#[test]
fn numbers_drop_trailing_zeros() {
assert_eq!(fmt_num(3.5, 2), "3.5");
assert_eq!(fmt_num(12.0, 2), "12");
assert_eq!(fmt_num(0.126, 2), "0.13");
assert_eq!(fmt_num(-0.001, 1), "0");
assert_eq!(fmt_num(90.0, 1), "90");
assert_eq!(fmt_num(-30.04, 1), "-30");
}
#[test]
fn rows_show_the_titles_values_and_drop_down_indices() {
let model = model();
let edge = cut_row(&model.cuts[0], None);
assert_eq!(edge.kind, 0);
assert_eq!(edge.title.as_str(), "Edge Front-Top");
assert_eq!((edge.a.as_str(), edge.b.as_str()), ("1.5", "2"));
assert_eq!(edge.face_index, 0);
let corner = cut_row(&model.cuts[1], None);
assert_eq!(corner.kind, 1);
assert_eq!(corner.title.as_str(), "Corner Top-Front-Right");
assert_eq!(corner.c.as_str(), "1.25");
assert_eq!(corner.face_index, 1);
let face = cut_row(&model.cuts[2], None);
assert_eq!(face.kind, 2);
assert_eq!(face.title.as_str(), "Face");
assert_eq!(
face.hint.as_str(),
"azimuth has no effect at elevation \u{b1}90"
);
assert_eq!((edge.hint.as_str(), corner.hint.as_str()), ("", ""));
assert_eq!(face.face_index, -1);
assert_eq!(face.azimuth.as_str(), "0");
assert_eq!(face.elevation.as_str(), "90");
assert_eq!(face.depth.as_str(), "0.8");
}
#[test]
fn an_edited_field_lands_in_the_right_place_and_only_there() {
let mut model = model();
apply_field(&mut model.cuts[0], CutField::B, 3.0, None)
.expect("edges have a second setback");
assert_eq!(
model.cuts[0],
RoughCut::Edge {
faces: [BoxFace::Front, BoxFace::Top],
setbacks_mm: [1.5, 3.0],
}
);
apply_field(&mut model.cuts[1], CutField::C, 2.5, None)
.expect("corners have a third setback");
let RoughCut::Corner { setbacks_mm, .. } = &model.cuts[1] else {
panic!("still a corner");
};
assert_eq!(*setbacks_mm, [0.9, 1.0, 2.5]);
assert!(apply_field(&mut model.cuts[0], CutField::C, 1.0, None).is_err());
assert!(apply_field(&mut model.cuts[2], CutField::A, 1.0, None).is_err());
assert!(apply_field(&mut model.cuts[0], CutField::Depth, 1.0, None).is_err());
assert!(apply_field(&mut model.cuts[0], CutField::Azimuth, 10.0, None).is_err());
}
fn slanted_face() -> RoughCut {
RoughCut::Face {
normal: normal_from_angles(30.0, 45.0),
depth_mm: 1.0,
}
}
fn face_normal(cut: &RoughCut) -> [f64; 3] {
let RoughCut::Face { normal, .. } = cut else {
panic!("a face cut");
};
*normal
}
#[test]
fn editing_one_angle_keeps_the_other() {
let mut cut = slanted_face();
let used =
apply_field(&mut cut, CutField::Azimuth, 100.0, None).expect("faces have an azimuth");
assert_eq!(used, Some(100.0), "an azimuth edit defines the azimuth");
let (azimuth, elevation) = angles_from_normal(face_normal(&cut));
assert!((azimuth - 100.0).abs() < 1e-9);
assert!((elevation - 45.0).abs() < 1e-9);
}
#[test]
fn angles_outside_their_range_are_refused_and_change_nothing() {
for (field, value) in [
(CutField::Azimuth, 180.5),
(CutField::Azimuth, -181.0),
(CutField::Azimuth, 1e300),
(CutField::Elevation, 90.5),
(CutField::Elevation, -91.0),
(CutField::Elevation, 1e300),
] {
let mut cut = slanted_face();
let error = apply_field(&mut cut, field, value, None).unwrap_err();
assert!(error.contains(field.label()), "{error}");
assert_eq!(cut, slanted_face(), "{field:?} {value}");
}
let mut cut = slanted_face();
assert!(apply_field(&mut cut, CutField::Azimuth, -180.0, None).is_ok());
assert!(apply_field(&mut cut, CutField::Azimuth, 180.0, None).is_ok());
assert!(apply_field(&mut cut, CutField::Elevation, -90.0, None).is_ok());
assert!(apply_field(&mut cut, CutField::Elevation, 90.0, None).is_ok());
assert_eq!(
apply_field(&mut slanted_face(), CutField::Elevation, 90.5, None).unwrap_err(),
"Elevation must be between -90 and 90 degrees."
);
}
#[test]
fn the_azimuth_survives_a_visit_to_the_pole() {
let mut cut = slanted_face();
let at_pole = apply_field(&mut cut, CutField::Elevation, 90.0, None)
.expect("faces have an elevation")
.expect("an elevation edit names the azimuth");
assert!((at_pole - 30.0).abs() < 1e-9);
assert_eq!(face_normal(&cut), [0.0, 1.0, 0.0]);
let typed = apply_field(&mut cut, CutField::Azimuth, 45.0, Some(at_pole))
.expect("faces have an azimuth");
assert_eq!(typed, Some(45.0));
assert_eq!(face_normal(&cut), [0.0, 1.0, 0.0]);
apply_field(&mut cut, CutField::Elevation, 45.0, typed).expect("faces have an elevation");
let (azimuth, elevation) = angles_from_normal(face_normal(&cut));
assert!((azimuth - 45.0).abs() < 1e-9, "azimuth {azimuth}");
assert!((elevation - 45.0).abs() < 1e-9, "elevation {elevation}");
}
#[test]
fn a_vertical_face_row_shows_the_typed_azimuth_and_the_hint() {
let up = RoughCut::Face {
normal: [0.0, 1.0, 0.0],
depth_mm: 1.0,
};
let row = cut_row(&up, Some(45.0));
assert_eq!(row.azimuth.as_str(), "45");
assert_eq!(row.elevation.as_str(), "90");
assert_eq!(row.hint.as_str(), POLE_HINT);
assert_eq!(row.title.as_str(), "Face");
let row = cut_row(&slanted_face(), Some(99.0));
assert_eq!(row.azimuth.as_str(), "30");
assert_eq!(row.title.as_str(), "Face");
assert_eq!(row.hint.as_str(), "");
let typed = BTreeMap::from([(2, 45.0)]);
let rows = cut_rows(&model(), &typed);
let face = rows.row_data(2).expect("the model has three cuts");
assert_eq!(face.azimuth.as_str(), "45");
let edge = rows.row_data(0).expect("the model has three cuts");
assert_eq!(edge.title.as_str(), "Edge Front-Top");
}
#[test]
fn a_row_field_is_read_and_written_by_its_field() {
let mut row = cut_row(&model().cuts[1], None);
assert_eq!(field_text(&row, CutField::C).as_str(), "1.25");
set_field_text(&mut row, CutField::C, "x");
assert_eq!(row.c.as_str(), "x");
assert_eq!(row.a.as_str(), "0.9", "the other fields stay");
let mut face = cut_row(&model().cuts[2], None);
set_field_text(&mut face, CutField::Depth, "2");
assert_eq!(field_text(&face, CutField::Depth).as_str(), "2");
}
#[test]
fn a_field_that_is_not_a_number_names_its_own_unit() {
use crate::gui::rough_plan::inputs::parse_with_unit;
let message = |field: CutField| {
parse_with_unit("abc", field.label(), field.unit()).expect_err("not a number")
};
assert_eq!(
message(CutField::Azimuth),
"Azimuth must be a number in degrees."
);
assert_eq!(
message(CutField::Elevation),
"Elevation must be a number in degrees."
);
for (field, text) in [
(CutField::A, "Setback A must be a number in mm."),
(CutField::B, "Setback B must be a number in mm."),
(CutField::C, "Setback C must be a number in mm."),
(CutField::Depth, "Depth must be a number in mm."),
] {
assert_eq!(message(field), text);
}
assert_eq!(parse_with_unit("-12,5", "Azimuth", "degrees"), Ok(-12.5));
assert_eq!(
parse_with_unit("inf", "Elevation", "degrees"),
Err("Elevation must be a finite number.".to_string())
);
}
#[test]
fn the_window_field_numbers_map_to_fields() {
assert_eq!(CutField::from_index(0), Some(CutField::A));
assert_eq!(CutField::from_index(5), Some(CutField::Depth));
assert_eq!(CutField::from_index(6), None);
assert_eq!(CutField::from_index(-1), None);
for number in 0..=5 {
let field = CutField::from_index(number).expect("0 to 5 are fields");
assert_eq!(field.number(), number);
}
}
}