use super::{
format::group_thousands,
host::{Host, on_host},
inputs::parse_weighed,
metrics::{format_model_readout, format_weight_check},
view,
};
use crate::{RoughPlanModel, RoughPlannerWindow, gui::latest_worker::LatestWorker};
use glam::DVec3;
use indicatrix::geometry::stone_metrics::{SolidMesh, SolidStatus, build_solid_mesh};
use indicatrix_cut_core::{
rough_plan::{RoughBase, RoughMeasure, RoughModel, ShapeError},
yield_metrics::carat_weight,
};
use slint::{ComponentHandle, Model, Timer, TimerMode, Weak};
use std::{
cell::RefCell,
collections::BTreeSet,
rc::Rc,
sync::{Arc, Mutex, PoisonError},
time::Duration,
};
const PEBBLE_DEBOUNCE: Duration = Duration::from_millis(60);
const INTERNAL_ERROR_TEXT: &str =
"The model could not be evaluated (internal error); edit the model to retry.";
#[derive(Debug, Clone)]
pub(super) struct ShapeRequest {
pub(super) revision: u64,
pub(super) model: RoughModel,
pub(super) specific_gravity: f64,
pub(super) material_name: String,
pub(super) weighed_ct: Option<f64>,
pub(super) selected_cut: Option<usize>,
}
impl ShapeRequest {
fn same_inputs(&self, other: &Self) -> bool {
self.model == other.model
&& self.specific_gravity.to_bits() == other.specific_gravity.to_bits()
&& self.material_name == other.material_name
&& self.weighed_ct.map(f64::to_bits) == other.weighed_ct.map(f64::to_bits)
&& self.selected_cut == other.selected_cut
}
}
#[derive(Debug)]
pub(super) struct ShapeOutput {
pub(super) measure: RoughMeasure,
pub(super) mesh: Arc<SolidMesh>,
pub(super) selected_cut: Option<usize>,
pub(super) removed_mm3: Option<f64>,
pub(super) model_text: String,
pub(super) check_text: String,
pub(super) check_level: i32,
pub(super) specific_gravity: f64,
}
#[derive(Debug)]
pub(super) enum ShapeFailure {
Invalid(ShapeError),
Internal,
}
impl ShapeFailure {
fn message(&self) -> String {
match self {
Self::Invalid(error) => error.to_string(),
Self::Internal => INTERNAL_ERROR_TEXT.to_string(),
}
}
const fn row(&self) -> Option<usize> {
match self {
Self::Invalid(error) => error_row(error),
Self::Internal => None,
}
}
}
#[derive(Debug)]
pub(super) struct ShapeResult {
pub(super) revision: u64,
pub(super) outcome: Result<ShapeOutput, ShapeFailure>,
}
fn mesh_of_planes(planes: &[(DVec3, f64)], centre: DVec3) -> Result<SolidMesh, ShapeError> {
let centred: Vec<(DVec3, f64)> = planes
.iter()
.map(|&(normal, offset)| (normal, offset - normal.dot(centre)))
.collect();
match build_solid_mesh(¢red) {
SolidStatus::Closed(mesh) => Ok(mesh),
SolidStatus::Unbounded { .. } | SolidStatus::Degenerate { .. } => {
Err(ShapeError::NothingLeft)
}
}
}
pub(super) fn centred_mesh(model: &RoughModel) -> Result<SolidMesh, ShapeError> {
let planes = model.halfspaces()?;
mesh_of_planes(&planes, model.base.bounding_box_centre())
}
fn removed_volume(model: &RoughModel, index: usize, volume_mm3: f64) -> Option<f64> {
if index >= model.cuts.len() {
return None;
}
let mut without = model.clone();
without.cuts.remove(index);
let full = without.measure().ok()?;
Some((full.volume_mm3 - volume_mm3).max(0.0))
}
struct Solid {
model: RoughModel,
measure: RoughMeasure,
mesh: Arc<SolidMesh>,
removed: Option<(usize, Option<f64>)>,
}
impl Solid {
fn build(model: &RoughModel) -> Result<Self, ShapeError> {
let planes = model.halfspaces()?;
let measure = model.measure_with_halfspaces(&planes)?;
let mesh = mesh_of_planes(&planes, model.base.bounding_box_centre())?;
Ok(Self {
model: model.clone(),
measure,
mesh: Arc::new(mesh),
removed: None,
})
}
fn removed_by(&mut self, cut: Option<usize>) -> Option<f64> {
let index = cut?;
if let Some((_, value)) = self.removed.filter(|&(asked, _)| asked == index) {
return value;
}
let value = removed_volume(&self.model, index, self.measure.volume_mm3);
self.removed = Some((index, value));
value
}
}
fn model_text(request: &ShapeRequest, volume_mm3: f64) -> String {
if request.material_name.is_empty() {
format!("Model {} mm³", group_thousands(volume_mm3.round() as usize))
} else {
format_model_readout(volume_mm3, request.specific_gravity, &request.material_name)
}
}
fn evaluate_reusing(
request: &ShapeRequest,
previous: Option<Solid>,
) -> (ShapeResult, Option<Solid>) {
let built = previous
.filter(|kept| kept.model == request.model)
.map_or_else(|| Solid::build(&request.model), Ok);
let mut solid = match built {
Ok(solid) => solid,
Err(error) => {
let result = ShapeResult {
revision: request.revision,
outcome: Err(ShapeFailure::Invalid(error)),
};
return (result, None);
}
};
let removed_mm3 = solid.removed_by(request.selected_cut);
let (check_text, check_level) = format_weight_check(
solid.measure.volume_mm3,
request.specific_gravity,
request.weighed_ct,
);
let output = ShapeOutput {
model_text: model_text(request, solid.measure.volume_mm3),
measure: solid.measure,
mesh: Arc::clone(&solid.mesh),
selected_cut: request.selected_cut,
removed_mm3,
check_text,
check_level,
specific_gravity: request.specific_gravity,
};
let result = ShapeResult {
revision: request.revision,
outcome: Ok(output),
};
(result, Some(solid))
}
pub(super) struct ShapeWorker {
worker: Rc<LatestWorker<ShapeRequest>>,
debounce: Timer,
pending: Rc<RefCell<Option<ShapeRequest>>>,
last: RefCell<Option<ShapeRequest>>,
}
impl ShapeWorker {
#[must_use]
pub(super) fn new(window: Weak<RoughPlannerWindow>) -> Self {
let in_flight: Arc<Mutex<Option<u64>>> = Arc::new(Mutex::new(None));
let hook_in_flight = Arc::clone(&in_flight);
let hook_window = Mutex::new(window.clone());
let mut kept: Option<Solid> = None;
let worker = LatestWorker::spawn_with_panic_hook(
"rough-shape",
move |request: ShapeRequest| {
*in_flight.lock().unwrap_or_else(PoisonError::into_inner) = Some(request.revision);
let (result, solid) = evaluate_reusing(&request, kept.take());
kept = solid;
let _ = window.upgrade_in_event_loop(move |_window| {
on_host(|host| apply_result(host, result));
});
in_flight
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
},
move || {
let revision = hook_in_flight
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
let Some(revision) = revision else { return };
let _ = hook_window
.lock()
.unwrap_or_else(PoisonError::into_inner)
.upgrade_in_event_loop(move |_window| {
on_host(|host| apply_internal_failure(host, revision));
});
},
);
Self {
worker: Rc::new(worker),
debounce: Timer::default(),
pending: Rc::new(RefCell::new(None)),
last: RefCell::new(None),
}
}
pub(super) fn submit_changed(&self, request: ShapeRequest) -> bool {
if self
.last
.borrow()
.as_ref()
.is_some_and(|last| last.same_inputs(&request))
{
return false;
}
*self.last.borrow_mut() = Some(request.clone());
if matches!(request.model.base, RoughBase::Pebble { .. }) {
*self.pending.borrow_mut() = Some(request);
let pending = Rc::clone(&self.pending);
let worker = Rc::clone(&self.worker);
self.debounce
.start(TimerMode::SingleShot, PEBBLE_DEBOUNCE, move || {
if let Some(request) = pending.borrow_mut().take() {
worker.submit(request);
}
});
} else {
self.pending.borrow_mut().take();
self.debounce.stop();
if !self.worker.submit(request) {
self.last.borrow_mut().take();
return false;
}
}
true
}
pub(super) fn invalidate(&self) {
self.last.borrow_mut().take();
self.pending.borrow_mut().take();
self.debounce.stop();
}
fn forget_last(&self) {
self.last.borrow_mut().take();
}
}
#[must_use]
const fn error_row(error: &ShapeError) -> Option<usize> {
match *error {
ShapeError::CutNotForBase { index }
| ShapeError::BadFaces { index }
| ShapeError::BadSetback { index, .. }
| ShapeError::BadNormal { index }
| ShapeError::BadDepth { index, .. } => Some(index),
ShapeError::NonPositiveSize
| ShapeError::TooLarge
| ShapeError::NothingLeft
| ShapeError::BadInset { .. } => None,
}
}
#[must_use]
fn removed_text(removed_mm3: f64, specific_gravity: f64) -> String {
format!(
"removes {} mm³ ({:.2} ct)",
group_thousands(removed_mm3.round() as usize),
carat_weight(removed_mm3, specific_gravity)
)
}
struct RowNotes {
error_row: Option<usize>,
error: String,
removed: Option<(usize, String)>,
}
fn set_row_notes(model: &RoughPlanModel<'_>, notes: &RowNotes, typing_errors: &BTreeSet<usize>) {
let rows = model.get_cuts();
for index in 0..rows.row_count() {
let Some(mut row) = rows.row_data(index) else {
continue;
};
let error = if notes.error_row == Some(index) {
notes.error.as_str()
} else {
""
};
let removed = notes
.removed
.as_ref()
.filter(|(at, _)| *at == index)
.map_or("", |(_, text)| text.as_str());
let error_changed = !typing_errors.contains(&index) && row.error.as_str() != error;
if error_changed || row.removed_text.as_str() != removed {
if error_changed {
row.error = error.into();
}
row.removed_text = removed.into();
rows.set_row_data(index, row);
}
}
}
fn weight_check_lines(weighed_text: &str, check_text: String, check_level: i32) -> (String, i32) {
match parse_weighed(weighed_text) {
Err(message) => (message, 3),
Ok(_) => (check_text, check_level),
}
}
pub(super) fn set_weight_check(model: &RoughPlanModel<'_>, check_text: String, check_level: i32) {
let (text, level) = weight_check_lines(&model.get_weighed_ct(), check_text, check_level);
model.set_weight_check_text(text.into());
model.set_weight_check_level(level);
}
fn apply_result(host: &Rc<Host>, result: ShapeResult) {
let (current, typing_errors) = {
let session = host.session.borrow();
let rows: BTreeSet<usize> = session.bad_fields.iter().map(|&(row, _)| row).collect();
(session.revision == result.revision, rows)
};
if !current {
return;
}
let model = host.window.global::<RoughPlanModel>();
match result.outcome {
Ok(output) => {
let removed = output
.removed_mm3
.zip(output.selected_cut)
.map(|(mm3, row)| (row, removed_text(mm3, output.specific_gravity)));
super::carat::show(host, &model, &output);
{
let mut session = host.session.borrow_mut();
session.model_mesh = Some(output.mesh);
session.model_measure = Some(output.measure);
}
model.set_model_text(output.model_text.into());
model.set_model_error("".into());
let notes = RowNotes {
error_row: None,
error: String::new(),
removed,
};
set_row_notes(&model, ¬es, &typing_errors);
}
Err(failure) => {
{
let mut session = host.session.borrow_mut();
session.model_mesh = None;
session.model_measure = None;
}
model.set_model_text("".into());
set_weight_check(&model, String::new(), 0);
let text = failure.message();
model.set_model_error(text.clone().into());
let notes = RowNotes {
error_row: failure.row(),
error: text,
removed: None,
};
set_row_notes(&model, ¬es, &typing_errors);
}
}
view::model_changed(host);
}
fn apply_internal_failure(host: &Rc<Host>, revision: u64) {
if host.session.borrow().revision == revision {
host.shape.forget_last();
}
let result = ShapeResult {
revision,
outcome: Err(ShapeFailure::Internal),
};
apply_result(host, result);
}
pub(super) fn selected_cut_changed(host: &Rc<Host>) {
super::editing::after_change(host);
}
pub(super) fn show_blank(host: &Rc<Host>) {
{
let mut session = host.session.borrow_mut();
session.model_mesh = None;
session.model_measure = None;
}
let model = host.window.global::<RoughPlanModel>();
model.set_model_text("".into());
set_weight_check(&model, String::new(), 0);
model.set_model_error("".into());
let notes = RowNotes {
error_row: None,
error: String::new(),
removed: None,
};
let typing_errors: BTreeSet<usize> = host
.session
.borrow()
.bad_fields
.iter()
.map(|&(row, _)| row)
.collect();
set_row_notes(&model, ¬es, &typing_errors);
view::model_changed(host);
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix_cut_core::rough_plan::{BoxFace, RoughCut};
fn evaluate(request: &ShapeRequest) -> ShapeResult {
evaluate_reusing(request, None).0
}
fn request(model: RoughModel) -> ShapeRequest {
ShapeRequest {
revision: 7,
model,
specific_gravity: 2.65,
material_name: "Quartz".to_string(),
weighed_ct: None,
selected_cut: None,
}
}
fn block() -> RoughModel {
RoughModel::new(
RoughBase::Block {
x_mm: 10.0,
y_mm: 8.0,
z_mm: 6.0,
},
Vec::new(),
)
}
fn cornered_block() -> RoughModel {
let mut model = block();
model.cuts.push(RoughCut::Corner {
faces: [BoxFace::Top, BoxFace::Front, BoxFace::Right],
setbacks_mm: [4.0, 6.0, 3.0],
});
model
}
#[test]
fn a_plain_block_has_its_box_volume_and_a_centred_mesh() {
let result = evaluate(&request(block()));
assert_eq!(result.revision, 7);
let output = result.outcome.expect("a plain block is valid");
assert!((output.measure.volume_mm3 - 480.0).abs() < 1e-6);
assert!(
(output.specific_gravity - 2.65).abs() < 1e-12,
"the gravity is carried for the removed-volume text"
);
assert!(
output.model_text.contains("480 mm³"),
"{}",
output.model_text
);
assert!(
output.model_text.contains("Quartz"),
"{}",
output.model_text
);
assert_eq!(output.check_level, 0);
let mut low = DVec3::splat(f64::INFINITY);
let mut high = DVec3::splat(f64::NEG_INFINITY);
for &p in &output.mesh.positions {
low = low.min(p);
high = high.max(p);
}
assert!((low + high).length() < 1e-9, "the box centre is the origin");
assert!((high - low - DVec3::new(10.0, 8.0, 6.0)).length() < 1e-9);
}
#[test]
fn the_selected_cut_reports_the_volume_it_removes() {
let mut ask = request(cornered_block());
ask.selected_cut = Some(0);
let output = evaluate(&ask).outcome.expect("valid corner cut");
assert!((output.measure.volume_mm3 - 468.0).abs() < 1e-6);
let removed = output.removed_mm3.expect("the selected cut has a volume");
assert!((removed - 12.0).abs() < 1e-6, "removed {removed}");
assert_eq!(output.selected_cut, Some(0), "the result names its cut");
ask.selected_cut = Some(5);
assert!(
evaluate(&ask)
.outcome
.expect("still valid")
.removed_mm3
.is_none()
);
ask.selected_cut = None;
let output = evaluate(&ask).outcome.expect("still valid");
assert!(output.removed_mm3.is_none());
assert_eq!(output.selected_cut, None);
}
#[test]
fn a_request_that_only_changes_the_cut_reuses_the_mesh_and_measure() {
let first = request(cornered_block());
let (first_result, kept) = evaluate_reusing(&first, None);
let first_output = first_result.outcome.expect("valid");
assert!(first_output.removed_mm3.is_none());
let mut second = first;
second.revision = 8;
second.selected_cut = Some(0);
let (second_result, kept) = evaluate_reusing(&second, kept);
let second_output = second_result.outcome.expect("valid");
assert!(
Arc::ptr_eq(&first_output.mesh, &second_output.mesh),
"the same model keeps its mesh"
);
assert_eq!(first_output.measure, second_output.measure);
let removed = second_output.removed_mm3.expect("the cut has a volume");
assert!((removed - 12.0).abs() < 1e-6, "removed {removed}");
let kept = kept.expect("the solid is kept");
assert_eq!(kept.removed, Some((0, second_output.removed_mm3)));
let mut third = second;
third.model = block();
third.selected_cut = None;
let (third_result, _) = evaluate_reusing(&third, Some(kept));
let third_output = third_result.outcome.expect("valid");
assert!(!Arc::ptr_eq(&second_output.mesh, &third_output.mesh));
assert!((third_output.measure.volume_mm3 - 480.0).abs() < 1e-6);
}
#[test]
fn a_bad_cut_is_reported_with_its_row() {
let mut model = block();
model.cuts.push(RoughCut::Edge {
faces: [BoxFace::Top, BoxFace::Front],
setbacks_mm: [1.0, 1.0],
});
model.cuts.push(RoughCut::Edge {
faces: [BoxFace::Top, BoxFace::Front],
setbacks_mm: [-1.0, 1.0],
});
let (result, kept) = evaluate_reusing(&request(model), None);
let failure = result.outcome.expect_err("negative setback");
assert!(kept.is_none(), "an invalid model keeps nothing");
assert!(
matches!(
failure,
ShapeFailure::Invalid(ShapeError::BadSetback { index: 1, .. })
),
"{failure:?}"
);
assert_eq!(failure.row(), Some(1));
assert_eq!(error_row(&ShapeError::NothingLeft), None);
}
#[test]
fn an_internal_failure_names_no_row_and_tells_the_user_to_edit() {
let failure = ShapeFailure::Internal;
assert_eq!(failure.row(), None);
assert_eq!(
failure.message(),
"The model could not be evaluated (internal error); edit the model to retry."
);
assert_eq!(
ShapeFailure::Invalid(ShapeError::NothingLeft).message(),
ShapeError::NothingLeft.to_string()
);
}
#[test]
fn the_weight_check_uses_the_weighed_carat() {
let mut ask = request(block());
ask.weighed_ct = Some(6.4);
let output = evaluate(&ask).outcome.expect("valid");
assert_eq!(output.check_level, 1);
ask.weighed_ct = Some(3.0);
assert_eq!(evaluate(&ask).outcome.expect("valid").check_level, 3);
}
#[test]
fn without_a_material_the_model_is_not_accused_of_the_wrong_weight() {
let mut ask = request(block());
ask.specific_gravity = 0.0;
ask.material_name = String::new();
ask.weighed_ct = Some(6.4);
let output = evaluate(&ask).outcome.expect("valid");
assert_eq!(output.check_level, 0);
assert!(output.check_text.is_empty(), "{}", output.check_text);
assert_eq!(output.model_text, "Model 480 mm³");
}
#[test]
fn a_bad_weighed_field_replaces_only_the_weight_check_line() {
assert_eq!(
weight_check_lines("", "close".to_string(), 2),
("close".to_string(), 2)
);
assert_eq!(
weight_check_lines("8,9", "matches".to_string(), 1),
("matches".to_string(), 1)
);
for typed in ["abc", "0", "-1", "inf"] {
let (text, level) = weight_check_lines(typed, "matches".to_string(), 1);
assert_eq!(level, 3, "{typed}");
assert!(text.contains("Weighed carat"), "{typed}: {text}");
}
let output = evaluate(&request(block())).outcome.expect("valid block");
assert_eq!(output.check_level, 0);
assert!(output.measure.volume_mm3 > 0.0);
}
#[test]
fn requests_that_differ_only_in_the_revision_are_the_same_inputs() {
let a = request(block());
let mut b = a.clone();
b.revision += 1;
assert!(a.same_inputs(&b));
b.selected_cut = Some(0);
assert!(!a.same_inputs(&b));
let mut c = a.clone();
c.weighed_ct = Some(1.0);
assert!(!a.same_inputs(&c));
}
#[test]
fn the_removed_line_names_volume_and_carats() {
assert_eq!(removed_text(84.0, 2.6), "removes 84 mm³ (1.09 ct)");
}
}