use super::{
state::{
EditorState, apply_multi_selection, design_to_gpu_planes, manufacturability_warning_lines,
manufacturability_warning_lines_from_solved, push_multi_selected_count, push_tiers,
status_text_and_is_problem, status_text_and_is_problem_from_solved, tier_items,
tier_items_from_solved, yield_report_texts, yield_report_texts_from_solved,
},
view::{SolidLastSolved, scaled_viewport_size},
};
use crate::{
EditorModel, EditorTierItem, MainWindow, SolidPreviewModel, TiltModel,
bridge::render_thread::{PlanesOwner, RenderContext},
gui::{
render::camera_lighting::contained_request_size,
solid_preview::preview_state::{CameraPose, SolidPreviewState},
},
};
use indicatrix::geometry::{GpuFacetPlane, meet_solver::SolvedTier};
use indicatrix_cut_core::Design;
use slint::{ComponentHandle, ModelRc, SharedString, VecModel};
use std::{
cell::RefCell,
collections::BTreeSet,
sync::{
Arc, Mutex, PoisonError,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread,
time::{Duration, Instant},
};
const SYNC_SOLVE_PLANE_LIMIT: usize = 32;
const SYNC_SOLVE_TIME_LIMIT: Duration = Duration::from_millis(500);
#[must_use]
pub(super) fn should_solve_synchronously(plane_count: usize, last_solve: Option<Duration>) -> bool {
last_solve.map_or(plane_count <= SYNC_SOLVE_PLANE_LIMIT, |last| {
last <= SYNC_SOLVE_TIME_LIMIT
})
}
const AUTO_SOLVE_DEBOUNCE: Duration = Duration::from_millis(150);
const TICK_INTERVAL: Duration = Duration::from_millis(250);
struct Runtime {
preview_state: Option<Arc<SolidPreviewState>>,
solid_last_solved: Option<SolidLastSolved>,
render_ctx: Option<Arc<Mutex<RenderContext>>>,
last_solve: Option<Duration>,
current_seq: u64,
debounce: Option<slint::Timer>,
current_design: Option<(u64, Design, BTreeSet<usize>)>,
solve_in_flight: bool,
pending_dispatch: Option<PendingDispatch>,
}
impl Runtime {
const fn new() -> Self {
Self {
preview_state: None,
solid_last_solved: None,
render_ctx: None,
last_solve: None,
current_seq: 0,
debounce: None,
current_design: None,
solve_in_flight: false,
pending_dispatch: None,
}
}
}
struct PendingDispatch {
design: Design,
generation: Arc<AtomicU64>,
multi_selected: BTreeSet<usize>,
}
thread_local! {
static RUNTIME: RefCell<Runtime> = const { RefCell::new(Runtime::new()) };
}
pub(super) fn init(preview_state: &Arc<SolidPreviewState>, solid_last_solved: &SolidLastSolved) {
RUNTIME.with(|cell| {
let mut rt = cell.borrow_mut();
rt.preview_state = Some(Arc::clone(preview_state));
rt.solid_last_solved = Some(Arc::clone(solid_last_solved));
});
}
pub(super) fn preview_state() -> Option<Arc<SolidPreviewState>> {
RUNTIME.with(|cell| cell.borrow().preview_state.clone())
}
pub(super) fn solid_last_solved() -> Option<SolidLastSolved> {
RUNTIME.with(|cell| cell.borrow().solid_last_solved.clone())
}
pub(super) fn stash_render_ctx(render_ctx: &Arc<Mutex<RenderContext>>) {
RUNTIME.with(|cell| {
cell.borrow_mut().render_ctx = Some(Arc::clone(render_ctx));
});
}
pub(super) fn render_ctx() -> Option<Arc<Mutex<RenderContext>>> {
RUNTIME.with(|cell| cell.borrow().render_ctx.clone())
}
pub(super) const fn should_schedule_auto_solve(
last_solve: Option<Duration>,
budget: Duration,
) -> bool {
if budget.is_zero() {
return false;
}
match last_solve {
Some(last) => last.as_millis() < budget.as_millis(),
None => true,
}
}
pub(super) fn auto_solve_off_note(last_solve: Duration) -> String {
format!(
"Auto-solve off for this design: last solve took {:.1}s.",
last_solve.as_secs_f32()
)
}
pub(super) fn design_to_gpu_planes_from_solved(
design: &Design,
solved: &[SolvedTier],
) -> Vec<GpuFacetPlane> {
design
.planes_from_solved(solved)
.into_iter()
.map(|(normal, offset)| GpuFacetPlane::new(normal.as_vec3(), -offset as f32))
.collect()
}
fn solving_banner(tier_count: usize, elapsed: Duration) -> String {
format!(
"Solving... ({tier_count} tier{}) -- {:.1}s elapsed",
if tier_count == 1 { "" } else { "s" },
elapsed.as_secs_f32()
)
}
pub(super) fn record_solve_duration(elapsed: Duration) {
RUNTIME.with(|cell| cell.borrow_mut().last_solve = Some(elapsed));
}
#[cfg(test)]
#[must_use]
fn last_measured_solve_duration() -> Option<Duration> {
RUNTIME.with(|cell| cell.borrow().last_solve)
}
pub(super) fn reset_for_new_design() {
RUNTIME.with(|cell| {
let mut rt = cell.borrow_mut();
rt.last_solve = None;
rt.current_seq += 1;
rt.debounce = None;
rt.current_design = None;
rt.pending_dispatch = None;
});
}
pub(super) fn cancel_in_flight_solve() {
RUNTIME.with(|cell| {
let mut rt = cell.borrow_mut();
rt.current_seq += 1;
rt.debounce = None;
rt.pending_dispatch = None;
});
}
fn is_current(seq: u64) -> bool {
RUNTIME.with(|cell| cell.borrow().current_seq == seq)
}
pub(super) fn stash_current_design(
generation: u64,
design: Design,
multi_selected: BTreeSet<usize>,
) {
RUNTIME.with(|cell| {
cell.borrow_mut().current_design = Some((generation, design, multi_selected));
});
}
pub(super) fn take_matching_design(generation: u64) -> Option<(Design, BTreeSet<usize>)> {
RUNTIME.with(|cell| {
let mut rt = cell.borrow_mut();
let is_match = rt
.current_design
.as_ref()
.is_some_and(|(stashed_generation, ..)| *stashed_generation == generation);
if !is_match {
return None;
}
let (_, design, multi_selected) = rt.current_design.take()?;
rt.debounce = None;
Some((design, multi_selected))
})
}
pub(super) fn on_edit(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
state: &EditorState,
) {
let budget_ms =
u64::try_from(ui.global::<EditorModel>().get_auto_solve_budget_ms()).unwrap_or(0);
let budget = Duration::from_millis(budget_ms);
let last_solve = RUNTIME.with(|cell| cell.borrow().last_solve);
if !should_schedule_auto_solve(last_solve, budget) {
if !budget.is_zero()
&& let Some(last) = last_solve
{
ui.global::<EditorModel>()
.set_status_text(auto_solve_off_note(last).into());
ui.global::<EditorModel>().set_status_is_problem(true);
}
return;
}
let ui_weak = ui.as_weak();
let render_ctx = Arc::clone(render_ctx);
let design = state.design.clone();
let generation = Arc::clone(&state.generation);
let multi_selected = state.multi_selected.clone();
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::SingleShot,
AUTO_SOLVE_DEBOUNCE,
move || {
if let Some(ui) = ui_weak.upgrade() {
dispatch_background_solve(
&ui,
&render_ctx,
design.clone(),
&generation,
multi_selected.clone(),
);
}
},
);
RUNTIME.with(|cell| cell.borrow_mut().debounce = Some(timer));
}
struct BackgroundSolveResult {
elapsed: Duration,
tiers: Vec<EditorTierItem>,
status_text: String,
status_is_problem: bool,
warnings: Vec<String>,
yield_texts: (String, String, String, String),
planes: Vec<GpuFacetPlane>,
solved: Option<Vec<SolvedTier>>,
gear: (u32, f32),
multi_selected_count: usize,
}
struct PanelInputs {
tiers: Vec<EditorTierItem>,
status_text: String,
status_is_problem: bool,
warnings: Vec<String>,
yield_texts: (String, String, String, String),
planes: Vec<GpuFacetPlane>,
}
fn panel_inputs(design: &Design, solved: Option<&Vec<SolvedTier>>) -> PanelInputs {
let n_d = design.effective_refractive_index();
solved.map_or_else(
|| {
let (status_text, status_is_problem) = status_text_and_is_problem(design);
PanelInputs {
tiers: tier_items(design, n_d),
status_text,
status_is_problem,
warnings: manufacturability_warning_lines(design),
yield_texts: yield_report_texts(design),
planes: design_to_gpu_planes(design),
}
},
|solved| {
let (status_text, status_is_problem) =
status_text_and_is_problem_from_solved(design, solved);
PanelInputs {
tiers: tier_items_from_solved(design, solved, n_d),
status_text,
status_is_problem,
warnings: manufacturability_warning_lines_from_solved(design, solved),
yield_texts: yield_report_texts_from_solved(design, solved),
planes: design_to_gpu_planes_from_solved(design, solved),
}
},
)
}
pub(super) fn dispatch_background_solve(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
design: Design,
generation: &Arc<AtomicU64>,
multi_selected: BTreeSet<usize>,
) {
let queued = RUNTIME.with(|cell| {
let mut rt = cell.borrow_mut();
if rt.solve_in_flight {
rt.pending_dispatch = Some(PendingDispatch {
design: design.clone(),
generation: Arc::clone(generation),
multi_selected: multi_selected.clone(),
});
true
} else {
rt.solve_in_flight = true;
false
}
});
if queued {
return;
}
let tier_count = design.tiers.len();
let started_generation = generation.load(Ordering::Relaxed);
let seq = RUNTIME.with(|cell| {
let mut rt = cell.borrow_mut();
rt.current_seq += 1;
rt.current_seq
});
ui.global::<EditorModel>().set_solve_running(true);
ui.global::<EditorModel>().set_solve_state("solving".into());
ui.global::<EditorModel>()
.set_status_text(solving_banner(tier_count, Duration::ZERO).into());
ui.global::<EditorModel>().set_status_is_problem(false);
let done_flag = Arc::new(AtomicBool::new(false));
let done_ticker = Arc::clone(&done_flag);
let ticker_ui = ui.as_weak();
thread::spawn(move || {
let start = Instant::now();
loop {
thread::sleep(TICK_INTERVAL);
if done_ticker.load(Ordering::Relaxed) {
break;
}
let elapsed = start.elapsed();
let _ = ticker_ui.upgrade_in_event_loop(move |ui| {
if is_current(seq) {
ui.global::<EditorModel>()
.set_status_text(solving_banner(tier_count, elapsed).into());
}
});
}
});
let ui_weak = ui.as_weak();
let render_ctx_for_apply = Arc::clone(render_ctx);
let generation = Arc::clone(generation);
thread::spawn(move || {
let start = Instant::now();
let solved_result = design.solve();
let multi_selected_count = multi_selected.len();
let PanelInputs {
mut tiers,
status_text,
status_is_problem,
warnings,
yield_texts,
planes,
} = panel_inputs(&design, solved_result.as_ref().ok());
apply_multi_selection(&mut tiers, &multi_selected);
let solved = solved_result.ok();
let gear = (
design.meta.gear_teeth_abs(),
design.meta.gear_reference_angle as f32,
);
let result = BackgroundSolveResult {
elapsed: start.elapsed(),
tiers,
status_text,
status_is_problem,
warnings,
yield_texts,
planes,
solved,
gear,
multi_selected_count,
};
done_flag.store(true, Ordering::Relaxed);
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
apply_background_solve_result(
&ui,
&render_ctx_for_apply,
seq,
started_generation,
&generation,
result,
);
});
});
}
fn apply_background_solve_result(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
seq: u64,
started_generation: u64,
generation: &Arc<AtomicU64>,
result: BackgroundSolveResult,
) {
let pending = RUNTIME.with(|cell| {
let mut rt = cell.borrow_mut();
rt.solve_in_flight = false;
rt.pending_dispatch.take()
});
if !is_current(seq) {
dispatch_pending(ui, render_ctx, pending);
return;
}
record_solve_duration(result.elapsed);
ui.global::<EditorModel>().set_solve_running(false);
if generation.load(Ordering::Relaxed) != started_generation {
dispatch_pending(ui, render_ctx, pending);
return;
}
push_tiers(ui, result.tiers);
push_multi_selected_count(ui, result.multi_selected_count);
ui.global::<EditorModel>()
.set_status_text(result.status_text.into());
ui.global::<EditorModel>()
.set_status_is_problem(result.status_is_problem);
ui.global::<EditorModel>().set_solve_state(
if result.status_is_problem {
"failed"
} else {
"solved"
}
.into(),
);
ui.global::<EditorModel>()
.set_last_solve_duration_ms(i32::try_from(result.elapsed.as_millis()).unwrap_or(i32::MAX));
ui.global::<EditorModel>()
.set_manufacturability_warnings(ModelRc::new(VecModel::from(
result
.warnings
.into_iter()
.map(SharedString::from)
.collect::<Vec<_>>(),
)));
let (vol_yield_text, carat_text, sg_used_text, fit_text) = result.yield_texts;
ui.global::<EditorModel>()
.set_volumetric_yield_text(vol_yield_text.into());
ui.global::<EditorModel>()
.set_carat_weight_text(carat_text.into());
ui.global::<EditorModel>()
.set_specific_gravity_used_text(sg_used_text.into());
ui.global::<EditorModel>()
.set_preform_fit_warning(fit_text.into());
let planes = Arc::new(result.planes);
let mut ctx = render_ctx.lock().unwrap_or_else(PoisonError::into_inner);
if !ctx.claim_active_planes(
Arc::clone(&planes),
Some(result.gear),
PlanesOwner::Editor {
generation: started_generation,
},
) {
drop(ctx);
dispatch_pending(ui, render_ctx, pending);
return;
}
ctx.dirty = true;
let design_gear = ctx.design_gear;
let camera = CameraPose {
yaw: ctx.yaw,
pitch: ctx.pitch,
distance: ctx.distance,
};
let render_size = (ctx.width, ctx.height);
drop(ctx);
let redraw_planes: Vec<(glam::Vec3, f32)> = planes
.iter()
.map(|p| (glam::Vec3::from(p.normal), -p.d))
.collect();
let view_mode = ui.global::<SolidPreviewModel>().get_view_mode() as u8;
let size = contained_request_size(view_mode, scaled_viewport_size(ui), render_size);
let (preview_state, solid_last_solved) = RUNTIME.with(|cell| {
let rt = cell.borrow();
(rt.preview_state.clone(), rt.solid_last_solved.clone())
});
if let Some(preview_state) = preview_state {
preview_state.request_redraw_with_gear(redraw_planes, camera, size, view_mode, design_gear);
}
if let (Some(solved), Some(cache)) = (result.solved, solid_last_solved) {
*cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(solved);
}
ui.global::<TiltModel>()
.set_cached_curve_material("".into());
if ui.global::<TiltModel>().get_dialog_open() {
ui.global::<TiltModel>().invoke_request_tilt_profile_axes();
}
dispatch_pending(ui, render_ctx, pending);
}
fn dispatch_pending(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
pending: Option<PendingDispatch>,
) {
if let Some(pending) = pending {
dispatch_background_solve(
ui,
render_ctx,
pending.design,
&pending.generation,
pending.multi_selected,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_budget_disables_auto_solve_regardless_of_measurement() {
assert!(!should_schedule_auto_solve(None, Duration::ZERO));
assert!(!should_schedule_auto_solve(
Some(Duration::from_millis(1)),
Duration::ZERO
));
}
#[test]
fn no_measurement_yet_is_scheduled_optimistically() {
assert!(should_schedule_auto_solve(None, Duration::from_millis(300)));
}
#[test]
fn a_measurement_under_budget_is_scheduled() {
assert!(should_schedule_auto_solve(
Some(Duration::from_millis(120)),
Duration::from_millis(300)
));
}
#[test]
fn a_measurement_at_or_over_budget_is_not_scheduled() {
assert!(!should_schedule_auto_solve(
Some(Duration::from_millis(300)),
Duration::from_millis(300)
));
assert!(!should_schedule_auto_solve(
Some(Duration::from_secs(6)),
Duration::from_millis(300)
));
}
#[test]
fn a_fresh_design_with_few_planes_solves_synchronously() {
assert!(should_solve_synchronously(4, None));
}
#[test]
fn a_fresh_design_with_many_planes_does_not_solve_synchronously() {
assert!(!should_solve_synchronously(210, None));
}
#[test]
fn a_real_fast_measurement_wins_over_a_high_plane_count() {
assert!(should_solve_synchronously(
210,
Some(Duration::from_millis(50))
));
}
#[test]
fn a_real_slow_measurement_loses_even_with_few_planes() {
assert!(!should_solve_synchronously(4, Some(Duration::from_secs(2))));
}
#[test]
fn last_measured_solve_duration_reflects_record_solve_duration() {
reset_for_new_design();
assert_eq!(last_measured_solve_duration(), None);
record_solve_duration(Duration::from_millis(77));
assert_eq!(
last_measured_solve_duration(),
Some(Duration::from_millis(77))
);
reset_for_new_design();
assert_eq!(last_measured_solve_duration(), None);
}
#[test]
fn a_freshly_recorded_sequence_number_is_current() {
let seq = RUNTIME.with(|cell| {
let mut rt = cell.borrow_mut();
rt.current_seq += 1;
rt.current_seq
});
assert!(is_current(seq));
}
#[test]
fn an_older_sequence_number_is_superseded_by_a_newer_dispatch() {
let old_seq = RUNTIME.with(|cell| {
let mut rt = cell.borrow_mut();
rt.current_seq += 1;
rt.current_seq
});
RUNTIME.with(|cell| cell.borrow_mut().current_seq += 1);
assert!(!is_current(old_seq));
}
#[test]
fn cancel_in_flight_solve_supersedes_whatever_sequence_was_current() {
let seq = RUNTIME.with(|cell| {
let mut rt = cell.borrow_mut();
rt.current_seq += 1;
rt.current_seq
});
assert!(is_current(seq));
cancel_in_flight_solve();
assert!(
!is_current(seq),
"cancelling must supersede whatever solve was in flight, so its \
eventual completion is dropped as stale"
);
}
#[test]
fn cancel_in_flight_solve_drops_a_queued_pending_dispatch() {
RUNTIME.with(|cell| {
cell.borrow_mut().pending_dispatch = Some(PendingDispatch {
design: fixture_design(),
generation: Arc::new(AtomicU64::new(0)),
multi_selected: BTreeSet::new(),
});
});
cancel_in_flight_solve();
RUNTIME.with(|cell| {
assert!(
cell.borrow().pending_dispatch.is_none(),
"a cancel must drop whatever dispatch was queued behind the \
cancelled solve, not replay it once the in-flight worker \
eventually frees the slot"
);
});
}
#[test]
fn cancel_in_flight_solve_drops_the_pending_debounce() {
RUNTIME.with(|cell| cell.borrow_mut().debounce = Some(slint::Timer::default()));
cancel_in_flight_solve();
RUNTIME.with(|cell| {
assert!(
cell.borrow().debounce.is_none(),
"a cancel must also drop whatever debounced auto-solve was pending"
);
});
}
#[test]
fn cancel_in_flight_solve_leaves_the_measurement_and_design_stash_untouched() {
reset_for_new_design();
record_solve_duration(Duration::from_millis(250));
stash_current_design(3, fixture_design(), BTreeSet::new());
cancel_in_flight_solve();
assert_eq!(
last_measured_solve_duration(),
Some(Duration::from_millis(250)),
"cancelling an in-flight solve must not discard this design's own \
last REAL measurement -- unlike reset_for_new_design, no different \
design has just replaced it"
);
assert!(
take_matching_design(3).is_some(),
"cancelling an in-flight solve must not clear an unrelated stash \
from a concurrent edit's own submit_preview_replan"
);
}
#[test]
fn record_solve_duration_is_visible_to_the_next_schedule_decision() {
record_solve_duration(Duration::from_millis(42));
let last = RUNTIME.with(|cell| cell.borrow().last_solve);
assert_eq!(last, Some(Duration::from_millis(42)));
reset_for_new_design();
let last = RUNTIME.with(|cell| cell.borrow().last_solve);
assert_eq!(last, None);
}
#[test]
fn solving_banner_pluralizes_the_tier_count() {
assert!(solving_banner(1, Duration::ZERO).contains("1 tier)"));
assert!(solving_banner(2, Duration::ZERO).contains("2 tiers)"));
}
#[test]
fn auto_solve_off_note_reports_the_measured_seconds() {
let text = auto_solve_off_note(Duration::from_millis(5900));
assert!(text.contains("5.9s"));
}
fn fixture_design() -> Design {
Design::fresh(
indicatrix_cut_core::PreformSpec::cylinder(96, 1.5, 1.0, 1.5),
96,
8,
1.54,
)
}
#[test]
fn take_matching_design_returns_none_when_nothing_stashed() {
reset_for_new_design();
assert!(take_matching_design(1).is_none());
}
#[test]
fn take_matching_design_returns_the_stash_for_its_own_generation() {
stash_current_design(5, fixture_design(), BTreeSet::from([2]));
let (design, multi_selected) = take_matching_design(5).expect("stashed at generation 5");
assert!(
design.tiers.is_empty(),
"a fresh design starts with no tiers"
);
assert_eq!(multi_selected, BTreeSet::from([2]));
}
#[test]
fn take_matching_design_only_matches_once_per_stash() {
stash_current_design(11, fixture_design(), BTreeSet::new());
assert!(
take_matching_design(11).is_some(),
"the first call must match"
);
assert!(
take_matching_design(11).is_none(),
"a second call for the same generation must not match again"
);
}
#[test]
fn take_matching_design_rejects_a_superseded_generation() {
stash_current_design(5, fixture_design(), BTreeSet::new());
assert!(
take_matching_design(6).is_none(),
"a newer generation must not match an older stash -- an edit landed \
after the frame asking for generation 6 was submitted"
);
}
#[test]
fn take_matching_design_drops_the_pending_debounce_on_a_match() {
stash_current_design(9, fixture_design(), BTreeSet::new());
RUNTIME.with(|cell| cell.borrow_mut().debounce = Some(slint::Timer::default()));
assert!(take_matching_design(9).is_some());
RUNTIME.with(|cell| {
assert!(
cell.borrow().debounce.is_none(),
"a matching frame must cancel whatever debounced auto-solve was pending"
);
});
}
}