mod form;
mod present;
mod run;
mod state;
use self::{
form::{Checked, Fields},
run::{Job, Poll, RunHandle},
state::Dialog,
};
use super::{relation_ui::refresh_with_followers, state::EditorState};
use crate::{
EditorModel, MainWindow, SweepModel, ViewportModel,
bridge::render_thread::RenderContext,
gui::{
library::clipboard::copy_to_clipboard_with_toast,
pickers::{PickerFilter, PickerKind, PickerRequest, pick},
show_toast,
solid_preview::preview_state::{SolidLastSolved, SolidPreviewState},
},
};
use indicatrix::optics::LightingPreset;
use indicatrix_editor::{
optimize_view::default_optimize_material_ri,
sweep::{
SweepMetric, SweepOptions, SweepOutcome, SweepPlan, apply_sweep_angle,
default_worker_count, sweep_csv,
},
};
use slint::{ComponentHandle, Timer, TimerMode, Weak};
use std::{
cell::RefCell,
rc::Rc,
sync::{Arc, Mutex, PoisonError},
time::Duration,
};
const POLL_INTERVAL: Duration = Duration::from_millis(100);
const NOTHING_TO_SWEEP: &str = "No tier of this design has an angle a sweep can vary. A table, \
a culet, a girdle and a tier whose angle follows a relation cannot be swept.";
const DESIGN_MOVED_ON: &str = "The design has changed since this sweep ran, so its rows no \
longer describe it. Run the sweep again.";
struct Controller {
ui: Weak<MainWindow>,
editor: Rc<RefCell<EditorState>>,
render_ctx: Arc<Mutex<RenderContext>>,
preview_state: Arc<SolidPreviewState>,
solid_last_solved: SolidLastSolved,
dialog: RefCell<Dialog>,
timer: Timer,
workers: usize,
}
pub(super) fn setup_sweep_callbacks(
ui: &MainWindow,
state: &Rc<RefCell<EditorState>>,
render_ctx: &Arc<Mutex<RenderContext>>,
preview_state: &Arc<SolidPreviewState>,
solid_last_solved: &SolidLastSolved,
) {
let controller = Rc::new(Controller {
ui: ui.as_weak(),
editor: Rc::clone(state),
render_ctx: Arc::clone(render_ctx),
preview_state: Arc::clone(preview_state),
solid_last_solved: Arc::clone(solid_last_solved),
dialog: RefCell::new(Dialog::new()),
timer: Timer::default(),
workers: default_worker_count(),
});
let model = ui.global::<SweepModel>();
let c = Rc::clone(&controller);
model.on_open_dialog(move || c.open());
let c = Rc::clone(&controller);
model.on_close_dialog(move || c.close());
let c = Rc::clone(&controller);
model.on_form_changed(move || c.form_changed());
let c = Rc::clone(&controller);
model.on_tier_changed(move || c.tier_changed());
let c = Rc::clone(&controller);
model.on_run(move || c.run());
let c = Rc::clone(&controller);
model.on_cancel(move || c.cancel());
let c = Rc::clone(&controller);
model.on_select_row(move |row| c.select_row(row));
let c = Rc::clone(&controller);
model.on_toggle_series(move |series| c.toggle_series(series));
let c = Rc::clone(&controller);
model.on_chart_hover(move |fraction| c.chart_hover(fraction));
let c = Rc::clone(&controller);
model.on_use_selected(move || c.use_selected());
let c = Rc::clone(&controller);
model.on_copy_csv(move || c.copy_csv());
model.on_save_csv(move || controller.save_csv());
}
fn push_check(ui: &MainWindow, checked: &Checked) {
let model = ui.global::<SweepModel>();
match checked {
Checked::Ready { summary, .. } => {
model.set_summary_text(summary.as_str().into());
model.set_form_ok(true);
}
Checked::Refused(message) => {
model.set_summary_text(message.as_str().into());
model.set_form_ok(false);
}
}
}
fn push_fields(ui: &MainWindow, fields: &Fields) {
let model = ui.global::<SweepModel>();
model.set_from_text(fields.from.as_str().into());
model.set_to_text(fields.to.as_str().into());
model.set_step_text(fields.step.as_str().into());
}
impl Controller {
fn open(&self) {
let Some(ui) = self.ui.upgrade() else {
return;
};
let model = ui.global::<SweepModel>();
if model.get_dialog_open() {
return;
}
let selected = usize::try_from(ui.global::<EditorModel>().get_selected_tier_index()).ok();
let tilt_average = model.get_tilt_average();
let opening = {
let st = self.editor.borrow();
form::open(&st.design, selected, tilt_average, self.workers)
};
let Some(opening) = opening else {
show_toast(&ui, NOTHING_TO_SWEEP, "info");
return;
};
{
let mut dialog = self.dialog.borrow_mut();
dialog.tiers = opening.choices.iter().map(|choice| choice.tier).collect();
dialog.run = None;
dialog.clear_results();
}
let labels = opening.choices.into_iter().map(|choice| choice.label);
model.set_tier_names(present::tier_names(labels.collect()));
model.set_tier_index(i32::try_from(opening.chosen).unwrap_or(0));
push_fields(&ui, &opening.fields);
model.set_notice_text(opening.notice.into());
push_check(&ui, &opening.checked);
model.set_running(false);
model.set_progress(0.0);
model.set_status_text("".into());
present::push_results(&ui, &self.dialog.borrow());
model.set_dialog_open(true);
}
fn close(&self) {
let Some(ui) = self.ui.upgrade() else {
return;
};
self.timer.stop();
{
let mut dialog = self.dialog.borrow_mut();
dialog.run = None;
dialog.clear_results();
}
present::push_results(&ui, &self.dialog.borrow());
let model = ui.global::<SweepModel>();
model.set_running(false);
model.set_dialog_open(false);
}
fn read_form(&self, ui: &MainWindow) -> Option<(usize, Fields, bool)> {
let model = ui.global::<SweepModel>();
let index = usize::try_from(model.get_tier_index()).ok()?;
let tier = *self.dialog.borrow().tiers.get(index)?;
let fields = Fields {
from: model.get_from_text().to_string(),
to: model.get_to_text().to_string(),
step: model.get_step_text().to_string(),
};
Some((tier, fields, model.get_tilt_average()))
}
fn form_changed(&self) {
let Some(ui) = self.ui.upgrade() else {
return;
};
let Some((tier, fields, tilt_average)) = self.read_form(&ui) else {
return;
};
let checked = {
let st = self.editor.borrow();
form::check(&st.design, tier, &fields, tilt_average, self.workers)
};
push_check(&ui, &checked);
}
fn tier_changed(&self) {
let Some(ui) = self.ui.upgrade() else {
return;
};
let Some((tier, _, tilt_average)) = self.read_form(&ui) else {
return;
};
let range = {
let st = self.editor.borrow();
form::retier(&st.design, tier, tilt_average, self.workers)
};
if let Some((fields, checked)) = range {
push_fields(&ui, &fields);
push_check(&ui, &checked);
}
}
fn job(
&self,
st: &EditorState,
plan: SweepPlan,
tilt_average: bool,
lighting: LightingPreset,
) -> Job {
let custom_materials = self
.render_ctx
.lock()
.unwrap_or_else(PoisonError::into_inner)
.custom_materials
.as_ref()
.clone();
let mut material = st.design.material.clone();
let _ = default_optimize_material_ri(&st.design, &mut material, &custom_materials);
Job {
design: st.design.clone(),
plan,
material,
custom_materials,
lighting,
options: SweepOptions {
tilt_average,
workers: self.workers,
},
}
}
fn run(self: &Rc<Self>) {
let Some(ui) = self.ui.upgrade() else {
return;
};
if self.dialog.borrow().run.is_some() {
return;
}
let Some((tier, fields, tilt_average)) = self.read_form(&ui) else {
return;
};
let lighting =
LightingPreset::from_index(ui.global::<ViewportModel>().get_selected_lighting_index());
let prepared = {
let st = self.editor.borrow();
match form::check(&st.design, tier, &fields, tilt_average, self.workers) {
Checked::Ready { plan, .. } => Ok((
self.job(&st, plan, tilt_average, lighting),
st.current_generation(),
)),
Checked::Refused(message) => Err(message),
}
};
match prepared {
Ok((job, generation)) => self.begin(&ui, job, generation),
Err(message) => show_toast(&ui, &message, "error"),
}
}
fn begin(self: &Rc<Self>, ui: &MainWindow, job: Job, generation: u64) {
let total = job.plan.angles.len();
let handle = match run::start(job) {
Ok(handle) => handle,
Err(message) => {
show_toast(ui, &message, "error");
return;
}
};
{
let mut dialog = self.dialog.borrow_mut();
dialog.clear_results();
dialog.generation = generation;
dialog.run = Some(handle);
}
let model = ui.global::<SweepModel>();
model.set_running(true);
model.set_progress(0.0);
model.set_status_text(present::running_status(0, total).into());
present::push_results(ui, &self.dialog.borrow());
let weak = Rc::downgrade(self);
self.timer
.start(TimerMode::Repeated, POLL_INTERVAL, move || {
if let Some(controller) = weak.upgrade() {
controller.poll();
}
});
}
fn poll(&self) {
let Some(ui) = self.ui.upgrade() else {
self.timer.stop();
return;
};
let polled = {
let mut dialog = self.dialog.borrow_mut();
dialog.run.as_mut().map(|run| (run.poll(), run.total()))
};
let model = ui.global::<SweepModel>();
match polled {
None => self.timer.stop(),
Some((Poll::Running { done }, total)) => {
model.set_progress(done as f32 / total.max(1) as f32);
model.set_status_text(present::running_status(done, total).into());
}
Some((Poll::Finished(outcome), _)) => self.finish(&ui, outcome),
Some((Poll::Died, _)) => {
self.timer.stop();
self.dialog.borrow_mut().run = None;
model.set_running(false);
model.set_status_text("The sweep stopped unexpectedly.".into());
show_toast(&ui, "The sweep stopped unexpectedly.", "error");
}
}
}
fn finish(&self, ui: &MainWindow, outcome: SweepOutcome) {
self.timer.stop();
let status = present::finished_status(&outcome);
{
let mut dialog = self.dialog.borrow_mut();
dialog.run = None;
dialog.clear_results();
dialog.outcome = (!outcome.rows.is_empty()).then_some(outcome);
}
let model = ui.global::<SweepModel>();
model.set_running(false);
model.set_progress(1.0);
model.set_status_text(status.into());
present::push_results(ui, &self.dialog.borrow());
}
fn cancel(&self) {
let Some(ui) = self.ui.upgrade() else {
return;
};
let stopping = self
.dialog
.borrow()
.run
.as_ref()
.map(RunHandle::cancel)
.is_some();
if stopping {
ui.global::<SweepModel>()
.set_status_text("Stopping...".into());
}
}
fn select_row(&self, row: i32) {
let Some(ui) = self.ui.upgrade() else {
return;
};
let Ok(index) = usize::try_from(row) else {
return;
};
if self.dialog.borrow_mut().select(index) {
present::push_pointer(&ui, &self.dialog.borrow());
}
}
fn toggle_series(&self, series: i32) {
let Some(ui) = self.ui.upgrade() else {
return;
};
let Some(metric) = usize::try_from(series)
.ok()
.and_then(SweepMetric::from_index)
else {
return;
};
self.dialog.borrow_mut().toggle(metric);
present::push_lines(&ui, &self.dialog.borrow());
}
fn chart_hover(&self, fraction: f32) {
let Some(ui) = self.ui.upgrade() else {
return;
};
let fraction = (fraction >= 0.0).then(|| f64::from(fraction));
if self.dialog.borrow_mut().hover_at(fraction) {
present::push_pointer(&ui, &self.dialog.borrow());
}
}
fn use_selected(&self) {
let Some(ui) = self.ui.upgrade() else {
return;
};
let (pick, generation) = {
let dialog = self.dialog.borrow();
(dialog.pick(), dialog.generation)
};
let Some(pick) = pick else {
return;
};
let mut st = self.editor.borrow_mut();
if st.current_generation() != generation {
drop(st);
show_toast(&ui, DESIGN_MOVED_ON, "error");
return;
}
let name = indicatrix_editor::retarget::plan::tier_display_name(&st.design, pick.tier);
match apply_sweep_angle(&mut st, pick.tier, pick.angle_deg) {
Ok(Some(_)) => {
refresh_with_followers(
&ui,
&self.render_ctx,
&self.preview_state,
&self.solid_last_solved,
&st,
[pick.tier],
);
let after = st.current_generation();
drop(st);
self.dialog.borrow_mut().mark_applied(pick.row, after);
present::push_results(&ui, &self.dialog.borrow());
show_toast(
&ui,
&format!(
"{name} is now {:.2}\u{b0}. Undo (Ctrl+Z) puts it back.",
pick.angle_deg.abs()
),
"success",
);
}
Ok(None) => {
drop(st);
show_toast(&ui, "No change.", "info");
}
Err(error) => {
drop(st);
show_toast(&ui, &error.to_string(), "error");
}
}
}
fn csv_text(&self) -> Option<String> {
self.dialog.borrow().outcome.as_ref().map(sweep_csv)
}
fn copy_csv(&self) {
let Some(ui) = self.ui.upgrade() else {
return;
};
if let Some(csv) = self.csv_text() {
copy_to_clipboard_with_toast(&ui, csv, "Copied the sweep as CSV.".to_owned());
}
}
fn save_csv(&self) {
let Some(ui) = self.ui.upgrade() else {
return;
};
let Some(csv) = self.csv_text() else {
return;
};
let tier_name = self
.dialog
.borrow()
.outcome
.as_ref()
.map(|outcome| outcome.tier_name.clone())
.unwrap_or_default();
let design_title = self
.editor
.borrow()
.design
.meta
.headers
.first()
.cloned()
.unwrap_or_default();
let request = PickerRequest {
kind: PickerKind::SaveFile,
title: Some("Save the angle sweep as CSV".to_owned()),
filters: vec![PickerFilter::single("csv")],
default_file_name: Some(present::csv_file_name(&design_title, &tier_name)),
starting_dir: None,
};
pick(&ui, request, move |ui, picked| {
let Some(path) = picked else {
return;
};
let ui_weak = ui.as_weak();
std::thread::spawn(move || {
let result = std::fs::write(&path, csv.as_bytes())
.map_err(|error| format!("Failed to write {}: {error}", path.display()));
let _ = ui_weak.upgrade_in_event_loop(move |ui| match result {
Ok(()) => show_toast(
&ui,
&format!("Saved the sweep to {}.", path.display()),
"success",
),
Err(message) => show_toast(&ui, &message, "error"),
});
});
});
}
}