From eb009d1bd6e010184d711b814dc00ee2816d53c8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:58:17 +0000 Subject: [PATCH] fix: give Find Similar an explicit empty-result filter state and clamp similarity Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/catalog/src/query.rs | 7 +++++++ crates/engine/src/cmd/cull.rs | 8 +++----- crates/engine/src/tests.rs | 27 +++++++++++++++++++++++++++ crates/engine/src/view.rs | 5 +++-- crates/ui-egui/src/panels/chips.rs | 2 +- 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index d1548f8bd..fae34ccb7 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -29,6 +29,10 @@ pub struct Filter { /// Only these photos (Find Similar results…); empty = no constraint. #[serde(skip_serializing_if = "Vec::is_empty")] pub only: Vec, + /// Show nothing: an explicit empty result (Find Similar with no matches), since an empty + /// `only` means "no constraint". + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub only_none: bool, /// Any of these labels (the filter bar's multi-select); empty = no constraint. #[serde(skip_serializing_if = "Vec::is_empty")] pub labels: Vec, @@ -325,6 +329,9 @@ impl Filter { if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) { return false; } + if self.only_none { + return false; + } if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) { return false; } diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index c5d9a1854..27e7a4fa1 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -106,17 +106,15 @@ fn find_similar(s: &mut Session, p: &Value) -> Result { if let Some(v) = sig(s, q) { let sim = lightcraft_pipeline::cull::similarity(&me, &v); if sim >= min { - hits.push((q, sim)); + hits.push((q, sim.clamp(0.0, 1.0))); } } } hits.sort_by(|a, b| b.1.total_cmp(&a.1)); if p.get("filter").and_then(Value::as_bool).unwrap_or(true) { s.filter.only = hits.iter().map(|h| h.0).collect(); - if s.filter.only.is_empty() { - // an empty `only` means "no constraint": use an id no photo has so no hits show no photos - s.filter.only = vec![PhotoId(u64::MAX)]; - } + // an empty `only` means "no constraint": no hits is its own state that shows no photos + s.filter.only_none = s.filter.only.is_empty(); } Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::>()})) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f4ac5ae5f..e59c21a11 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1112,6 +1112,33 @@ fn find_similar_with_no_hits_shows_no_photos() { assert_eq!(s.visible_cloned().len(), s.catalog.len(), "Clear Filters brings everything back"); } +#[test] +fn find_similar_of_a_flat_photo_shows_an_empty_view_and_counts_zero() { + let mut s = demo(); + let photo = s.catalog.photos().next().unwrap().clone(); + // a flat photo's signature is all zeros: it is not similar even to itself + s.signatures.insert(crate::media::content_key(&photo), [0.0; 64]); + let r = s.execute("library.findSimilar", &json!({"id": photo.id.0})).unwrap(); + assert_eq!(r["photos"].as_array().map(Vec::len), Some(0)); + assert_eq!(s.visible_cloned().len(), 0, "no matches show nothing, not the whole library"); + assert_eq!(s.catalog.count(&s.filter), 0); + let chips = crate::filter_chips(&s.filter, &s.catalog); + assert_eq!(chips.iter().map(|c| c.label.as_str()).collect::>(), ["Only 0 photos"]); + s.execute("library.filter", &chips[0].clear).unwrap(); + assert_eq!(s.filter, Default::default(), "the chip clears the empty state"); + assert_eq!(s.visible_cloned().len(), s.catalog.len()); +} + +#[test] +fn find_similar_values_stay_within_zero_and_one() { + let mut s = demo(); + let id = s.catalog.photos().next().unwrap().id; + let r = s.execute("library.findSimilar", &json!({"id": id.0, "similarity": -1.0})).unwrap(); + let sims: Vec = r["photos"].as_array().unwrap().iter().map(|h| h["similarity"].as_f64().unwrap()).collect(); + assert_eq!(sims.len(), s.catalog.len()); + assert!(sims.iter().all(|v| (0.0..=1.0).contains(v)), "{sims:?}"); +} + #[test] fn quick_develop_adds_to_each_photo() { use lightcraft_catalog::PhotoId; diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 5b32999bb..0d5cde3cf 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -214,8 +214,9 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec { if let Some(rs) = f.rule_set.as_ref().filter(|r| !r.rules.is_empty()) { add(format!("Rules: {}", rs.describe_with(cat)), json!({"ruleSet": Null})); } - if !f.only.is_empty() { - add(format!("Only {} photos", f.only.len()), json!({"only": []})); + if f.only_none || !f.only.is_empty() { + let n = if f.only_none { 0 } else { f.only.len() }; + add(format!("Only {n} photos"), json!({"only": [], "onlyNone": false})); } v } diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index 30f03971b..3a8f0ce8e 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -55,7 +55,7 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt }; } if chip.clear.get("only").is_some() { - return format!("{} {}", tr("Only"), crate::i18n::tr_format!("{n} photos", n = filter.only.len())); + return format!("{} {}", tr("Only"), crate::i18n::tr_format!("{n} photos", n = if filter.only_none { 0 } else { filter.only.len() })); } if let Some((prefix, value)) = chip.label.split_once(": ") { let value = if matches!(prefix, "Flag" | "Type") {