use super::auto_solve::solve_cancellably;
use indicatrix::geometry::{
meet_solver::{
SolveControl, SolveError, SolveProgress, SolvedTier, VerifiedSolveReport,
solve_meet_points_verified_with,
},
stone_metrics::ExternalProportions,
};
use indicatrix_cut_core::{Design, DesignSolveError};
use slint::{ComponentHandle, Weak};
use std::{
cell::Cell,
collections::BTreeSet,
sync::{
Arc, Condvar, Mutex, PoisonError,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread,
time::{Duration, Instant},
};
const PROGRESS_THROTTLE: Duration = Duration::from_millis(100);
pub enum SolveKind {
Full,
#[expect(
dead_code,
reason = "SolveService's public API is specified with this variant even though \
no caller in this module submits it yet -- see the variant's own doc comment"
)]
Dirty {
previous: Vec<SolvedTier>,
dirty: BTreeSet<usize>,
},
Verified {
targets: ExternalProportions,
compute_baseline: bool,
},
}
pub struct VerifiedSolve {
pub solved: Vec<SolvedTier>,
pub report: VerifiedSolveReport,
pub baseline: Option<Vec<SolvedTier>>,
}
pub struct SolveRequest {
pub design: Arc<Design>,
pub generation: u64,
pub kind: SolveKind,
}
pub enum SolveOutcome {
Solved(Result<Vec<SolvedTier>, DesignSolveError>),
Verified(Result<VerifiedSolve, SolveError>),
}
pub struct SolveResult {
pub generation: u64,
pub elapsed: Duration,
pub outcome: SolveOutcome,
}
#[derive(Debug, Clone, Copy)]
pub struct SolveProgressReport {
pub generation: u64,
pub progress: SolveProgress,
}
pub struct SolveHandle {
pub generation: u64,
cancel: Arc<AtomicBool>,
}
impl SolveHandle {
pub fn cancel(&self) {
self.cancel.store(true, Ordering::Relaxed);
}
}
struct Queued {
request: SolveRequest,
seq: u64,
cancel: Arc<AtomicBool>,
}
struct Mailbox {
slot: Mutex<Option<Queued>>,
ready: Condvar,
}
impl Mailbox {
const fn new() -> Self {
Self {
slot: Mutex::new(None),
ready: Condvar::new(),
}
}
fn put(&self, item: Queued) {
{
let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
*slot = Some(item);
}
self.ready.notify_one();
}
fn take_blocking(&self) -> Queued {
let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
loop {
if let Some(item) = slot.take() {
return item;
}
slot = self
.ready
.wait(slot)
.unwrap_or_else(PoisonError::into_inner);
}
}
#[cfg(test)]
fn try_take(&self) -> Option<Queued> {
let mut slot = self.slot.lock().unwrap_or_else(PoisonError::into_inner);
slot.take()
}
}
struct ProgressThrottle {
last_forwarded: Cell<Option<Instant>>,
last_phase_sweep: Cell<Option<(indicatrix::geometry::meet_solver::SolvePhase, u32)>>,
}
impl ProgressThrottle {
const fn new() -> Self {
Self {
last_forwarded: Cell::new(None),
last_phase_sweep: Cell::new(None),
}
}
fn should_forward(&self, progress: SolveProgress) -> bool {
let key = (progress.phase, progress.sweep);
let new_phase_sweep = self.last_phase_sweep.get() != Some(key);
let interval_elapsed = self
.last_forwarded
.get()
.is_none_or(|t| t.elapsed() >= PROGRESS_THROTTLE);
if !new_phase_sweep && !interval_elapsed {
return false;
}
self.last_phase_sweep.set(Some(key));
self.last_forwarded.set(Some(Instant::now()));
true
}
}
fn run_solve(
request: &SolveRequest,
cancel: &AtomicBool,
on_progress: &dyn Fn(SolveProgress),
) -> SolveOutcome {
let control = SolveControl::with_cancel(cancel).reporting(on_progress);
match &request.kind {
SolveKind::Full => SolveOutcome::Solved(request.design.solve_with(&control)),
SolveKind::Dirty { previous, dirty } => {
SolveOutcome::Solved(request.design.resolve_dirty_with(previous, dirty, &control))
}
SolveKind::Verified {
targets,
compute_baseline,
} => {
let baseline = (*compute_baseline)
.then(|| solve_cancellably(&request.design, cancel).ok())
.flatten();
let gear_teeth_abs = request.design.meta.gear_teeth_abs();
let tiers = request.design.meet_tier_inputs();
SolveOutcome::Verified(
solve_meet_points_verified_with(gear_teeth_abs, &tiers, targets, &[], &control)
.map(|(solved, report)| VerifiedSolve {
solved,
report,
baseline,
}),
)
}
}
}
pub struct SolveService {
mailbox: Arc<Mailbox>,
latest_seq: Arc<AtomicU64>,
}
impl SolveService {
pub fn new<T, P, D>(ui_weak: Weak<T>, on_progress: P, on_result: D) -> Self
where
T: ComponentHandle + 'static,
P: Fn(&T, SolveProgressReport) + Send + Clone + 'static,
D: Fn(&T, SolveResult) + Send + Clone + 'static,
{
let mailbox = Arc::new(Mailbox::new());
let latest_seq = Arc::new(AtomicU64::new(0));
let worker_mailbox = Arc::clone(&mailbox);
let worker_seq = Arc::clone(&latest_seq);
thread::spawn(move || {
worker_loop(
&ui_weak,
&worker_mailbox,
&worker_seq,
on_progress,
on_result,
);
});
Self {
mailbox,
latest_seq,
}
}
pub fn submit(&self, request: SolveRequest) -> SolveHandle {
let seq = self.latest_seq.fetch_add(1, Ordering::Relaxed) + 1;
let cancel = Arc::new(AtomicBool::new(false));
let generation = request.generation;
self.mailbox.put(Queued {
request,
seq,
cancel: Arc::clone(&cancel),
});
SolveHandle { generation, cancel }
}
}
fn worker_loop<T, P, D>(
ui_weak: &Weak<T>,
mailbox: &Mailbox,
latest_seq: &AtomicU64,
on_progress: P,
on_result: D,
) where
T: ComponentHandle + 'static,
P: Fn(&T, SolveProgressReport) + Send + Clone + 'static,
D: Fn(&T, SolveResult) + Send + Clone + 'static,
{
loop {
let Queued {
request,
seq,
cancel,
} = mailbox.take_blocking();
let generation = request.generation;
let start = Instant::now();
let throttle = ProgressThrottle::new();
let outcome = run_solve(&request, &cancel, &|progress| {
if latest_seq.load(Ordering::Relaxed) != seq || !throttle.should_forward(progress) {
return;
}
let ui = ui_weak.clone();
let on_progress = on_progress.clone();
let _ = ui.upgrade_in_event_loop(move |ui| {
on_progress(
&ui,
SolveProgressReport {
generation,
progress,
},
);
});
});
if latest_seq.load(Ordering::Relaxed) != seq {
continue;
}
let result = SolveResult {
generation,
elapsed: start.elapsed(),
outcome,
};
let ui = ui_weak.clone();
let on_result = on_result.clone();
let _ = ui.upgrade_in_event_loop(move |ui| {
on_result(&ui, result);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix::geometry::meet_solver::{Block, MeetConstraint, classify_blocks};
use indicatrix_cut_core::PreformSpec;
fn dummy_request(generation: u64) -> SolveRequest {
SolveRequest {
design: Arc::new(Design::fresh(
PreformSpec::block(2.0, 1.0, 2.0),
96,
8,
1.54,
)),
generation,
kind: SolveKind::Full,
}
}
fn dummy_queued(generation: u64, seq: u64) -> Queued {
Queued {
request: dummy_request(generation),
seq,
cancel: Arc::new(AtomicBool::new(false)),
}
}
#[test]
fn three_requests_in_a_burst_coalesce_to_one_solve_of_the_last() {
let mailbox = Mailbox::new();
mailbox.put(dummy_queued(1, 1));
mailbox.put(dummy_queued(2, 2));
mailbox.put(dummy_queued(3, 3));
let got = mailbox.try_take().expect("one request must be queued");
assert_eq!(got.request.generation, 3, "must keep only the LAST request");
assert_eq!(got.seq, 3);
assert!(
mailbox.try_take().is_none(),
"the two earlier requests must have been discarded, not queued behind the last"
);
}
#[test]
fn put_after_take_queues_a_fresh_request() {
let mailbox = Mailbox::new();
mailbox.put(dummy_queued(1, 1));
assert!(mailbox.try_take().is_some());
assert!(
mailbox.try_take().is_none(),
"mailbox must be empty after a take"
);
mailbox.put(dummy_queued(2, 2));
let got = mailbox
.try_take()
.expect("a request put after an empty take must be queued");
assert_eq!(got.request.generation, 2);
}
#[test]
fn handle_cancel_sets_the_flag_a_worker_would_read() {
let flag = Arc::new(AtomicBool::new(false));
let handle = SolveHandle {
generation: 0,
cancel: Arc::clone(&flag),
};
assert!(!flag.load(Ordering::Relaxed));
handle.cancel();
assert!(flag.load(Ordering::Relaxed));
}
#[test]
fn a_result_whose_seq_no_longer_matches_latest_seq_is_stale() {
let latest_seq = AtomicU64::new(1);
assert_eq!(latest_seq.load(Ordering::Relaxed), 1);
latest_seq.store(2, Ordering::Relaxed);
assert_ne!(
1,
latest_seq.load(Ordering::Relaxed),
"an older seq must read as stale once a newer submit landed"
);
assert_eq!(
2,
latest_seq.load(Ordering::Relaxed),
"the newest seq must still read as current"
);
}
fn progress(phase: indicatrix::geometry::meet_solver::SolvePhase, sweep: u32) -> SolveProgress {
SolveProgress {
phase,
sweep,
max_sweeps: 4,
blocks_done: 0,
blocks_total: 10,
}
}
#[test]
fn first_report_of_a_new_phase_sweep_is_always_forwarded() {
let throttle = ProgressThrottle::new();
assert!(throttle.should_forward(progress(
indicatrix::geometry::meet_solver::SolvePhase::Refine,
1
)));
}
#[test]
fn a_second_report_of_the_same_phase_sweep_within_the_interval_is_dropped() {
let throttle = ProgressThrottle::new();
let p = progress(indicatrix::geometry::meet_solver::SolvePhase::Refine, 1);
assert!(throttle.should_forward(p));
assert!(
!throttle.should_forward(p),
"an immediate repeat of the same (phase, sweep) inside the throttle window must be dropped"
);
}
#[test]
fn a_report_from_a_new_sweep_is_forwarded_even_inside_the_interval() {
let throttle = ProgressThrottle::new();
assert!(throttle.should_forward(progress(
indicatrix::geometry::meet_solver::SolvePhase::Refine,
1
)));
assert!(
throttle.should_forward(progress(
indicatrix::geometry::meet_solver::SolvePhase::Refine,
2
)),
"a genuinely new sweep must never be throttled away entirely"
);
}
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 {
let schedule =
indicatrix_formats::asc::parse_asc(CRACKOTTO_STEP_ASC).expect("fixture must parse");
let mut design = Design::from_asc_schedule(PreformSpec::block(2.0, 1.0, 2.0), &schedule);
assert_eq!(
design.tiers.len(),
103,
"fixture must have its real tier count"
);
let inputs = design.meet_tier_inputs();
let blocks = classify_blocks(&inputs);
let mut anchor_kept = [false; 3];
let block_slot = |b: Block| match b {
Block::Crown => 0,
Block::Pavilion => 1,
Block::Girdle => 2,
};
for (i, tier) in design.tiers.iter_mut().enumerate() {
let slot = block_slot(blocks[i]);
if !anchor_kept[slot] {
anchor_kept[slot] = true;
continue;
}
if let Some(original) = tier.imported_meet.clone() {
tier.constraint = original;
}
}
let non_anchor = design
.tiers
.iter()
.filter(|t| !matches!(t.constraint, MeetConstraint::ScaleReference(_)))
.count();
assert!(
non_anchor > 90,
"fixture must be mostly non-anchor for this to be a meaningful cancellation test, got {non_anchor}"
);
design
}
#[test]
fn cancel_returns_quickly_on_the_largest_real_fixture() {
let design = crackotto_step_with_real_meet_structure();
let cancel = Arc::new(AtomicBool::new(false));
let request = SolveRequest {
design: Arc::new(design),
generation: 0,
kind: SolveKind::Full,
};
let cancel_for_thread = Arc::clone(&cancel);
let worker = thread::spawn(move || run_solve(&request, &cancel_for_thread, &|_| {}));
thread::sleep(Duration::from_millis(50));
let cancel_requested_at = Instant::now();
cancel.store(true, Ordering::Relaxed);
let outcome = 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)"
);
match outcome {
SolveOutcome::Solved(Err(DesignSolveError::Solve(SolveError::Cancelled))) => {}
SolveOutcome::Solved(Ok(_)) => {
panic!("expected a cancelled solve, got a completed one")
}
SolveOutcome::Solved(Err(other)) => {
panic!("expected a cancelled solve, got a different error: {other}")
}
SolveOutcome::Verified(_) => {
panic!("expected SolveKind::Full to produce SolveOutcome::Solved")
}
}
}
}