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, mesh_io::including_text,
};
use glam::DVec3;
use indicatrix::geometry::stone_metrics::{SolidMesh, SolidStatus, build_solid_mesh};
use indicatrix_cut_core::{
rough_plan::{
RoughBase, RoughMeasure, RoughModel, ShapeError,
shape::{ClippedSurface, SurfaceCap},
},
yield_metrics::carat_weight,
};
use slint::{ComponentHandle, Model, Timer, TimerMode, Weak};
use std::{
cell::RefCell,
collections::{BTreeMap, 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)
}
}
}
const CREASE_DEGREES: f64 = 30.0;
fn corner_key(point: DVec3, cell: f64) -> [i64; 3] {
[point.x, point.y, point.z].map(|c| (c / cell).round() as i64)
}
fn mesh_of_surface(
surface: &ClippedSurface,
base_facets: usize,
centre: DVec3,
) -> Result<SolidMesh, ShapeError> {
let mut pieces: Vec<(usize, DVec3, [DVec3; 3])> = Vec::new();
for &[a, b, c] in &surface.triangles {
let normal = (b - a).cross(c - a).normalize_or_zero();
if normal != DVec3::ZERO {
pieces.push((0, normal, [a - centre, b - centre, c - centre]));
}
}
for SurfaceCap {
cut,
normal,
triangles,
} in &surface.caps
{
for &[a, b, c] in triangles {
pieces.push((
base_facets + cut,
*normal,
[a - centre, b - centre, c - centre],
));
}
}
if pieces.is_empty() {
return Err(ShapeError::NothingLeft);
}
let (mut low, mut high) = (DVec3::splat(f64::INFINITY), DVec3::splat(f64::NEG_INFINITY));
for (_, _, corners) in &pieces {
for &p in corners {
low = low.min(p);
high = high.max(p);
}
}
let cell = ((high - low).length() * 1e-6).max(1e-12);
let mut edges: BTreeMap<([i64; 3], [i64; 3]), Vec<usize>> = BTreeMap::new();
let edge_key = |corners: &[DVec3; 3], side: usize| {
let from = corner_key(corners[side], cell);
let to = corner_key(corners[(side + 1) % 3], cell);
(from.min(to), from.max(to))
};
for (index, (_, _, corners)) in pieces.iter().enumerate() {
for side in 0..3 {
edges
.entry(edge_key(corners, side))
.or_default()
.push(index);
}
}
let crease = CREASE_DEGREES.to_radians().cos();
let edge_drawn = |index: usize, side: usize| {
let (id, normal, corners) = &pieces[index];
match edges[&edge_key(corners, side)].as_slice() {
[first, second] => {
let other = if *first == index { *second } else { *first };
let (other_id, other_normal, _) = &pieces[other];
if id != other_id {
true
} else if *id == 0 {
normal.dot(*other_normal) < crease
} else {
false
}
}
_ => true,
}
};
let mut mesh = SolidMesh::default();
let mut piece_normals = Vec::with_capacity(pieces.len());
let mut visible = Vec::with_capacity(pieces.len());
for (index, (id, normal, corners)) in pieces.iter().enumerate() {
let first = u32::try_from(mesh.positions.len()).map_err(|_| ShapeError::NothingLeft)?;
for &corner in corners {
mesh.positions.push(corner);
mesh.normals.push(*normal);
mesh.facet_id.push(*id);
}
mesh.indices.extend([first, first + 1, first + 2]);
mesh.rings.push((*id, corners.to_vec()));
piece_normals.push(*normal);
visible.push((0..3).map(|side| edge_drawn(index, side)).collect());
}
mesh.piece_normals = Some(piece_normals);
mesh.edge_visible = Some(visible);
Ok(mesh)
}
fn mesh_of_model(model: &RoughModel, planes: &[(DVec3, f64)]) -> Result<SolidMesh, ShapeError> {
let centre = model.base.bounding_box_centre();
let Some(rough) = model.mesh() else {
return mesh_of_planes(planes, centre);
};
let base_facets = model.base.to_halfspaces(false)?.len();
let cuts = planes.get(base_facets..).unwrap_or_default();
mesh_of_surface(&rough.clipped_surface(cuts), base_facets, centre)
}
pub(super) fn centred_mesh(model: &RoughModel) -> Result<SolidMesh, ShapeError> {
let planes = model.halfspaces()?;
mesh_of_model(model, &planes)
}
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_model(model, &planes)?;
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, measure: &RoughMeasure) -> String {
let volume_mm3 = measure.volume_mm3;
let line = 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)
};
if measure.inclusion_count == 0 {
line
} else {
format!(
"{line} {}",
including_text(measure.inclusion_count, measure.inclusion_mm3)
)
}
}
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),
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;