use glam::{DVec3, Vec3};
use indicatrix::geometry::meet_solver::{MeetConstraint, SolveStrategy, SolvedTier};
use indicatrix_cut_core::{Design, MissingAnchor};
use std::{
collections::BTreeSet,
time::{Duration, Instant},
};
pub const DEFAULT_PREVIEW_BUDGET: Duration = Duration::from_millis(50);
pub trait DirtySolver {
fn resolve_dirty(
&self,
design: &Design,
previous: &[SolvedTier],
dirty: &BTreeSet<usize>,
) -> Result<Vec<SolvedTier>, MissingAnchor>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RealSolver;
impl DirtySolver for RealSolver {
fn resolve_dirty(
&self,
design: &Design,
previous: &[SolvedTier],
dirty: &BTreeSet<usize>,
) -> Result<Vec<SolvedTier>, MissingAnchor> {
design.resolve_dirty(previous, dirty)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Freshness {
Pinned,
Fresh,
Stale { pending: BTreeSet<usize> },
Unsolvable(MissingAnchor),
}
#[derive(Debug, Clone)]
pub struct PreviewPlan {
pub planes: Vec<(Vec3, f32)>,
pub solved: Option<Vec<SolvedTier>>,
pub freshness: Freshness,
}
fn narrow_planes(planes: Vec<(DVec3, f64)>) -> Vec<(Vec3, f32)> {
planes
.into_iter()
.map(|(n, m)| (Vec3::new(n.x as f32, n.y as f32, n.z as f32), m as f32))
.collect()
}
fn masts_from_pinned_tiers(design: &Design) -> Vec<SolvedTier> {
design
.tiers
.iter()
.map(|tier| {
let MeetConstraint::ScaleReference(mast) = &tier.constraint else {
unreachable!(
"masts_from_pinned_tiers: caller must confirm every tier is \
ScaleReference first"
);
};
SolvedTier {
mast: *mast,
strategy: SolveStrategy::ScaleReference,
detail: "pinned (ScaleReference)".to_string(),
}
})
.collect()
}
#[must_use]
pub fn plan_preview(
design: &Design,
last_solved: Option<&[SolvedTier]>,
dirty: &BTreeSet<usize>,
budget: Duration,
solver: &dyn DirtySolver,
) -> PreviewPlan {
if design
.tiers
.iter()
.all(|tier| matches!(tier.constraint, MeetConstraint::ScaleReference(_)))
{
let solved = masts_from_pinned_tiers(design);
let planes = narrow_planes(design.planes_from_solved(&solved));
return PreviewPlan {
planes,
solved: Some(solved),
freshness: Freshness::Pinned,
};
}
let aligned_previous = last_solved.filter(|previous| previous.len() == design.tiers.len());
let Some(previous) = aligned_previous else {
return match design.solve() {
Ok(solved) => {
let planes = narrow_planes(design.planes_from_solved(&solved));
PreviewPlan {
planes,
solved: Some(solved),
freshness: Freshness::Fresh,
}
}
Err(err) => {
let planes = last_solved.map_or_else(Vec::new, |previous| {
narrow_planes(design.planes_from_solved(previous))
});
PreviewPlan {
planes,
solved: None,
freshness: Freshness::Unsolvable(err),
}
}
};
};
let start = Instant::now();
let result = solver.resolve_dirty(design, previous, dirty);
let elapsed = start.elapsed();
match result {
Ok(new_solved) if elapsed <= budget => {
let planes = narrow_planes(design.planes_from_solved(&new_solved));
PreviewPlan {
planes,
solved: Some(new_solved),
freshness: Freshness::Fresh,
}
}
Ok(new_solved) => {
let planes = narrow_planes(design.planes_from_solved(previous));
PreviewPlan {
planes,
solved: Some(new_solved),
freshness: Freshness::Stale {
pending: dirty.clone(),
},
}
}
Err(err) => {
let planes = narrow_planes(design.planes_from_solved(previous));
PreviewPlan {
planes,
solved: None,
freshness: Freshness::Unsolvable(err),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix::geometry::meet_solver::{Block, classify_blocks, meet_tier_inputs_from_asc};
use indicatrix_cut_core::{ConstraintTier, PreformSpec, ScheduleMeta};
struct PanicSolver;
impl DirtySolver for PanicSolver {
fn resolve_dirty(
&self,
_design: &Design,
_previous: &[SolvedTier],
_dirty: &BTreeSet<usize>,
) -> Result<Vec<SolvedTier>, MissingAnchor> {
panic!("DirtySolver::resolve_dirty must not be called on this branch");
}
}
struct InstantSolver {
result: Vec<SolvedTier>,
}
impl DirtySolver for InstantSolver {
fn resolve_dirty(
&self,
_design: &Design,
_previous: &[SolvedTier],
_dirty: &BTreeSet<usize>,
) -> Result<Vec<SolvedTier>, MissingAnchor> {
Ok(self.result.clone())
}
}
struct SlowSolver {
result: Vec<SolvedTier>,
sleep: Duration,
}
impl DirtySolver for SlowSolver {
fn resolve_dirty(
&self,
_design: &Design,
_previous: &[SolvedTier],
_dirty: &BTreeSet<usize>,
) -> Result<Vec<SolvedTier>, MissingAnchor> {
std::thread::sleep(self.sleep);
Ok(self.result.clone())
}
}
fn tier(name: &str, angle_deg: f64, constraint: MeetConstraint) -> ConstraintTier {
ConstraintTier {
angle_deg,
name: name.to_string(),
indices: vec![0.0],
constraint,
imported_meet: None,
detached: Vec::new(),
}
}
fn pinned_design() -> Design {
Design::new(
PreformSpec::block(1.0, 1.0, 1.0),
ScheduleMeta {
gear_teeth: 96,
..ScheduleMeta::default()
},
vec![tier("Table", 0.0, MeetConstraint::ScaleReference(0.5))],
)
}
fn free_design() -> Design {
Design::new(
PreformSpec::block(2.0, 1.0, 2.0),
ScheduleMeta {
gear_teeth: 96,
..ScheduleMeta::default()
},
vec![
tier("C1", 30.0, MeetConstraint::ScaleReference(0.6)),
tier("C2", 40.0, MeetConstraint::MeetExisting),
],
)
}
#[test]
fn pinned_only_design_never_calls_the_solver() {
let design = pinned_design();
let plan = plan_preview(
&design,
None,
&BTreeSet::new(),
DEFAULT_PREVIEW_BUDGET,
&PanicSolver,
);
assert_eq!(plan.freshness, Freshness::Pinned);
assert_ne!(plan.planes, [] as [(Vec3, f32); 0]);
assert_eq!(plan.solved.map(|s| s.len()), Some(1));
}
#[test]
fn cheap_free_design_resolves_fresh_within_budget() {
let design = free_design();
let previous = design.solve().expect("fixture must solve");
let solver = InstantSolver {
result: previous.clone(),
};
let dirty = BTreeSet::from([1]);
let plan = plan_preview(
&design,
Some(&previous),
&dirty,
Duration::from_millis(50),
&solver,
);
assert_eq!(plan.freshness, Freshness::Fresh);
assert!(plan.solved.is_some());
assert_ne!(plan.planes, [] as [(Vec3, f32); 0]);
}
#[test]
fn over_budget_solver_reports_stale_with_the_edited_tier_pending() {
let design = free_design();
let previous = design.solve().expect("fixture must solve");
let solver = SlowSolver {
result: previous.clone(),
sleep: Duration::from_millis(40),
};
let dirty = BTreeSet::from([1]);
let budget = Duration::from_millis(5);
let plan = plan_preview(&design, Some(&previous), &dirty, budget, &solver);
match plan.freshness {
Freshness::Stale { pending } => assert_eq!(pending, dirty),
other => panic!("expected Stale, got {other:?}"),
}
assert!(
plan.solved.is_some(),
"the fresh (late) result must still be chained forward as the next \
call's last_solved, even though this frame reports Stale"
);
}
#[test]
fn misaligned_previous_falls_back_to_a_full_solve() {
let design = free_design();
let stale_previous: Vec<SolvedTier> = Vec::new();
let dirty = BTreeSet::from([1]);
let plan = plan_preview(
&design,
Some(&stale_previous),
&dirty,
DEFAULT_PREVIEW_BUDGET,
&PanicSolver,
);
assert_eq!(plan.freshness, Freshness::Fresh);
assert_eq!(plan.solved.map(|s| s.len()), Some(design.tiers.len()));
}
#[test]
fn no_previous_at_all_also_falls_back_to_a_full_solve() {
let design = free_design();
let plan = plan_preview(
&design,
None,
&BTreeSet::new(),
DEFAULT_PREVIEW_BUDGET,
&PanicSolver,
);
assert_eq!(plan.freshness, Freshness::Fresh);
assert!(plan.solved.is_some());
}
const CRACKOTTO_STEP_ASC: &str = include_str!(
"../../../../../crates/indicatrix-cut-core/src/optimize_cost_probe_crackotto_step.asc"
);
fn crackotto_step_design() -> Design {
let schedule =
indicatrix_formats::asc::parse_asc(CRACKOTTO_STEP_ASC).expect("fixture must parse");
let mut inputs = meet_tier_inputs_from_asc(&schedule);
let blocks = classify_blocks(&inputs);
for block in [Block::Crown, Block::Pavilion, Block::Girdle] {
let anchored = inputs.iter().zip(&blocks).any(|(t, &b)| {
b == block && matches!(t.constraint, MeetConstraint::ScaleReference(_))
});
if anchored {
continue;
}
if let Some(i) = (0..inputs.len()).find(|&i| blocks[i] == block) {
inputs[i].constraint = MeetConstraint::ScaleReference(schedule.tiers[i].mast);
}
}
let tiers = inputs
.into_iter()
.zip(&schedule.tiers)
.map(|(input, original)| ConstraintTier {
angle_deg: input.angle_deg,
name: original.name.clone(),
indices: input.indices,
constraint: input.constraint,
imported_meet: None,
detached: Vec::new(),
})
.collect();
Design::new(
PreformSpec::block(2.0, 1.0, 2.0),
ScheduleMeta {
gemcad_version: schedule.gemcad_version.clone(),
gear_teeth: schedule.gear_teeth,
gear_reference_angle: schedule.gear_reference_angle,
symmetry_order: schedule.symmetry_order,
mirror: schedule.mirror,
refractive_index: schedule.refractive_index,
headers: schedule.headers.clone(),
footnotes: schedule.footnotes,
},
tiers,
)
}
#[test]
#[ignore = "timing measurement, not a correctness check -- run with \
--release --ignored --nocapture"]
fn timing_resolve_dirty_on_crackotto_step_103_tier() {
let design = crackotto_step_design();
assert_eq!(
design.tiers.len(),
103,
"fixture must have its real tier count"
);
let baseline = design.solve().expect("must solve to get a starting point");
let mut edited = design;
edited.tiers[20].angle_deg += 0.5;
let dirty = BTreeSet::from([20]);
let start = Instant::now();
let result = edited
.resolve_dirty(&baseline, &dirty)
.expect("subgraph resolve");
let elapsed = start.elapsed();
assert_eq!(result.len(), 103);
println!(
"CrackOtto-Step (103 tiers): resolve_dirty for a single-tier edit: {elapsed:?} \
(DEFAULT_PREVIEW_BUDGET = {DEFAULT_PREVIEW_BUDGET:?})"
);
}
}