Files
OneCAD/crates/ui-egui/src/sketch_pattern_tree.rs
T

133 lines
6.7 KiB
Rust

//! Sketch patterns are children of their sketch, rather than separate timeline features.
use serde_json::json;
use solidcraft_engine::{Sel, sketch::SketchPatternKind};
use crate::{
SolveApp,
browser::{Row, draw_row},
};
pub(crate) fn has_patterns(app: &SolveApp, sketch: u64) -> bool {
app.session.doc.sketch(sketch).is_ok_and(|sk| !sk.patterns.is_empty())
}
/// Shared by the parts browser and the vertical feature history. The stored document is the
/// source, so rolled-back sketches retain their children without showing preview-only copies.
pub(crate) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, sketch: u64, depth: usize, scope: &str, occurrence: Option<(u64, &[u64])>) {
let patterns: Vec<_> = app.session.doc.sketch(sketch).map(|sk| sk.patterns.clone()).unwrap_or_default();
for pattern in patterns {
let (name, icon) = match pattern.kind {
SketchPatternKind::Rectangular { .. } => ("Rectangular Pattern", "sketch_rect_pattern"),
SketchPatternKind::Circular { .. } => ("Circular Pattern", "sketch_circ_pattern"),
};
let suffix = pattern.id.strip_prefix("pat").unwrap_or(&pattern.id);
let label = format!("{} {suffix}", crate::i18n::tr_label(name));
let active = app.session.active_sketch == Some(sketch);
let curves: Vec<_> = app
.session
.doc
.sketch(sketch)
.map(|sk| {
pattern
.entities
.iter()
.chain(pattern.instances.iter().flat_map(|i| &i.curves))
.map(|id| if sk.curve_index(id).is_some() { Sel::SketchCurve { id: id.clone() } } else { Sel::SketchPoint { id: id.clone() } })
.collect()
})
.unwrap_or_default();
let selected = active && !curves.is_empty() && curves.iter().all(|curve| app.session.selection.contains(curve));
let response = draw_row(
ui,
ui.id().with(("sketch_pattern", sketch, &pattern.id)),
&Row { depth, icon, label: &label, name: true, selected, ..Default::default() },
);
if let Some(rect) = response.rect {
crate::scenario::publish_handle(&format!("{scope}:sketch-pattern:{sketch}:{}", pattern.id), rect.center());
}
if response.double {
if let Some((component, path)) = occurrence {
let _ = app.run("component.activate", json!({"component":component,"occurrence":path.last(),"path":path}));
}
app.edit_sketch(sketch);
if app.session.active_sketch == Some(sketch) {
crate::sketch_modify_ui::edit_pattern(app, &pattern.id);
}
} else if response.clicked {
let items = if active { curves } else { vec![Sel::Feature { id: sketch }] };
let _ = app.run("select.set", json!({"items":items}));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{dialogs::Kind, scenario::Harness, sketch_modify_ui::Mod};
fn harness(left_history: bool) -> (Harness, u64, String, String) {
let mut h = Harness::new();
h.app.home.open = false;
h.app.ui.show_browser = true;
h.app.preferences.timeline_left = left_history;
h.app.run("sketch.create", json!({"plane":"XY","name":"Patterned Sketch"})).unwrap();
let sketch = h.app.session.active_sketch.unwrap();
h.app.run("sketch.line", json!({"points":[[10,5],[15,5]],"ids":["source"]})).unwrap();
let rectangular = h
.app
.run("sketch.pattern.rectangular", json!({"entities":["source"],"count":4,"spacing":20,"alignment":"center","reversed":true}))
.unwrap();
let circular = h.app.run("sketch.pattern.circular", json!({"entities":["source"],"center":"origin","count":3})).unwrap();
h.app.finish_sketch();
h.frames(6);
(h, sketch, rectangular["pattern"].as_str().unwrap().into(), circular["pattern"].as_str().unwrap().into())
}
#[test]
fn browser_patterns_fold_with_the_sketch_and_double_click_opens_the_saved_editor() {
let (mut h, sketch, rectangular, circular) = harness(false);
let handle = format!("browser:sketch-pattern:{sketch}:{rectangular}");
let child = crate::scenario::handle_at(&handle).unwrap();
let parent = crate::scenario::handle_at("row:Patterned Sketch").unwrap();
assert!(child.y > parent.y, "patterns belong below their sketch");
assert!(crate::scenario::handle_at(&format!("browser:sketch-pattern:{sketch}:{circular}")).is_some());
h.step(&json!({"click":{"handle":"fold:Patterned Sketch"}})).unwrap();
h.frames(3);
assert!(crate::scenario::handle_at(&handle).is_none());
h.step(&json!({"click":{"handle":"fold:Patterned Sketch"}})).unwrap();
// Let the fold clicks leave egui's triple-click interval before double-clicking a child.
h.frames(40);
h.step(&json!({"click":{"handle":handle},"double":true})).unwrap();
h.frames(3);
assert_eq!(h.app.session.active_sketch, Some(sketch), "selection={:?}, status={:?}", h.app.session.selection, h.app.status);
let dialog = h.app.dialog.as_ref().unwrap();
assert_eq!(dialog.extra["pattern"], rectangular);
assert!(matches!(&dialog.kind, Kind::SketchModify(Mod::PatternRect { count, reversed: true, .. }) if count == "4"));
}
#[test]
fn left_history_lists_both_pattern_kinds_and_tracks_deletion_and_undo() {
let (mut h, sketch, rectangular, circular) = harness(true);
let handle = format!("timeline:sketch-pattern:{sketch}:{circular}");
assert!(crate::scenario::handle_at(&format!("timeline:sketch-pattern:{sketch}:{rectangular}")).is_some());
assert!(crate::scenario::handle_at(&handle).is_some());
h.step(&json!({"click":{"handle":"timeline-sketch-fold:Patterned Sketch"}})).unwrap();
h.frames(3);
assert!(crate::scenario::handle_at(&handle).is_none());
h.step(&json!({"click":{"handle":"timeline-sketch-fold:Patterned Sketch"}})).unwrap();
h.frames(40);
h.step(&json!({"click":{"handle":handle},"double":true})).unwrap();
h.frames(3);
assert!(matches!(h.app.dialog.as_ref().unwrap().kind, Kind::SketchModify(Mod::PatternCirc { .. })));
assert_eq!(h.app.dialog.as_ref().unwrap().extra["pattern"], circular);
h.step(&json!({"key":"Escape"})).unwrap();
h.app.run("sketch.delete", json!({"entities":["source"]})).unwrap();
h.frames(3);
assert!(crate::scenario::handle_at(&handle).is_none());
h.app.run("edit.undo", json!({})).unwrap();
h.frames(3);
assert!(crate::scenario::handle_at(&handle).is_some());
}
}