diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 53dcf5b47e..d7a421e74f 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -406,17 +406,37 @@ pub(crate) fn status_text(name: &str, ty: &str, error: Option<&str>, warning: Op /// it isn't built then. pub(crate) fn feature_status(app: &SolveApp, id: u64) -> Option { let index = app.session.doc.feature_index(id)?; + status_at(app, index, || app.session.model.result(id)) +} + +/// The mark of the feature at `index` in the history, given how to find its result. +fn status_at<'a>(app: &SolveApp, index: usize, result: impl FnOnce() -> Option<&'a solidcraft_engine::doc::FeatureResult>) -> Option { let feature = app.session.doc.features.get(index)?; if feature.suppressed || app.session.doc.marker.is_some_and(|marker| index >= marker) { return None; } - let result = app.session.model.result(id)?; + let result = result()?; if result.error.is_some() { Some(Status::Error) } else { result.warning.as_ref().map(|_| Status::Warning) } } /// The worst mark among `ids`, with the message of the first feature that has it. pub(crate) fn worst_status(app: &SolveApp, ids: &[u64]) -> (Option, Option) { - let worst = ids.iter().filter_map(|id| feature_status(app, *id).map(|status| (status, *id))).reduce(|a, b| if b.0 > a.0 { b } else { a }); + let mark = |status: Option, id: &u64| status.map(|status| (status, *id)); + // A long folder finds each feature's place and result in tables built once: a search per + // feature made a folded long history cost the square of its length every frame. A short + // one searches, which costs less than building the tables. + let marks: Vec<(Status, u64)> = if ids.len() < 16 { + ids.iter().filter_map(|id| mark(feature_status(app, *id), id)).collect() + } else { + let places: std::collections::HashMap = app.session.doc.features.iter().enumerate().rev().map(|(i, f)| (f.id, i)).collect(); + let results = result_index(&app.session.model.results); + let status = |id: &u64| { + let index = *places.get(id)?; + status_at(app, index, || results.get(id).and_then(|k| app.session.model.results.get(*k))) + }; + ids.iter().filter_map(|id| mark(status(id), id)).collect() + }; + let worst = marks.into_iter().reduce(|a, b| if b.0 > a.0 { b } else { a }); match worst { Some((status, id)) => (Some(status), feature_message(app, id)), None => (None, None), @@ -1873,6 +1893,58 @@ mod tests { assert_eq!(index.get(&twice[first].id), Some(&first), "the first result of an id wins, as in Model::result"); } + /// A folder's mark is the worst of its features' own, searched for a short folder and found + /// in tables for a long one: an error over warnings, the message of the first feature with + /// the worst, none for a feature suppressed or rolled back, and a feature listed twice in the + /// history found at its first place. + #[test] + fn a_folders_mark_is_the_worst_of_its_features_own() { + use crate::ui_kit::Status; + let mut h = two_boxes(false); + let third = + json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0], "operation": "new_body", "name": "Third", "body_name": "Three"}); + h.app.run("solid.box", third).unwrap(); + h.frames(2); + let ids: Vec = h.app.session.doc.features.iter().map(|f| f.id).collect(); + assert_eq!(ids.len(), 3); + let (a, b, c) = (ids[0], ids[1], ids[2]); + for (id, error, warning) in [(a, None, Some("a")), (b, Some("b"), None), (c, None, Some("c"))] { + let result = h.app.session.model.results.iter_mut().find(|r| r.id == id).unwrap(); + result.error = error.map(str::to_string); + result.warning = warning.map(str::to_string); + } + let own = |h: &Harness, ids: &[u64]| { + let worst = ids.iter().filter_map(|id| super::feature_status(&h.app, *id).map(|s| (s, *id))).reduce(|a, b| if b.0 > a.0 { b } else { a }); + (worst.map(|w| w.0), worst.and_then(|w| super::feature_message(&h.app, w.1))) + }; + // Every folder short enough to be searched, then the same repeated past the length that + // builds the tables. + let check = |h: &Harness, folder: &[u64], status: Option, of: Option, why: &str| { + for ids in [folder.to_vec(), folder.repeat(16)] { + let found = super::worst_status(&h.app, &ids); + assert_eq!(found, (status, of.and_then(|id| super::feature_message(&h.app, id))), "{why}, {} ids", ids.len()); + assert_eq!(found, own(h, &ids), "{why}, {} ids", ids.len()); + } + }; + check(&h, &ids, Some(Status::Error), Some(b), "the error outweighs the warnings"); + check(&h, &[a, c], Some(Status::Warning), Some(a), "the first warned feature's message"); + check(&h, &[c, a], Some(Status::Warning), Some(c), "the first in the folder, not in the history"); + check(&h, &[], None, None, "an empty folder"); + check(&h, &[u64::MAX], None, None, "an id outside the history"); + + let mut twice = h.app.session.doc.features[0].clone(); + twice.suppressed = true; + std::sync::Arc::make_mut(&mut h.app.session.doc).features.push(twice); + check(&h, &[a], Some(Status::Warning), Some(a), "a feature listed twice is found at its first place"); + std::sync::Arc::make_mut(&mut h.app.session.doc).features.pop(); + + std::sync::Arc::make_mut(&mut h.app.session.doc).marker = Some(1); + check(&h, &ids, Some(Status::Warning), Some(a), "the features after the marker have no mark"); + std::sync::Arc::make_mut(&mut h.app.session.doc).marker = None; + std::sync::Arc::make_mut(&mut h.app.session.doc).features[1].suppressed = true; + check(&h, &ids, Some(Status::Warning), Some(a), "a suppressed feature has none"); + } + #[test] fn the_feature_menu_copies_a_warning_message() { for left in [false, true] {