diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8372ea1907..34404ac3e7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -77,6 +77,9 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { (ss.plane.to_world(ss.sketch.point(ss.sketch.resolve_point(point)?)?), None) } Sel::Body { name } => (st.body(name)?.mesh().bounds().center(), None), + // A feature picked as a whole (a pattern or mirror of features): the middle of what it + // made, as a body sits at the middle of its box. + Sel::Feature { id } => (crate::viewport::feature_bounds(app, *id)?.center(), None), Sel::Plane { name } => { let pl = solidcraft_engine::geom::Plane::named(name).or_else(|| { solidcraft_engine::view::construction_planes(&app.session).into_iter().find(|(_, n, _)| n == name).map(|(_, _, pl)| pl) diff --git a/crates/ui-egui/src/pattern_arrow_tests.rs b/crates/ui-egui/src/pattern_arrow_tests.rs index 68d7a11ee2..135823a0db 100644 --- a/crates/ui-egui/src/pattern_arrow_tests.rs +++ b/crates/ui-egui/src/pattern_arrow_tests.rs @@ -87,3 +87,32 @@ fn a_solid_pattern_shows_the_second_arrow_once_it_has_a_second_direction() { assert_eq!(after, spacing); assert_ne!(after2, spacing2); } + +#[test] +fn a_pattern_of_features_puts_its_arrow_on_the_patterned_feature() { + let mut h = Harness::new(); + h.run(&json!([ + {"call": "engine.execute", "params": {"command": "solid.box", "params": {"corner": [0, 0, 0], "length": 60, "width": 20, "height": 5, "name": "Plate"}}}, + {"call": "engine.execute", "params": {"command": "solid.box", "params": {"corner": [8, 6, 5], "length": 8, "width": 8, "height": 6, "operation": "join", "name": "Boss"}}}, + {"call": "engine.execute", "params": {"command": "solid.pattern.rectangular", "params": {"features": ["Boss"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 20, "name": "Pattern"}}}, + {"call": "ui.view", "params": {"view": "iso"}}, + {"call": "ui.editFeature", "params": {"feature": "Pattern"}} + ])) + .unwrap(); + h.settle(); + let d = h.app.dialog.as_ref().expect("the pattern dialog"); + assert!(matches!(d.inputs.first().and_then(|i| i.items.first()), Some(solidcraft_engine::Sel::Feature { .. })), "{:?}", d.inputs); + // The arrow starts at the middle of the boss and points along X, the pattern's direction. + let (base, dir) = crate::canvas::anchor(&h.app, d).expect("the pattern has an anchor"); + assert!(base.dist(solidcraft_engine::geom::Vec3::new(12.0, 10.0, 8.0)) < 1e-6, "{base:?}"); + assert!(dir.is_some_and(|v| v.dist(solidcraft_engine::geom::Vec3::new(1.0, 0.0, 0.0)) < 1e-9), "{dir:?}"); + let Some(tip) = handle_at("arrow") else { panic!("no arrow on a pattern of features") }; + let from = h.call("ui.worldToScreen", json!({"points": [[12, 10, 8], [32, 10, 8]]}))["result"].clone(); + let at = |i: usize| egui::pos2(from[i][0].as_f64().unwrap() as f32, from[i][1].as_f64().unwrap() as f32); + let (b, x) = (at(0), at(1)); + // The tip lies off the base, on the screen line of the boss's +X direction. + let (ux, uy) = (x.x - b.x, x.y - b.y); + let (vx, vy) = (tip.x - b.x, tip.y - b.y); + assert!(vx * ux + vy * uy > 0.0, "tip {tip:?} base {b:?}"); + assert!((vx * uy - vy * ux).abs() / (ux * ux + uy * uy).sqrt() < 3.0, "tip {tip:?} off the X line through {b:?}"); +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index e7ccdaf4e6..d24c4abbb6 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1801,6 +1801,12 @@ pub fn section_hatch(app: &SolveApp) -> bool { /// Do not expand these faces into surface patches: neighbouring faces can belong to another /// feature even when they share a surface. fn feature_geometry(app: &SolveApp, id: u64) -> Vec { + feature_parts(app, id, true) +} + +/// A feature's geometry as [`feature_geometry`] finds it; `shown_only` leaves out hidden bodies +/// and bodies a dialog's preview stands in for. +fn feature_parts(app: &SolveApp, id: u64, shown_only: bool) -> Vec { if !app.session.model.result(id).is_some_and(|r| !r.skipped && r.error.is_none()) { return Vec::new(); } @@ -1808,7 +1814,7 @@ fn feature_geometry(app: &SolveApp, id: u64) -> Vec { let prefix = format!("F{id}:"); let mut out = Vec::new(); for b in &st.bodies { - if !app.session.body_visible(&b.name) || app.preview.effective_replaced().contains(&b.name) { + if shown_only && (!app.session.body_visible(&b.name) || app.preview.effective_replaced().contains(&b.name)) { continue; } if b.feature == id { @@ -1849,9 +1855,20 @@ fn displayed_selection(app: &SolveApp, item: &Sel) -> Vec { /// The box around what is selected (bodies, faces, edges, vertices, sketch curves and the /// bodies of selected features), where it is shown. pub fn selection_bounds(app: &SolveApp) -> Option { + bounds_of(app, app.session.selection.iter().flat_map(|item| displayed_selection(app, item))) +} + +/// The box around the geometry a feature made (its bodies, or its faces on bodies later features +/// changed), also where a dialog's preview stands in for it: a pattern of the feature previews +/// the body the feature is on. None when the feature made nothing. +pub(crate) fn feature_bounds(app: &SolveApp, id: u64) -> Option { + bounds_of(app, feature_parts(app, id, false).into_iter()) +} + +fn bounds_of(app: &SolveApp, items: impl Iterator) -> Option { let st = crate::open_meshing::shown_world(app); let mut b = solidcraft_engine::geom::Aabb3::EMPTY; - for s in app.session.selection.iter().flat_map(|item| displayed_selection(app, item)) { + for s in items { match &s { Sel::Body { name } => { if let Some(x) = st.body(name) {