use super::{
dto::{DesignShape, SavedDesignDto},
format::design_shape,
};
use crate::gui::rough_plan::run::DesignStatus;
use indicatrix_vault::{
db::sqlite::Database,
model::solid_extents::{SOLID_EXTENTS_VERSION, StoredSolidExtents},
};
use std::{
collections::{BTreeMap, BTreeSet},
sync::{Mutex, PoisonError},
};
use tracing::warn;
const FINGERPRINT_TOLERANCE: f64 = 1e-6;
const LISTED_NAMES: usize = 5;
pub struct Staleness {
pub statuses: BTreeMap<i64, DesignStatus>,
pub warnings: Vec<String>,
}
pub trait LibraryView {
fn titles_of(&self, ids: &[i64]) -> Result<BTreeMap<i64, String>, String>;
fn extents_of(&self, ids: &[i64]) -> Result<BTreeMap<i64, StoredSolidExtents>, String>;
fn ids_titled(&self, titles: &[String]) -> Result<BTreeMap<String, Vec<i64>>, String>;
fn stamp(&self) -> Option<u32>;
}
impl LibraryView for Mutex<Database> {
fn titles_of(&self, ids: &[i64]) -> Result<BTreeMap<i64, String>, String> {
self.lock()
.unwrap_or_else(PoisonError::into_inner)
.entry_titles_for(ids)
.map_err(|e| format!("{e:#}"))
}
fn extents_of(&self, ids: &[i64]) -> Result<BTreeMap<i64, StoredSolidExtents>, String> {
self.lock()
.unwrap_or_else(PoisonError::into_inner)
.solid_extents_for(ids)
.map_err(|e| format!("{e:#}"))
}
fn ids_titled(&self, titles: &[String]) -> Result<BTreeMap<String, Vec<i64>>, String> {
self.lock()
.unwrap_or_else(PoisonError::into_inner)
.entry_ids_titled(titles)
.map_err(|e| format!("{e:#}"))
}
fn stamp(&self) -> Option<u32> {
self.lock()
.unwrap_or_else(PoisonError::into_inner)
.library_stamp()
.inspect_err(|e| warn!("Rough planner: could not read the library stamp: {e}"))
.ok()
}
}
struct TitleCandidate {
entry_id: i64,
shape: DesignShape,
}
struct Namesakes {
candidates: Vec<TitleCandidate>,
}
fn is_unknown(fingerprint: [f64; 3]) -> bool {
fingerprint
.iter()
.all(|component| component.abs() < f64::MIN_POSITIVE)
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() / a.abs().max(b.abs()).max(1e-12) <= FINGERPRINT_TOLERANCE
}
#[must_use]
pub fn fingerprints_match(a: [f64; 3], b: [f64; 3]) -> bool {
a.iter().zip(b).all(|(&left, right)| close(left, right))
}
#[must_use]
pub fn shapes_match(saved: &DesignShape, current: &DesignShape) -> bool {
fingerprints_match(saved.fingerprint, current.fingerprint)
&& match (saved.width_caliper, current.width_caliper) {
(Some(left), Some(right)) => close(left, right),
_ => true,
}
}
const fn comparable(saved: &DesignShape) -> bool {
saved.extents_version == 0 || saved.extents_version == SOLID_EXTENTS_VERSION
}
fn usable(saved: &DesignShape) -> bool {
comparable(saved) && !is_unknown(saved.fingerprint)
}
#[must_use]
pub fn status_for_existing(saved: &DesignShape, current: Option<&DesignShape>) -> DesignStatus {
match current {
Some(current) if usable(saved) && !shapes_match(saved, current) => DesignStatus::Changed,
_ => DesignStatus::Unchanged,
}
}
fn matching_ids(candidates: &[TitleCandidate], saved: &DesignShape) -> Vec<i64> {
if !usable(saved) {
return Vec::new();
}
candidates
.iter()
.filter(|candidate| shapes_match(saved, &candidate.shape))
.map(|candidate| candidate.entry_id)
.collect()
}
#[cfg(test)]
fn unique_match(candidates: &[TitleCandidate], saved: &DesignShape) -> Option<i64> {
match matching_ids(candidates, saved).as_slice() {
[only] => Some(*only),
_ => None,
}
}
fn title_key(title: &str) -> String {
title.trim().to_ascii_lowercase()
}
fn is_fallback_title(design: &SavedDesignDto) -> bool {
design.title == format!("Design {}", design.entry_id)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Binding {
Id,
Title { colliding: bool },
}
fn binding_of(
design: &SavedDesignDto,
local_title: Option<&String>,
same_library: bool,
) -> Binding {
match local_title {
None => Binding::Title { colliding: false },
Some(local)
if same_library
|| is_fallback_title(design)
|| title_key(local) == title_key(&design.title) =>
{
Binding::Id
}
Some(_) => Binding::Title { colliding: true },
}
}
fn status_by_title(
design: &SavedDesignDto,
found: Option<&Namesakes>,
colliding: bool,
local: Option<&DesignShape>,
) -> DesignStatus {
let saved = design.shape();
let candidates = found.map_or(&[][..], |found| found.candidates.as_slice());
match matching_ids(candidates, &saved).as_slice() {
[resolved_entry_id] => DesignStatus::MatchedByTitle {
resolved_entry_id: *resolved_entry_id,
},
[] if colliding && usable(&saved) && local.is_some_and(|l| shapes_match(&saved, l)) => {
DesignStatus::Unchanged
}
[] if !colliding && usable(&saved) && !candidates.is_empty() => DesignStatus::Changed,
_ => DesignStatus::Deleted,
}
}
#[derive(Default)]
struct Notes {
unverified: Vec<String>,
incomparable: Vec<String>,
search: Option<String>,
}
fn name_list(titles: &[String]) -> String {
let shown: Vec<String> = titles
.iter()
.take(LISTED_NAMES)
.map(|title| format!("\"{title}\""))
.collect();
match titles.len().saturating_sub(LISTED_NAMES) {
0 => shown.join(", "),
more => format!("{} and {more} more", shown.join(", ")),
}
}
impl Notes {
fn into_warnings(self) -> Vec<String> {
let mut lines = Vec::new();
lines.extend(self.search);
if !self.unverified.is_empty() {
let (count, names) = (self.unverified.len(), name_list(&self.unverified));
lines.push(if count == 1 {
format!(
"The design {names} has not been re-measured in this library, so its shape could not be checked against the saved plan. Running the plan re-measures it."
)
} else {
format!(
"{count} designs have not been re-measured in this library, so their shapes could not be checked against the saved plan: {names}. Running the plan re-measures them."
)
});
}
if !self.incomparable.is_empty() {
lines.push(format!(
"The plan measured {} with an earlier version of the measuring rule, so {} could not be compared with the library now.",
name_list(&self.incomparable),
if self.incomparable.len() == 1 { "it" } else { "they" }
));
}
lines
}
}
fn status_by_id(
design: &SavedDesignDto,
current: Option<&DesignShape>,
notes: &mut Notes,
) -> DesignStatus {
let saved = design.shape();
if current.is_none() {
notes.unverified.push(design.title.clone());
} else if !comparable(&saved) && !is_unknown(saved.fingerprint) {
notes.incomparable.push(design.title.clone());
}
status_for_existing(&saved, current)
}
fn find_namesakes(
library: &impl LibraryView,
wanted: &[&SavedDesignDto],
notes: &mut Notes,
) -> Option<BTreeMap<String, Namesakes>> {
if wanted.is_empty() {
return None;
}
let titles: Vec<String> = wanted
.iter()
.map(|design| title_key(&design.title))
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let searched = library.ids_titled(&titles).and_then(|by_title| {
let ids: Vec<i64> = by_title.values().flatten().copied().collect();
library.extents_of(&ids).map(|stored| (by_title, stored))
});
let (by_title, stored) = match searched {
Ok(found) => found,
Err(error) => {
warn!("Rough planner: title search for saved designs failed: {error}");
notes.search = Some(format!(
"Could not search the library for the titles of {} design{}: {error}",
wanted.len(),
if wanted.len() == 1 { "" } else { "s" }
));
return None;
}
};
let namesakes = by_title
.into_iter()
.map(|(title, ids)| {
let candidates = ids
.iter()
.filter_map(|&entry_id| {
let extents = stored.get(&entry_id)?.extents?;
Some(TitleCandidate {
entry_id,
shape: design_shape(&extents),
})
})
.collect();
(title, Namesakes { candidates })
})
.collect();
Some(namesakes)
}
pub fn resolve_designs(
library: &impl LibraryView,
designs: &[SavedDesignDto],
plan_library: Option<u32>,
) -> Result<Staleness, String> {
let ids: Vec<i64> = designs.iter().map(|design| design.entry_id).collect();
let titles = library
.titles_of(&ids)
.map_err(|e| format!("Could not read the library: {e}"))?;
let present: Vec<i64> = titles.keys().copied().collect();
let stored = library
.extents_of(&present)
.map_err(|e| format!("Could not read the design measurements: {e}"))?;
let same_library = plan_library.is_some() && plan_library == library.stamp();
let bindings: Vec<Binding> = designs
.iter()
.map(|design| binding_of(design, titles.get(&design.entry_id), same_library))
.collect();
let mut notes = Notes::default();
let by_title: Vec<&SavedDesignDto> = designs
.iter()
.zip(&bindings)
.filter(|(_, binding)| matches!(binding, Binding::Title { .. }))
.map(|(design, _)| design)
.collect();
let namesakes = find_namesakes(library, &by_title, &mut notes);
let mut statuses = BTreeMap::new();
for (design, binding) in designs.iter().zip(&bindings) {
let current = stored
.get(&design.entry_id)
.and_then(|row| row.extents)
.map(|extents| design_shape(&extents));
let status = match *binding {
Binding::Id => status_by_id(design, current.as_ref(), &mut notes),
Binding::Title { colliding } => status_by_title(
design,
namesakes
.as_ref()
.and_then(|found| found.get(&title_key(&design.title))),
colliding,
current.as_ref(),
),
};
statuses.insert(design.entry_id, status);
}
Ok(Staleness {
statuses,
warnings: notes.into_warnings(),
})
}
const fn severity(status: &DesignStatus) -> u8 {
match status {
DesignStatus::Unchanged => 0,
DesignStatus::MatchedByTitle { .. } => 1,
DesignStatus::Changed => 2,
DesignStatus::Deleted => 3,
}
}
#[must_use]
pub fn rekey_statuses(
statuses: BTreeMap<i64, DesignStatus>,
remap: &BTreeMap<i64, i64>,
) -> BTreeMap<i64, DesignStatus> {
let mut out: BTreeMap<i64, DesignStatus> = BTreeMap::new();
for (id, status) in statuses {
let key = remap.get(&id).copied().unwrap_or(id);
match out.get(&key) {
Some(kept) if severity(kept) >= severity(&status) => {}
_ => {
out.insert(key, status);
}
}
}
out
}
#[must_use]
pub fn remap_of(statuses: &BTreeMap<i64, DesignStatus>) -> BTreeMap<i64, i64> {
statuses
.iter()
.filter_map(|(&id, status)| match status {
DesignStatus::MatchedByTitle { resolved_entry_id } => Some((id, *resolved_entry_id)),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests;