mod drive;
#[cfg(test)]
mod drive_tests;
mod hulls;
mod results;
mod scan;
mod stages;
mod start;
mod state;
#[cfg(test)]
mod tests;
mod tracker;
use super::{
format::{group_thousands, load_titles, to_i32},
host::{Host, on_host},
inputs::{FilterSnapshot, sorted_unique},
saved::{convert::CandidateSource, dto::DesignShape, format::design_shape},
};
use crate::{RoughPlanModel, RoughPlannerWindow, gui::batch::batch_queue::local_lane_count};
use indicatrix_cut_core::rough_plan::{
CandidateDesign, PlanInput, PlanSettings, RoughLayout, RoughModel,
};
use indicatrix_vault::{
db::sqlite::Database,
model::{
solid_extents::{SolidExtentsSource, StoredSolidExtents},
solid_hull::SolidHull,
},
};
use slint::{ComponentHandle, Weak};
use std::{
any::Any,
collections::{BTreeMap, BTreeSet},
panic::{AssertUnwindSafe, catch_unwind},
rc::Rc,
sync::{
Arc, Mutex, PoisonError,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use tracing::warn;
use self::{drive::drive, hulls::prepare_design_hulls, tracker::Tracker};
pub(super) use self::{
hulls::{rotate_into_caliper_frame, to_caliper_frame},
results::show_layouts,
start::{guard_unsaved_results, start_plan},
state::{DesignStatus, ResultsSource, RunState, ShownLayouts},
};
const PROGRESS_INTERVAL: Duration = Duration::from_millis(50);
const CANCELLING_STAGE: &str = "Cancelling...";
const WORKER_STACK_BYTES: usize = 8 * 1024 * 1024;
pub struct Reporter {
ui_weak: Mutex<Weak<RoughPlannerWindow>>,
last_push: Mutex<Option<Instant>>,
cancel: Arc<AtomicBool>,
}
impl Reporter {
pub const fn new(ui_weak: Weak<RoughPlannerWindow>, cancel: Arc<AtomicBool>) -> Self {
Self {
ui_weak: Mutex::new(ui_weak),
last_push: Mutex::new(None),
cancel,
}
}
pub fn cancelled(&self) -> bool {
self.cancel.load(Ordering::Relaxed)
}
pub fn abort(&self) {
self.cancel.store(true, Ordering::Relaxed);
}
fn shown_stage<'a>(&self, stage: &'a str) -> &'a str {
if self.cancelled() {
CANCELLING_STAGE
} else {
stage
}
}
pub fn report(&self, stage: &str, fraction: f32, force: bool) {
{
let mut last = self
.last_push
.lock()
.unwrap_or_else(PoisonError::into_inner);
let now = Instant::now();
if !force && last.is_some_and(|at| now.duration_since(at) < PROGRESS_INTERVAL) {
return;
}
*last = Some(now);
}
let stage = self.shown_stage(stage).to_string();
let fraction = fraction.clamp(0.0, 1.0);
let weak = self.ui_weak.lock().unwrap_or_else(PoisonError::into_inner);
let _ = weak.upgrade_in_event_loop(move |ui| {
let model = ui.global::<RoughPlanModel>();
model.set_stage(stage.into());
model.set_progress_fraction(fraction);
});
}
}
pub enum IdSource {
Filter(Box<FilterSnapshot>),
Library,
}
pub const ALL_EXCLUDED_MESSAGE: &str = "Every candidate design is excluded from planning. \
Restore one under Candidate designs, or widen the filter.";
#[derive(Debug, PartialEq, Eq)]
pub struct Candidates {
pub ids: Vec<i64>,
pub excluded: usize,
}
fn without_excluded(ids: Vec<i64>, excluded: &BTreeSet<i64>) -> (Vec<i64>, usize) {
let before = ids.len();
let kept: Vec<i64> = ids
.into_iter()
.filter(|id| !excluded.contains(id))
.collect();
let removed = before - kept.len();
(kept, removed)
}
const fn no_candidates_message(filtered: bool, excluded: usize) -> &'static str {
if excluded > 0 {
ALL_EXCLUDED_MESSAGE
} else if filtered {
"No designs match the current filters."
} else {
"The library has no designs yet."
}
}
impl IdSource {
pub fn resolve(&self, db: &Mutex<Database>) -> Result<Candidates, String> {
let (ids, filtered) = match self {
Self::Filter(snapshot) => (
snapshot
.query(db)
.map_err(|e| format!("Could not resolve the filtered set: {e}"))?,
true,
),
Self::Library => (
db.lock()
.unwrap_or_else(PoisonError::into_inner)
.all_entry_ids()
.map(sorted_unique)
.map_err(|e| format!("Could not list the library: {e}"))?,
false,
),
};
let excluded_ids = db
.lock()
.unwrap_or_else(PoisonError::into_inner)
.planner_excluded_ids()
.map_err(|e| format!("Could not read the designs excluded from planning: {e}"))?;
let (ids, excluded) = without_excluded(ids, &excluded_ids);
if ids.is_empty() {
Err(no_candidates_message(filtered, excluded).to_string())
} else {
Ok(Candidates { ids, excluded })
}
}
}
pub struct PlanJob {
pub ids: IdSource,
pub model: RoughModel,
pub settings: PlanSettings,
pub material_name: String,
pub weighed_ct: Option<f64>,
pub candidate_source: CandidateSource,
}
pub struct Finished {
pub shown: ShownLayouts,
pub summary: String,
pub skipped: usize,
}
pub enum PlanOutcome {
Done(Box<Finished>),
Cancelled,
Failed(String),
}
fn candidates_from(stored: &BTreeMap<i64, StoredSolidExtents>) -> Vec<CandidateDesign> {
stored
.iter()
.filter_map(|(&entry_id, row)| {
if row.source != SolidExtentsSource::DesignFile {
return None;
}
let extents = row.extents?;
let usable = [
extents.width_caliper,
extents.length_caliper,
extents.height,
extents.volume,
]
.iter()
.all(|figure| figure.is_finite() && *figure > 0.0);
usable.then(|| CandidateDesign {
entry_id,
width: extents.width_caliper.min(extents.length_caliper),
length: extents.width_caliper.max(extents.length_caliper),
height: extents.height,
volume: extents.volume,
})
})
.collect()
}
fn excluded_note(count: usize) -> String {
match count {
0 => String::new(),
1 => "; 1 design excluded from planning".to_string(),
n => format!("; {} designs excluded from planning", group_thousands(n)),
}
}
fn unmeasured_note(count: usize) -> String {
match count {
0 => String::new(),
1 => "; 1 design could not be measured".to_string(),
n => format!("; {} designs could not be measured", group_thousands(n)),
}
}
fn cache_note(save_failures: usize) -> String {
match save_failures {
0 => String::new(),
1 => "; cache could not be saved (1 design)".to_string(),
n => format!(
"; cache could not be saved ({} designs)",
group_thousands(n)
),
}
}
fn outline_note(count: usize) -> String {
match count {
0 => String::new(),
1 => "; 1 design without an outline was only planned as a box".to_string(),
n => format!(
"; {} designs without an outline were only planned as boxes",
group_thousands(n)
),
}
}
fn validate_model(model: &RoughModel) -> Result<(), String> {
model.measure().map(|_| ()).map_err(|e| e.to_string())
}
fn summary_text(layouts: usize, designs: usize, elapsed: Duration, note: &str) -> String {
if layouts == 0 {
return format!(
"No layout fits: the rough is too small for the minimum stone width, or no \
measured design is small enough{note}."
);
}
format!(
"{layouts} layout{} from {} designs -- {:.1} s{note}",
if layouts == 1 { "" } else { "s" },
group_thousands(designs),
elapsed.as_secs_f64()
)
}
enum GatherStop {
Cancelled,
Failed(String),
}
struct Gathered {
stored: BTreeMap<i64, StoredSolidExtents>,
hulls: BTreeMap<i64, SolidHull>,
scanned: bool,
save_failures: usize,
}
fn gather(db: &Mutex<Database>, reporter: &Reporter, ids: &[i64]) -> Result<Gathered, GatherStop> {
let (mut stored, mut hulls) =
scan::load_extents_and_hulls(db, ids).map_err(GatherStop::Failed)?;
let (missing, outlines_only) = scan::ids_needing_scan(ids, &stored, &hulls);
let outcome = scan::scan_missing(db, reporter, &missing, outlines_only);
if outcome.cancelled {
return Err(GatherStop::Cancelled);
}
if !missing.is_empty() {
let (fresh_extents, fresh_hulls) =
scan::load_extents_and_hulls(db, &missing).map_err(GatherStop::Failed)?;
stored.extend(fresh_extents);
hulls.extend(fresh_hulls);
}
scan::merge_measured(&mut stored, &mut hulls, outcome.measured);
Ok(Gathered {
stored,
hulls,
scanned: !missing.is_empty(),
save_failures: outcome.save_failures,
})
}
pub fn run_plan(db: &Mutex<Database>, job: &PlanJob, reporter: &Reporter) -> PlanOutcome {
let started = Instant::now();
if let Err(message) = validate_model(&job.model) {
return PlanOutcome::Failed(message);
}
let Candidates { ids, excluded } = match job.ids.resolve(db) {
Ok(candidates) => candidates,
Err(message) => return PlanOutcome::Failed(message),
};
let gathered = match gather(db, reporter, &ids) {
Ok(gathered) => gathered,
Err(GatherStop::Cancelled) => return PlanOutcome::Cancelled,
Err(GatherStop::Failed(message)) => return PlanOutcome::Failed(message),
};
let designs = candidates_from(&gathered.stored);
let prepared = prepare_design_hulls(&gathered.stored, &gathered.hulls);
if prepared.stale > 0 || prepared.skipped > 0 || prepared.duplicates > 0 {
warn!(
"Rough planner: {} cached outlines no longer match their extents and {} are \
unusable (those designs are planned as boxes only); {} repeat an earlier \
design's outline exactly (fitted once)",
prepared.stale, prepared.skipped, prepared.duplicates
);
}
let skipped = gathered.stored.len().saturating_sub(designs.len());
let boxes_only = designs
.len()
.saturating_sub(prepared.hulls.len() + prepared.duplicates);
let note = format!(
"{}{}{}{}",
excluded_note(excluded),
unmeasured_note(ids.len().saturating_sub(gathered.stored.len())),
cache_note(gathered.save_failures),
outline_note(boxes_only)
);
if designs.is_empty() {
let summary = format!(
"None of the {} selected designs has a usable design file{note}.",
group_thousands(ids.len())
);
return finished(
job,
Vec::new(),
BTreeMap::new(),
BTreeMap::new(),
summary,
skipped,
);
}
let input = PlanInput {
model: &job.model,
settings: &job.settings,
designs: &designs,
hulls: &prepared.hulls,
};
let base = if gathered.scanned {
scan::SCAN_BAND
} else {
0.0
};
let tracker = Tracker::new(reporter, base, input.path());
let Some(layouts) = drive(&input, local_lane_count(), &tracker) else {
return PlanOutcome::Cancelled;
};
let titles = load_titles(db, &layouts);
let shapes = planned_shapes(&gathered.stored, &layouts);
let summary = summary_text(layouts.len(), designs.len(), started.elapsed(), ¬e);
finished(job, layouts, titles, shapes, summary, skipped)
}
fn planned_shapes(
stored: &BTreeMap<i64, StoredSolidExtents>,
layouts: &[RoughLayout],
) -> BTreeMap<i64, DesignShape> {
layouts
.iter()
.flat_map(|layout| layout.stones.iter().map(|stone| stone.entry_id))
.filter_map(|id| {
let extents = stored.get(&id)?.extents.as_ref()?;
Some((id, design_shape(extents)))
})
.collect()
}
fn finished(
job: &PlanJob,
layouts: Vec<RoughLayout>,
titles: BTreeMap<i64, String>,
shapes: BTreeMap<i64, DesignShape>,
summary: String,
skipped: usize,
) -> PlanOutcome {
PlanOutcome::Done(Box::new(Finished {
shown: ShownLayouts {
layouts,
plan_model: Some(job.model.clone()),
plan_settings: Some(job.settings),
material_name: job.material_name.clone(),
weighed_ct: job.weighed_ct,
candidate_source: job.candidate_source,
titles,
statuses: BTreeMap::new(),
shapes,
source: ResultsSource::Planned,
},
summary,
skipped,
}))
}
fn panic_message(payload: &(dyn Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_string())
}
fn end_run(host: &Rc<Host>) {
let model = host.window.global::<RoughPlanModel>();
model.set_running(false);
model.set_stage("".into());
model.set_progress_fraction(1.0);
}
fn apply_outcome(host: &Rc<Host>, outcome: PlanOutcome) {
let model = host.window.global::<RoughPlanModel>();
match outcome {
PlanOutcome::Done(finished) => {
let Finished {
shown,
summary,
skipped,
} = *finished;
model.set_skipped_count(to_i32(skipped));
model.set_stage("Preparing the results...".into());
host.session.borrow_mut().run.summary = Some(summary);
results::show_layouts(host, shown, Some(end_run));
}
PlanOutcome::Cancelled => {
model.set_running(false);
model.set_stage("".into());
model.set_progress_fraction(0.0);
start::restore_previous(host);
model.set_summary("Planning cancelled.".into());
}
PlanOutcome::Failed(message) => {
model.set_running(false);
model.set_stage("".into());
model.set_progress_fraction(0.0);
start::restore_previous(host);
model.set_error_text(message.into());
}
}
}
pub fn spawn_worker(
ui_weak: Weak<RoughPlannerWindow>,
db: Arc<Mutex<Database>>,
job: PlanJob,
cancel: Arc<AtomicBool>,
) -> std::io::Result<()> {
std::thread::Builder::new()
.name("rough-plan".to_string())
.stack_size(WORKER_STACK_BYTES)
.spawn(move || {
let run_flag = Arc::clone(&cancel);
let reporter = Reporter::new(ui_weak.clone(), cancel);
let outcome = catch_unwind(AssertUnwindSafe(|| run_plan(&db, &job, &reporter)))
.unwrap_or_else(|payload| {
let message = panic_message(&*payload);
warn!("Rough planner panicked: {message}");
PlanOutcome::Failed(format!("Internal error: {message}"))
});
let _ = ui_weak.upgrade_in_event_loop(move |_ui| {
on_host(|host| {
let is_this_run = host
.session
.borrow()
.cancel
.as_ref()
.is_some_and(|flag| Arc::ptr_eq(flag, &run_flag));
if is_this_run {
apply_outcome(host, outcome);
}
});
});
})
.map(|_handle| ())
}
pub(super) fn setup_run_callbacks(host: &Rc<Host>) {
let model = host.window.global::<RoughPlanModel>();
model.on_plan(|| on_host(start_plan));
model.on_replan_loaded(|| on_host(start_plan));
model.on_cancel(|| on_host(start::cancel_plan));
model.on_toggle_keep(|index| on_host(|host| start::toggle_keep(host, index)));
model.on_confirm_replace(|replace| on_host(|host| start::answer_replace(host, replace)));
}