use super::{
activity::ActivityRegistry,
state::{
EditorState, apply_multi_selection, cutting_schedule_rows, design_to_gpu_planes,
girdle_and_ratio_texts, manufacturability_warnings_tagged, preform_mm_texts,
preform_y_offset_mm_text, proportions_texts, 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::{
ReplanSource, SolidLastSolved, facet_count_from_solved, girdle_and_ratio_texts_from_solved,
preform_mm_texts_from_solved, proportions_texts_from_solved, scaled_viewport_size,
submit_preview_replan_for,
},
};
use crate::{
AngleItem, EditorModel, EditorTierItem, MainWindow, SolidPreviewModel, TiltModel,
bridge::render_thread::{PlanesOwner, RenderContext},
gui::{
render::camera_lighting::contained_request_size,
show_toast,
solid_preview::preview_state::{CameraPose, SolidPreviewState},
},
};
use indicatrix::{
geometry::{
GpuFacetPlane,
meet_solver::{SolveControl, SolveError, SolveStrategy, SolvedTier, solve_meet_points},
},
optics::materials::GemMaterial,
};
use indicatrix_cut_core::{Design, DesignSolveError};
use slint::{ComponentHandle, ModelRc, SharedString, VecModel, Weak};
use std::{
cell::RefCell,
collections::BTreeSet,
rc::Rc,
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 IDLE_REPLAN_DEBOUNCE: Duration = Duration::from_millis(400);
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>>>,
editor_state: Option<Rc<RefCell<EditorState>>>,
activity: Option<Rc<ActivityRegistry>>,
last_solve: Option<Duration>,
current_seq: u64,
debounce: Option<slint::Timer>,
current_design: Option<(u64, Arc<Design>, BTreeSet<usize>)>,
solve_in_flight: bool,
pending_dispatch: Option<PendingDispatch>,
idle_replan: Option<slint::Timer>,
current_cancel: Option<Arc<AtomicBool>>,
activity_id: Option<u64>,
too_many_planes_toasted: bool,
}
impl Runtime {
const fn new() -> Self {
Self {
preview_state: None,
solid_last_solved: None,
render_ctx: None,
editor_state: None,
activity: None,
last_solve: None,
current_seq: 0,
debounce: None,
current_design: None,
solve_in_flight: false,
pending_dispatch: None,
idle_replan: None,
current_cancel: None,
activity_id: None,
too_many_planes_toasted: false,
}
}
}
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,
activity: &Rc<ActivityRegistry>,
) {
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));
rt.activity = Some(Rc::clone(activity));
});
}
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())
}
#[must_use]
pub(super) fn activity() -> Option<Rc<ActivityRegistry>> {
RUNTIME.with(|cell| cell.borrow().activity.clone())
}
pub(super) fn stash_editor_state(state: &Rc<RefCell<EditorState>>) {
RUNTIME.with(|cell| {
cell.borrow_mut().editor_state = Some(Rc::clone(state));
});
}
#[must_use]
pub(super) fn editor_state() -> Option<Rc<RefCell<EditorState>>> {
RUNTIME.with(|cell| cell.borrow().editor_state.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()
}
pub(super) fn solve_cancellably(
design: &Design,
cancel: &AtomicBool,
) -> Result<Vec<SolvedTier>, DesignSolveError> {
match design.solve_with(&SolveControl::with_cancel(cancel)) {
Err(DesignSolveError::Solve(SolveError::TooManyPlanes { .. })) => Ok(solve_meet_points(
design.meta.gear_teeth_abs(),
&design.meet_tier_inputs(),
)),
other => other,
}
}
#[must_use]
pub(super) fn likely_hit_plane_cap(solved: &[SolvedTier]) -> bool {
solved
.iter()
.any(|t| matches!(t.strategy, SolveStrategy::Failed) && t.detail.contains("plane cap"))
}
#[must_use]
pub(super) fn too_many_planes_message(design: &Design) -> Option<String> {
match design.solve_with(&SolveControl::default()) {
Err(DesignSolveError::Solve(SolveError::TooManyPlanes { planes, max })) => Some(format!(
"This design has {planes} facet planes; the solver supports up to {max} -- reduce \
symmetry or split the design."
)),
_ => None,
}
}
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));
}
#[must_use]
pub(super) fn last_solve() -> Option<Duration> {
RUNTIME.with(|cell| cell.borrow().last_solve)
}
#[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.idle_replan = 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();
if let Some(cancel) = rt.current_cancel.as_ref() {
cancel.store(true, Ordering::Relaxed);
}
rt.current_seq += 1;
rt.debounce = None;
rt.pending_dispatch = None;
rt.idle_replan = None;
});
}
pub(super) fn cancel_in_flight_solve_from_activity(ui_weak: &Weak<MainWindow>) {
cancel_in_flight_solve();
let Some(ui) = ui_weak.upgrade() else {
return;
};
let model = ui.global::<EditorModel>();
model.set_solve_running(false);
model.set_solve_state("stale".into());
model.set_status_text("Solve abandoned -- click Solve when you are ready.".into());
model.set_status_is_problem(true);
}
fn is_current(seq: u64) -> bool {
RUNTIME.with(|cell| cell.borrow().current_seq == seq)
}
pub(super) fn stash_current_design(
generation: u64,
design: Arc<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<(Arc<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 schedule_idle_replan_if_stale(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
generation: u64,
design: &Design,
multi_selected: &BTreeSet<usize>,
) {
let Some((preview_state, solid_last_solved)) = RUNTIME.with(|cell| {
let rt = cell.borrow();
rt.preview_state.clone().zip(rt.solid_last_solved.clone())
}) else {
return;
};
let epoch = RUNTIME.with(|cell| cell.borrow().current_seq);
let ui_weak = ui.as_weak();
let render_ctx = Arc::clone(render_ctx);
let design = design.clone();
let multi_selected = multi_selected.clone();
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::SingleShot,
IDLE_REPLAN_DEBOUNCE,
move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let nothing_newer_since = RUNTIME.with(|cell| {
let rt = cell.borrow();
rt.current_design.is_none() && rt.current_seq == epoch
});
if !nothing_newer_since || !ui.global::<SolidPreviewModel>().get_stale() {
return;
}
submit_preview_replan_for(
&ui,
&render_ctx,
&preview_state,
&solid_last_solved,
ReplanSource {
design: &design,
generation,
multi_selected: &multi_selected,
},
BTreeSet::new(),
true,
);
},
);
RUNTIME.with(|cell| cell.borrow_mut().idle_replan = Some(timer));
}
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<(usize, String)>,
yield_texts: (String, String, String, String),
planes: Vec<GpuFacetPlane>,
solved: Option<Vec<SolvedTier>>,
too_many_planes: bool,
gear: (u32, f32),
multi_selected_count: usize,
design: Design,
}
struct PanelInputs {
tiers: Vec<EditorTierItem>,
status_text: String,
status_is_problem: bool,
warnings: Vec<(usize, String)>,
yield_texts: (String, String, String, String),
planes: Vec<GpuFacetPlane>,
too_many_planes: bool,
}
fn panel_inputs(
design: &Design,
solved: Option<&Vec<SolvedTier>>,
custom_materials: &[GemMaterial],
custom_sg: &[(String, f64)],
) -> PanelInputs {
let n_d = design.effective_refractive_index_with(custom_materials);
let too_many_planes = solved.is_some_and(|solved| likely_hit_plane_cap(solved))
&& too_many_planes_message(design).is_some();
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_warnings_tagged(design, None),
yield_texts: yield_report_texts(design, custom_sg),
planes: design_to_gpu_planes(design),
too_many_planes,
}
},
|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_warnings_tagged(design, Some(solved)),
yield_texts: yield_report_texts_from_solved(design, solved, custom_sg),
planes: design_to_gpu_planes_from_solved(design, solved),
too_many_planes,
}
},
)
}
fn spawn_solving_ticker(ui: &MainWindow, seq: u64, tier_count: usize) -> Arc<AtomicBool> {
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());
}
});
}
});
done_flag
}
fn queue_or_claim_solve_slot(
design: &Design,
generation: &Arc<AtomicU64>,
multi_selected: &BTreeSet<usize>,
cancel: &Arc<AtomicBool>,
) -> bool {
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;
rt.current_cancel = Some(Arc::clone(cancel));
false
}
})
}
fn register_solve_activity(ui: &MainWindow, tier_count: usize) {
let activity_id = RUNTIME
.with(|cell| cell.borrow().activity.clone())
.map(|a| {
let ui_weak = ui.as_weak();
a.start(
"solve",
solving_banner(tier_count, Duration::ZERO),
Some(Box::new(move || {
cancel_in_flight_solve_from_activity(&ui_weak);
})),
)
});
RUNTIME.with(|cell| cell.borrow_mut().activity_id = activity_id);
}
fn solve_and_build_result(
design: Design,
cancel: &AtomicBool,
multi_selected: &BTreeSet<usize>,
custom_materials: &[GemMaterial],
custom_sg: &[(String, f64)],
start: Instant,
) -> BackgroundSolveResult {
let solved_result = solve_cancellably(&design, cancel);
let multi_selected_count = multi_selected.len();
let PanelInputs {
mut tiers,
status_text,
status_is_problem,
warnings,
yield_texts,
planes,
too_many_planes,
} = panel_inputs(
&design,
solved_result.as_ref().ok(),
custom_materials,
custom_sg,
);
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,
);
BackgroundSolveResult {
elapsed: start.elapsed(),
tiers,
status_text,
status_is_problem,
warnings,
yield_texts,
planes,
solved,
too_many_planes,
gear,
multi_selected_count,
design,
}
}
pub(super) fn dispatch_background_solve(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
design: Design,
generation: &Arc<AtomicU64>,
multi_selected: BTreeSet<usize>,
) {
let cancel = Arc::new(AtomicBool::new(false));
if queue_or_claim_solve_slot(&design, generation, &multi_selected, &cancel) {
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);
register_solve_activity(ui, tier_count);
let done_flag = spawn_solving_ticker(ui, seq, tier_count);
let (custom_materials, custom_sg) = {
let ctx = render_ctx.lock().unwrap_or_else(PoisonError::into_inner);
(
Arc::clone(&ctx.custom_materials),
Arc::clone(&ctx.custom_material_specific_gravity),
)
};
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 result = solve_and_build_result(
design,
&cancel,
&multi_selected,
&custom_materials,
&custom_sg,
start,
);
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 free_in_flight_slot() -> Option<PendingDispatch> {
RUNTIME.with(|cell| {
let mut rt = cell.borrow_mut();
rt.solve_in_flight = false;
rt.current_cancel = None;
if let (Some(activity), Some(id)) = (rt.activity.clone(), rt.activity_id.take()) {
activity.finish(id);
}
rt.pending_dispatch.take()
})
}
fn maybe_toast_too_many_planes(ui: &MainWindow, result: &BackgroundSolveResult) {
let already_toasted = RUNTIME.with(|cell| {
let mut rt = cell.borrow_mut();
let already = rt.too_many_planes_toasted;
rt.too_many_planes_toasted = result.too_many_planes;
already
});
if result.too_many_planes && !already_toasted {
show_toast(ui, &result.status_text, "warning");
}
}
fn apply_background_solve_result(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
seq: u64,
started_generation: u64,
generation: &Arc<AtomicU64>,
result: BackgroundSolveResult,
) {
let pending = free_in_flight_slot();
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;
}
maybe_toast_too_many_planes(ui, &result);
push_solve_dependent_background_fields(ui, &result);
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));
let warning_tiers: Vec<i32> = result
.warnings
.iter()
.map(|(index, _)| i32::try_from(*index).unwrap_or(i32::MAX))
.collect();
ui.global::<EditorModel>()
.set_manufacturability_warnings(ModelRc::new(VecModel::from(
result
.warnings
.into_iter()
.map(|(_, text)| SharedString::from(text))
.collect::<Vec<_>>(),
)));
ui.global::<EditorModel>()
.set_manufacturability_warning_tiers(ModelRc::new(VecModel::from(warning_tiers)));
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 push_solve_dependent_background_fields(ui: &MainWindow, result: &BackgroundSolveResult) {
let solved_slice = result.solved.as_deref();
let (table_pct, crown_height, pavilion_depth, total_depth, length_to_width) = solved_slice
.map_or_else(
|| proportions_texts(&result.design),
|solved| proportions_texts_from_solved(&result.design, solved),
);
ui.global::<EditorModel>()
.set_proportions_table_pct(table_pct.into());
ui.global::<EditorModel>()
.set_proportions_crown_height(crown_height.into());
ui.global::<EditorModel>()
.set_proportions_pavilion_depth(pavilion_depth.into());
ui.global::<EditorModel>()
.set_proportions_total_depth(total_depth.into());
ui.global::<EditorModel>()
.set_proportions_length_to_width(length_to_width.into());
let (girdle_thickness, crown_to_width, pavilion_to_width, girdle_to_width) = solved_slice
.map_or_else(
|| girdle_and_ratio_texts(&result.design),
|solved| girdle_and_ratio_texts_from_solved(&result.design, solved),
);
ui.global::<EditorModel>()
.set_girdle_thickness_text(girdle_thickness.into());
ui.global::<EditorModel>()
.set_crown_to_width_text(crown_to_width.into());
ui.global::<EditorModel>()
.set_pavilion_to_width_text(pavilion_to_width.into());
ui.global::<EditorModel>()
.set_girdle_to_width_text(girdle_to_width.into());
let (preform_half_width_mm, preform_depth_mm) = solved_slice.map_or_else(
|| preform_mm_texts(&result.design),
|solved| preform_mm_texts_from_solved(&result.design, solved),
);
ui.global::<EditorModel>()
.set_preform_half_width_mm_text(preform_half_width_mm.into());
ui.global::<EditorModel>()
.set_preform_depth_mm_text(preform_depth_mm.into());
let mm_per_unit =
solved_slice.and_then(|solved| result.design.yield_report(solved).mm_per_unit);
ui.global::<EditorModel>().set_preform_y_offset_mm(
preform_y_offset_mm_text(result.design.preform_y_offset, mm_per_unit).into(),
);
let facet_count = solved_slice.map_or(0, |solved| {
i32::try_from(facet_count_from_solved(&result.design, solved)).unwrap_or(i32::MAX)
});
ui.global::<EditorModel>().set_facet_count(facet_count);
if let Some(solved) = solved_slice {
let rows: Vec<AngleItem> = cutting_schedule_rows(&result.design, solved);
ui.global::<EditorModel>()
.set_cutting_rows(ModelRc::new(VecModel::from(rows)));
} else {
ui.global::<EditorModel>()
.set_cutting_rows(ModelRc::new(VecModel::from(Vec::<AngleItem>::new())));
}
}
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 last_solve_is_the_production_counterpart_of_last_measured_solve_duration() {
reset_for_new_design();
assert_eq!(last_solve(), None);
record_solve_duration(Duration::from_millis(123));
assert_eq!(last_solve(), Some(Duration::from_millis(123)));
assert_eq!(last_solve(), last_measured_solve_duration());
}
#[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 reset_for_new_design_drops_the_idle_replan_timer() {
RUNTIME.with(|cell| cell.borrow_mut().idle_replan = Some(slint::Timer::default()));
reset_for_new_design();
RUNTIME.with(|cell| {
assert!(
cell.borrow().idle_replan.is_none(),
"a wholesale New/Load must drop a partial-frame idle-replan timer \
armed for whatever design it is replacing -- otherwise that timer \
can fire later and resubmit the OLD design's stashed masts on top \
of the one just loaded"
);
});
}
#[test]
fn cancel_in_flight_solve_drops_the_idle_replan_timer() {
RUNTIME.with(|cell| cell.borrow_mut().idle_replan = Some(slint::Timer::default()));
cancel_in_flight_solve();
RUNTIME.with(|cell| {
assert!(
cell.borrow().idle_replan.is_none(),
"cancelling a solve must also drop whatever idle-replan timer 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, Arc::new(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, Arc::new(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, Arc::new(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, Arc::new(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 stash_current_design_shares_the_arc_rather_than_deep_cloning() {
let snapshot = Arc::new(fixture_design());
assert_eq!(Arc::strong_count(&snapshot), 1);
stash_current_design(7, Arc::clone(&snapshot), BTreeSet::new());
assert_eq!(
Arc::strong_count(&snapshot),
2,
"the stash must hold a SHARED clone of the same allocation, not a deep copy"
);
let (taken, _) = take_matching_design(7).expect("stashed at generation 7");
assert!(
Arc::ptr_eq(&snapshot, &taken),
"take_matching_design must hand back the SAME allocation stash_current_design \
was given, never a fresh deep clone"
);
}
const CRACKOTTO_STEP_ASC: &str = include_str!(
"../../../../../crates/indicatrix-cut-core/src/optimize_cost_probe_crackotto_step.asc"
);
fn crackotto_step_with_real_meet_structure() -> Design {
use indicatrix::geometry::meet_solver::{Block, classify_blocks};
let schedule =
indicatrix_formats::asc::parse_asc(CRACKOTTO_STEP_ASC).expect("fixture must parse");
let mut design = Design::from_asc_schedule(
indicatrix_cut_core::PreformSpec::block(2.0, 1.0, 2.0),
&schedule,
);
let inputs = design.meet_tier_inputs();
let blocks = classify_blocks(&inputs);
let mut anchor_kept = [false; 3];
let slot = |b: Block| match b {
Block::Crown => 0,
Block::Pavilion => 1,
Block::Girdle => 2,
};
for (i, tier) in design.tiers.iter_mut().enumerate() {
let s = slot(blocks[i]);
if !anchor_kept[s] {
anchor_kept[s] = true;
continue;
}
if let Some(original) = tier.imported_meet.clone() {
tier.constraint = original;
}
}
design
}
#[test]
fn solve_cancellably_stops_within_100ms_on_the_largest_real_fixture() {
let design = crackotto_step_with_real_meet_structure();
let cancel = Arc::new(AtomicBool::new(false));
let cancel_for_thread = Arc::clone(&cancel);
let worker = thread::spawn(move || solve_cancellably(&design, &cancel_for_thread));
thread::sleep(Duration::from_millis(50));
let cancel_requested_at = Instant::now();
cancel.store(true, Ordering::Relaxed);
let result = worker.join().expect("worker thread must not panic");
let cancel_latency = cancel_requested_at.elapsed();
assert!(
cancel_latency < Duration::from_millis(100),
"cancel took {cancel_latency:?}, want < 100ms (full uncancelled solve is ~5.9s)"
);
assert!(
matches!(result, Err(DesignSolveError::Solve(SolveError::Cancelled))),
"expected a cancelled solve"
);
}
#[test]
fn take_matching_design_drops_the_pending_debounce_on_a_match() {
stash_current_design(9, Arc::new(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"
);
});
}
}