use super::{
base::{
BaseFields, blank_model, is_blank, parse_base, pending_base, push_base, switch_base_kind,
},
cut_faces::{
CORNERS, DEFAULT_CORNER, DEFAULT_EDGE, EDGES, clamp_corner_setbacks, clamp_edge_setbacks,
default_corner, default_edge, default_face,
},
cut_rows::{CutField, apply_field, cut_row, cut_rows, field_text, set_field_text},
format::to_i32,
host::{Host, on_host, on_idle_host},
inputs::{PICK_MATERIAL_MESSAGE, is_input_message, parse_weighed, parse_with_unit},
shape_worker::{ShapeRequest, show_blank},
};
use crate::{RoughCutRow, RoughPlanModel};
use indicatrix_cut_core::rough_plan::{RoughBase, RoughCut};
use slint::{ComponentHandle, Model};
use std::{rc::Rc, time::Duration};
const REVERT_DELAY: Duration = Duration::from_millis(30);
const BLOCK_ONLY: &str = "Edge and corner cuts are only supported on block rough.";
const NEEDS_SIZE: &str = "Enter the rough size before adding cuts.";
pub(super) fn setup_edit_callbacks(host: &Rc<Host>) {
let model = host.window.global::<RoughPlanModel>();
model.on_set_base(|kind| on_idle_host(|host| set_base(host, kind)));
model.on_import_obj(|| on_idle_host(super::obj_import::start));
model.on_base_field_committed(|| on_idle_host(base_field_committed));
model.on_fit_to_weight(|| on_idle_host(super::carat::fit_to_weight));
model.on_add_cut(|kind| on_idle_host(|host| add_cut(host, kind)));
model.on_remove_cut(|row| on_idle_host(|host| remove_cut(host, row)));
model.on_cut_field_edited(|row, field, text| {
on_idle_host(|host| cut_field_edited(host, row, field, &text));
});
model.on_cut_field_committed(|row| on_idle_host(|host| cut_field_committed(host, row)));
model.on_cut_faces_changed(|row, index| {
on_idle_host(|host| cut_faces_changed(host, row, index));
});
model.on_undo_shape(|| on_idle_host(undo_shape));
model.on_redo_shape(|| on_idle_host(redo_shape));
model.on_new_model(|| on_idle_host(new_model));
super::inclusions::setup_inclusion_callbacks(host);
}
pub(super) struct ShapeInputs {
pub(super) specific_gravity: f64,
pub(super) material_name: String,
pub(super) weighed_ct: Option<f64>,
pub(super) selected_cut: Option<usize>,
}
pub(super) fn read_inputs(host: &Rc<Host>) -> ShapeInputs {
let model = host.window.global::<RoughPlanModel>();
let weighed_ct = super::carat::typed_carat(host).ok().flatten();
let (material_name, specific_gravity) = usize::try_from(model.get_material_index())
.ok()
.and_then(|i| {
host.session
.borrow()
.choices
.get(i)
.map(|c| (c.name.clone(), c.specific_gravity))
})
.unwrap_or_default();
ShapeInputs {
specific_gravity,
material_name,
weighed_ct,
selected_cut: usize::try_from(model.get_selected_cut()).ok(),
}
}
pub(super) fn input_error(host: &Rc<Host>, message: &str) {
host.session.borrow_mut().revision += 1;
host.shape.invalidate();
host.window
.global::<RoughPlanModel>()
.set_model_error(message.into());
}
fn clear_input_error(host: &Rc<Host>) {
let model = host.window.global::<RoughPlanModel>();
if is_input_message(model.get_error_text().as_str()) {
model.set_error_text("".into());
}
}
pub(super) fn after_change(host: &Rc<Host>) {
clear_input_error(host);
let inputs = read_inputs(host);
let mut session = host.session.borrow_mut();
if is_blank(&session.model) {
session.revision += 1;
drop(session);
host.shape.invalidate();
show_blank(host);
return;
}
let request = ShapeRequest {
revision: session.revision + 1,
model: session.model.clone(),
specific_gravity: inputs.specific_gravity,
material_name: inputs.material_name,
weighed_ct: inputs.weighed_ct,
selected_cut: inputs.selected_cut,
};
if host.shape.submit_changed(request) {
session.revision += 1;
}
}
pub(super) fn readout_inputs_changed(host: &Rc<Host>) {
host.shape.invalidate();
after_change(host);
}
pub(super) fn push_history_flags(host: &Rc<Host>) {
let (can_undo, can_redo) = {
let session = host.session.borrow();
(session.can_undo(), session.can_redo())
};
let model = host.window.global::<RoughPlanModel>();
model.set_can_undo(can_undo);
model.set_can_redo(can_redo);
}
pub(super) fn resync(host: &Rc<Host>, rewrite_base: bool) {
rebuild(host, rewrite_base, false);
}
fn rebuild(host: &Rc<Host>, rewrite_base: bool, keep_azimuths: bool) {
let (snapshot, azimuths) = {
let mut session = host.session.borrow_mut();
session.bad_fields.clear();
if !keep_azimuths {
session.face_azimuths.clear();
}
(session.model.clone(), session.face_azimuths.clone())
};
let model = host.window.global::<RoughPlanModel>();
if rewrite_base {
push_base(&host.window, &snapshot.base);
}
model.set_cuts(cut_rows(&snapshot, &azimuths));
if usize::try_from(model.get_selected_cut()).is_ok_and(|row| row >= snapshot.cuts.len()) {
model.set_selected_cut(-1);
}
push_history_flags(host);
super::inclusions::push(host);
host.shape.invalidate();
after_change(host);
}
fn set_base(host: &Rc<Host>, kind: i32) {
let fields = BaseFields::read(&host.window);
let current = parse_base(&fields).unwrap_or_else(|_| host.session.borrow().model.base);
install_base(host, switch_base_kind(¤t, kind));
}
pub(super) fn install_base(host: &Rc<Host>, base: RoughBase) {
{
let mut session = host.session.borrow_mut();
session.model.base = base;
session.commit();
}
resync(host, true);
}
pub(super) fn install_imported_base(host: &Rc<Host>, base: RoughBase, note: Option<&str>) {
install_base(host, base);
if let Some(note) = note {
let model = host.window.global::<RoughPlanModel>();
model.set_status_text(note.into());
model.set_status_level(1);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum BaseCommit {
Invalid(String),
Unchanged,
Changed,
}
fn commit_base_fields(host: &Rc<Host>) -> BaseCommit {
let fields = BaseFields::read(&host.window);
let current = host.session.borrow().model.base;
match pending_base(&fields, ¤t) {
Err(message) => {
input_error(host, &message);
BaseCommit::Invalid(message)
}
Ok(None) => BaseCommit::Unchanged,
Ok(Some(base)) => {
let recorded = {
let mut session = host.session.borrow_mut();
session.model.base = base;
session.commit()
};
if recorded {
push_history_flags(host);
}
BaseCommit::Changed
}
}
}
fn commit_sizes(host: &Rc<Host>) -> Result<(), String> {
let stale_error = !host
.window
.global::<RoughPlanModel>()
.get_model_error()
.is_empty();
match commit_base_fields(host) {
BaseCommit::Invalid(message) => Err(message),
BaseCommit::Changed => {
after_change(host);
Ok(())
}
BaseCommit::Unchanged => {
if stale_error {
host.shape.invalidate();
after_change(host);
}
Ok(())
}
}
}
pub(super) fn commit_pending_sizes(host: &Rc<Host>) -> bool {
commit_sizes(host).is_ok()
}
pub(super) fn commit_pending_base(host: &Rc<Host>) -> Result<(), String> {
commit_sizes(host)?;
let model = host.window.global::<RoughPlanModel>();
if let Err(message) = parse_weighed(&model.get_weighed_ct()) {
model.set_weight_check_text(message.clone().into());
model.set_weight_check_level(3);
return Err(message);
}
let material_known = usize::try_from(model.get_material_index())
.is_ok_and(|index| index < host.session.borrow().choices.len());
if !material_known {
model.set_error_text(PICK_MATERIAL_MESSAGE.into());
return Err(PICK_MATERIAL_MESSAGE.to_string());
}
Ok(())
}
fn base_field_committed(host: &Rc<Host>) {
clear_input_error(host);
if !matches!(commit_base_fields(host), BaseCommit::Invalid(_)) {
readout_inputs_changed(host);
}
}
fn new_cut(base: &RoughBase, kind: i32) -> Result<RoughCut, String> {
let extents = base.bounding_box_extents();
let is_block = matches!(base, RoughBase::Block { .. });
match kind {
0 | 1 if !is_block => Err(BLOCK_ONLY.to_string()),
0 => Ok(default_edge(EDGES[DEFAULT_EDGE], extents)),
1 => Ok(default_corner(CORNERS[DEFAULT_CORNER], extents)),
_ => Ok(default_face(base, [0.0, 1.0, 0.0])),
}
}
fn add_cut(host: &Rc<Host>, kind: i32) {
if !commit_pending_sizes(host) {
return;
}
let cut = {
let session = host.session.borrow();
if is_blank(&session.model) {
Err(NEEDS_SIZE.to_string())
} else {
new_cut(&session.model.base, kind)
}
};
let cut = match cut {
Ok(cut) => cut,
Err(message) => {
host.window
.global::<RoughPlanModel>()
.set_model_error(message.as_str().into());
return;
}
};
add_committed_cut(host, cut);
}
pub(super) fn add_committed_cut(host: &Rc<Host>, cut: RoughCut) {
let index = {
let mut session = host.session.borrow_mut();
session.model.cuts.push(cut);
session.commit();
session.model.cuts.len() - 1
};
let model = host.window.global::<RoughPlanModel>();
model.set_selected_cut(to_i32(index));
model.set_focus_cut_row(to_i32(index));
rebuild(host, false, true);
}
#[must_use]
const fn selection_after_removal(selected: i32, removed: i32) -> i32 {
if selected == removed {
-1
} else if selected > removed {
selected - 1
} else {
selected
}
}
fn remove_cut(host: &Rc<Host>, row: i32) {
let Ok(index) = usize::try_from(row) else {
return;
};
{
let mut session = host.session.borrow_mut();
if index >= session.model.cuts.len() {
return;
}
session.model.cuts.remove(index);
session.commit();
}
let model = host.window.global::<RoughPlanModel>();
model.set_selected_cut(selection_after_removal(model.get_selected_cut(), row));
resync(host, false);
}
fn update_row(host: &Rc<Host>, index: usize, change: impl FnOnce(&mut RoughCutRow)) {
let rows = host.window.global::<RoughPlanModel>().get_cuts();
if let Some(mut row) = rows.row_data(index) {
let before = row.clone();
change(&mut row);
if row != before {
rows.set_row_data(index, row);
}
}
}
fn set_row_error(host: &Rc<Host>, index: usize, message: &str) {
update_row(host, index, |row| row.error = message.into());
}
fn mark_bad_text(host: &Rc<Host>, index: usize, field: i32, message: &str) {
host.session.borrow_mut().bad_fields.insert((index, field));
set_row_error(host, index, message);
}
fn cut_field_edited(host: &Rc<Host>, row: i32, field: i32, text: &str) {
let (Ok(index), Some(cut_field)) = (usize::try_from(row), CutField::from_index(field)) else {
return;
};
let value = match parse_with_unit(text, cut_field.label(), cut_field.unit()) {
Ok(value) => value,
Err(message) => {
mark_bad_text(host, index, field, &message);
return;
}
};
let applied = {
let mut session = host.session.borrow_mut();
let typed_azimuth = session.face_azimuths.get(&index).copied();
let Some(cut) = session.model.cuts.get_mut(index) else {
return;
};
apply_field(cut, cut_field, value, typed_azimuth)
};
match applied {
Ok(azimuth) => {
let others_bad = {
let mut session = host.session.borrow_mut();
session.bad_fields.remove(&(index, field));
if let Some(azimuth) = azimuth {
session.face_azimuths.insert(index, azimuth);
}
session.bad_fields.iter().any(|&(bad, _)| bad == index)
};
if !others_bad {
set_row_error(host, index, "");
}
push_history_flags(host);
after_change(host);
}
Err(message) => mark_bad_text(host, index, field, &message),
}
}
fn model_row(host: &Rc<Host>, index: usize) -> Option<RoughCutRow> {
let session = host.session.borrow();
let typed_azimuth = session.face_azimuths.get(&index).copied();
session
.model
.cuts
.get(index)
.map(|cut| cut_row(cut, typed_azimuth))
}
fn show_model_values(host: &Rc<Host>, index: usize, fields: &[CutField]) {
let Some(fresh) = model_row(host, index) else {
return;
};
update_row(host, index, |row| {
row.title = fresh.title.clone();
row.hint = fresh.hint.clone();
for &field in fields {
set_field_text(row, field, field_text(&fresh, field).as_str());
}
});
}
fn revert_bad_fields(host: &Rc<Host>, index: usize, fields: Vec<CutField>) {
let Some(standin) = model_row(host, index) else {
return;
};
update_row(host, index, |row| {
row.error = "".into();
for &field in &fields {
let text = format!("{} ", field_text(&standin, field));
set_field_text(row, field, &text);
}
});
slint::Timer::single_shot(REVERT_DELAY, move || {
on_host(|host| {
let still_good: Vec<CutField> = {
let session = host.session.borrow();
fields
.iter()
.copied()
.filter(|field| !session.bad_fields.contains(&(index, field.number())))
.collect()
};
show_model_values(host, index, &still_good);
});
});
}
fn cut_field_committed(host: &Rc<Host>, row: i32) {
let Ok(index) = usize::try_from(row) else {
return;
};
let (bad, recorded) = {
let mut session = host.session.borrow_mut();
let bad: Vec<CutField> = session
.bad_fields
.iter()
.filter(|&&(bad_row, _)| bad_row == index)
.filter_map(|&(_, field)| CutField::from_index(field))
.collect();
session.bad_fields.retain(|&(bad_row, _)| bad_row != index);
(bad, session.commit())
};
if recorded {
push_history_flags(host);
}
show_model_values(host, index, &[CutField::Azimuth, CutField::Elevation]);
if !bad.is_empty() {
revert_bad_fields(host, index, bad);
host.shape.invalidate();
after_change(host);
}
}
fn change_faces(cut: &mut RoughCut, index: usize, extents: [f64; 3]) -> bool {
let before = cut.clone();
match cut {
RoughCut::Edge { faces, setbacks_mm } => {
if let Some(&new_faces) = EDGES.get(index) {
*faces = new_faces;
*setbacks_mm = clamp_edge_setbacks(new_faces, *setbacks_mm, extents);
}
}
RoughCut::Corner { faces, setbacks_mm } => {
if let Some(&new_faces) = CORNERS.get(index) {
*faces = new_faces;
*setbacks_mm = clamp_corner_setbacks(new_faces, *setbacks_mm, extents);
}
}
RoughCut::Face { .. } => {}
}
*cut != before
}
fn cut_faces_changed(host: &Rc<Host>, row: i32, index: i32) {
let (Ok(row), Ok(index)) = (usize::try_from(row), usize::try_from(index)) else {
return;
};
let changed = {
let mut session = host.session.borrow_mut();
let extents = session.model.base.bounding_box_extents();
let changed = session
.model
.cuts
.get_mut(row)
.is_some_and(|cut| change_faces(cut, index, extents));
if changed {
session.commit();
}
changed
};
if changed {
rebuild(host, false, true);
}
}
fn undo_shape(host: &Rc<Host>) {
let _ = commit_base_fields(host);
let changed = host.session.borrow_mut().undo_step();
if changed {
resync(host, true);
} else {
push_history_flags(host);
}
}
fn redo_shape(host: &Rc<Host>) {
let _ = commit_base_fields(host);
let changed = host.session.borrow_mut().redo_step();
if changed {
resync(host, true);
} else {
push_history_flags(host);
}
}
fn new_model(host: &Rc<Host>) {
super::mesh_task::cancel(host);
{
let mut session = host.session.borrow_mut();
session.replace_model(blank_model());
session.carat_shown = None;
}
let model = host.window.global::<RoughPlanModel>();
model.set_weighed_ct("".into());
model.set_selected_cut(-1);
model.set_model_error("".into());
resync(host, true);
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix_cut_core::rough_plan::BoxFace;
fn block() -> RoughBase {
RoughBase::Block {
x_mm: 10.0,
y_mm: 8.0,
z_mm: 6.0,
}
}
#[test]
fn each_add_button_makes_its_default_cut() {
assert_eq!(
new_cut(&block(), 0),
Ok(default_edge(
[BoxFace::Top, BoxFace::Front],
[10.0, 8.0, 6.0]
))
);
assert_eq!(
new_cut(&block(), 1),
Ok(default_corner(
[BoxFace::Top, BoxFace::Front, BoxFace::Right],
[10.0, 8.0, 6.0]
))
);
let RoughCut::Face { normal, depth_mm } =
new_cut(&block(), 2).expect("a face cut fits every base")
else {
panic!("kind 2 is a face cut");
};
assert_eq!(normal, [0.0, 1.0, 0.0]);
assert!((depth_mm - 0.8).abs() < 1e-12);
}
#[test]
fn edges_and_corners_are_refused_on_other_bases() {
let pebble = RoughBase::Pebble {
x_mm: 10.0,
y_mm: 8.0,
z_mm: 6.0,
};
assert_eq!(new_cut(&pebble, 0), Err(BLOCK_ONLY.to_string()));
assert_eq!(new_cut(&pebble, 1), Err(BLOCK_ONLY.to_string()));
assert!(new_cut(&pebble, 2).is_ok());
}
#[test]
fn deleting_a_row_moves_the_selection_with_its_row() {
assert_eq!(selection_after_removal(2, 2), -1);
assert_eq!(selection_after_removal(3, 1), 2);
assert_eq!(selection_after_removal(0, 1), 0);
assert_eq!(selection_after_removal(-1, 0), -1);
}
#[test]
fn a_face_change_keeps_the_setbacks_but_clamps_them() {
let mut cut = RoughCut::Edge {
faces: [BoxFace::Top, BoxFace::Front],
setbacks_mm: [5.0, 7.0],
};
let extents = [10.0, 8.0, 6.0];
assert!(change_faces(&mut cut, 8, extents));
assert_eq!(
cut,
RoughCut::Edge {
faces: [BoxFace::Front, BoxFace::Left],
setbacks_mm: [5.0, 6.0],
}
);
assert!(!change_faces(&mut cut, 8, extents));
assert!(!change_faces(&mut cut, 40, extents));
let mut face = RoughCut::Face {
normal: [0.0, 1.0, 0.0],
depth_mm: 1.0,
};
assert!(!change_faces(&mut face, 0, extents));
}
#[test]
fn a_corner_face_change_clamps_each_setback_to_its_own_axis() {
let mut cut = RoughCut::Corner {
faces: [BoxFace::Top, BoxFace::Front, BoxFace::Left],
setbacks_mm: [7.5, 5.5, 9.5],
};
assert!(change_faces(&mut cut, 7, [10.0, 8.0, 6.0]));
assert_eq!(
cut,
RoughCut::Corner {
faces: [BoxFace::Bottom, BoxFace::Back, BoxFace::Right],
setbacks_mm: [7.5, 5.5, 9.5],
}
);
assert!(change_faces(&mut cut, 6, [10.0, 4.0, 3.0]));
let RoughCut::Corner { setbacks_mm, .. } = cut else {
panic!("still a corner");
};
assert_eq!(setbacks_mm, [4.0, 3.0, 9.5]);
}
}