diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 08839a89..38437508 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -257,7 +257,8 @@ fn fits(a: Accept, s: &Sel) -> bool { Sel::Axis { .. } => a & AXES != 0, Sel::SketchCurve { .. } => a & (AXES | CURVES) != 0, Sel::Vertex { .. } => a & selection::VERTICES != 0, - Sel::Feature { .. } | Sel::SketchPoint { .. } | Sel::SketchConstraint { .. } => false, + Sel::Feature { .. } => a & selection::FEATURES != 0, + Sel::SketchPoint { .. } | Sel::SketchConstraint { .. } => false, } } @@ -408,6 +409,14 @@ impl Dialog { Dialog::new(kind, inputs) } }; + // Patterns of features picked in the timeline: the objects are features. + if is_pattern(&d.kind) + && s.selection.iter().any(|x| matches!(x, Sel::Feature { .. })) + && let Some(inp) = d.inputs.first_mut() + { + inp.accept = selection::FEATURES; + inp.label = objects_label(inp.accept); + } // Pre-selection: what is selected now becomes the input (first input that takes it); a // face or edge stands for its body where bodies are wanted. for sel in &s.selection { @@ -780,8 +789,34 @@ pub(crate) fn row_label(ui: &mut egui::Ui, text: &str) { }); } -/// Rows above the selection inputs: the pattern type, the Hole placement (one face point, or -/// sketch points). +/// The pattern objects' label: features are picked in the timeline. +fn objects_label(accept: Accept) -> &'static str { + if accept == selection::FEATURES { "Features" } else { "Objects" } +} + +fn is_pattern(k: &Kind) -> bool { + matches!(k, Kind::PatternRect { .. } | Kind::PatternCirc { .. } | Kind::PathPattern { .. }) +} + +/// The Object Type row of a pattern: bodies (picked in the view) or features (picked in the +/// timeline, e.g. a hole). +fn object_type_row(d: &mut Dialog, ui: &mut egui::Ui) { + let Some(inp) = d.inputs.first_mut() else { return }; + row_label(ui, "Object Type"); + let cur = usize::from(inp.accept == selection::FEATURES); + let mut ty = cur; + combo(ui, "pat_obj", &["Bodies", "Features"], &mut ty); + ui.end_row(); + if ty != cur { + inp.accept = if ty == 1 { selection::FEATURES } else { BODIES }; + inp.label = objects_label(inp.accept); + inp.items.clear(); + d.active = 0; + } +} + +/// Rows above the selection inputs: the pattern type and object type, the Hole placement (one +/// face point, or sketch points). fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) { let current = match d.kind { Kind::PatternRect { .. } => Some(0), @@ -811,14 +846,18 @@ fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) { vec![SelInput::new("Objects", BODIES, true), SelInput::new("Path", CURVES, true)], ), }; + let accept = d.inputs.first().map(|i| i.accept).unwrap_or(BODIES); if let Some(i) = inputs.first_mut() { i.items = objects; + i.accept = accept; + i.label = objects_label(accept); } d.kind = kind; d.inputs = inputs; d.extra.clear(); d.advance(); } + object_type_row(d, ui); return; } let Kind::Hole { opts, .. } = &mut d.kind else { return }; @@ -1699,7 +1738,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S (None, Some(k)) => k, (None, None) => return Err("select a direction (an axis or a sketch line) first".into()), }; - let mut p = json!({"features": source_features(s, &body_names(0)), "dir1": pt(d1), "count1": count, "spacing1": spacing}); + let mut p = json!({"features": pattern_features(s, sels(d, 0)), "dir1": pt(d1), "count1": count, "spacing1": spacing}); if let Some((_, d2)) = sels(d, 2).first().and_then(|x| axis_of(app, x)) { p["dir2"] = pt(d2); p["count2"] = json!(count2); @@ -1718,7 +1757,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S } None => return Err("select an axis".into()), }; - ("PatternCircular", json!({"features": source_features(s, &body_names(0)), "axis": axis, "count": count, "angle": angle})) + ("PatternCircular", json!({"features": pattern_features(s, sels(d, 0)), "axis": axis, "count": count, "angle": angle})) } Kind::Loft { operation } => { need(0, "profiles of two or more sketches")?; @@ -1822,7 +1861,13 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S need(1, "the path")?; let path = curve_ids(d, 1); let sketch = curves_sketch(app, &path, None).ok_or("the path curves must be in one sketch")?; - ("PatternOnPath", json!({"bodies": body_names(0), "path_sketch": sketch, "path": path, "count": count, "spacing": spacing})) + let mut p = json!({"path_sketch": sketch, "path": path, "count": count, "spacing": spacing}); + if sels(d, 0).iter().any(|x| matches!(x, Sel::Feature { .. })) { + p["features"] = json!(pattern_features(s, sels(d, 0))); + } else { + p["bodies"] = json!(body_names(0)); + } + ("PatternOnPath", p) } Kind::Pipe { diameter, wall } => { need(0, "the path")?; @@ -1999,6 +2044,27 @@ fn curve_ids(d: &Dialog, i: usize) -> Vec { } /// The features that made these bodies (what patterns and mirrors copy). +/// The features a pattern repeats: picked features as they are, picked bodies by the features +/// that made them. +fn pattern_features(s: &Session, objects: &[Sel]) -> Vec { + let mut out: Vec = Vec::new(); + let bodies: Vec = objects.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect(); + for x in objects { + if let Sel::Feature { id } = x + && let Some(f) = s.doc.feature(*id) + && !out.contains(&f.name) + { + out.push(f.name.clone()); + } + } + for f in source_features(s, &bodies) { + if !out.contains(&f) { + out.push(f); + } + } + out +} + fn source_features(s: &Session, bodies: &[String]) -> Vec { let st = s.model.state(); let mut features: Vec = Vec::new(); @@ -2381,8 +2447,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option return None, }; + // Features that made no body of their own (a hole, a fillet) were picked as features. + let as_features = ids.iter().any(|id| !st.bodies.iter().any(|b| b.feature == *id)); if let Some(inp) = d.inputs.first_mut() { - inp.items = bodies; + if as_features { + inp.accept = selection::FEATURES; + inp.label = objects_label(inp.accept); + inp.items = ids.iter().map(|id| Sel::Feature { id: *id }).collect(); + } else { + inp.items = bodies; + } } d } diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index b94ec0e7..5bb29352 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -131,3 +131,34 @@ fn hole_at_sketch_points() { let r = app.session.execute(&cmds[0].0, &cmds[0].1); assert!(r.is_ok(), "{r:?}"); } + +#[test] +fn a_hole_is_patterned_as_a_feature() { + let mut app = app_with(json!([ + {"command": "PrimitiveBox", "params": {"length": 60, "width": 40, "height": 10}}, + {"command": "FusionHoleCommand", "params": {"position": [10, 10, 10], "diameter": 5}} + ])); + let hole = app.session.doc.features.last().unwrap().id; + // Picked in the timeline before the command: the objects become features. + let mut d = start(&mut app, vec![Sel::Feature { id: hole }], "PatternRectangular"); + assert_eq!(d.inputs[0].accept, crate::selection::FEATURES); + assert_eq!(d.inputs[0].items, vec![Sel::Feature { id: hole }]); + d.inputs[1].items = vec![Sel::Axis { name: "X".into() }]; + if let Kind::PatternRect { count, spacing, .. } = &mut d.kind { + *count = "4".into(); + *spacing = "12 mm".into(); + } + let cmds = apply_commands(&app, &d).unwrap(); + assert_eq!(cmds[0].1["features"], json!(["Hole1"])); + let before = app.session.model.state().bodies[0].mesh().measure().volume; + for (id, p) in cmds { + app.session.execute(&id, &p).unwrap(); + } + let after = app.session.model.state().bodies[0].mesh().measure().volume; + assert!(after < before - 500.0, "three more holes: {before} -> {after}"); + // Edit Feature shows the hole as a feature again. + let pat = app.session.doc.features.last().unwrap().id; + let e = crate::dialogs::for_feature(&app, pat, None).unwrap(); + assert_eq!(e.inputs[0].accept, crate::selection::FEATURES); + assert_eq!(e.inputs[0].items, vec![Sel::Feature { id: hole }]); +} diff --git a/crates/ui-egui/src/selection.rs b/crates/ui-egui/src/selection.rs index 863c6576..413f660d 100644 --- a/crates/ui-egui/src/selection.rs +++ b/crates/ui-egui/src/selection.rs @@ -25,6 +25,8 @@ pub const VERTICES: Accept = 128; pub const CURVES: Accept = 256; /// Sketch points of any visible sketch (hole centres); picked as ":". pub const POINTS: Accept = 512; +/// Timeline features (pattern objects), picked in the timeline. +pub const FEATURES: Accept = 1024; /// One selection input of a command dialog ("Profiles", "Edges", "Plane", ...). #[derive(Clone, Debug, PartialEq)] diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 7953fa56..4fc1a659 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -138,7 +138,8 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { let err = res.and_then(|r| r.error.clone()); let warn = res.and_then(|r| r.warning.clone()); let rolled = i >= marker; - let sel = app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Feature { id: f } if f == id)); + let is_it = |s: &solvecraft_engine::Sel| matches!(s, solvecraft_engine::Sel::Feature { id: f } if f == id); + let sel = app.session.selection.iter().any(is_it) || app.dialog.as_ref().is_some_and(|d| d.items().iter().any(is_it)); if resp.hovered() { hovered = Some(*id); } @@ -183,7 +184,16 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { } let resp = resp.on_hover_text(tip); if resp.clicked() { - let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})); + // A dialog input that takes features (pattern objects) gets the click. + let takes = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::FEATURES != 0); + if takes { + if let Some(mut d) = app.dialog.take() { + d.pick(&app.session, solvecraft_engine::Sel::Feature { id: *id }); + app.dialog = Some(d); + } + } else { + let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})); + } } if resp.double_clicked() { edit = Some(*id);