From ddfa833bc2625cb6a52fb74ffa4d755f83ce1977 Mon Sep 17 00:00:00 2001 From: Dimitrios Vidalis <97257872+V1dalTheAbidal@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:12:27 +0200 Subject: [PATCH] Grips: hidden objects paint no grips and take no grip hits (#3048) * ui: omit hidden objects from grip painting and hit tests Respect the drawing's existing visibility predicate when painting selection grips and finding the grip under the pointer. A hidden handle restored through another selection path must not show or activate grips. The visibility predicate also suppresses grips for entities on off or frozen layers, not only objects hidden with HIDEOBJECTS. The frame regression uses ordinary model geometry and HIDEOBJECTS, with an on-screen selected-object control. * Grips: a hidden object takes no hot-grip stretch and no dynamic grips Clicking a hot grip stretches every hot-grip object, and the set can still hold an object that was hidden after it became hot (its grips are no longer painted, but the stretch still moved it). `click_grip` now drops hidden objects from the hot-grip set first. Dynamic-block grips come from the selection, which Undo or another selection source can restore with a hidden block reference in it; `dyngrips::of_selection` now skips hidden references as ordinary grips already do. Adds a test for each, a doc comment on the test helpers, and `cadcraft-io` as a dev-dependency of the UI crate so the dynamic grips test can read the synthetic dynamic-block fixture of the io tests. * Grips: a hidden object in the selection takes no part in a grip edit A grip edit took the whole selection as its targets. A hidden object that was back in the selection (Undo, sssetfirst, a control call) was stretched with a visible object's grip that shared its point, and the Move, Rotate, Scale and Mirror modes transformed it. The grip edit now leaves out what the drawing does not show when a grip becomes hot: a hidden object's own grip is never made hot, the object is not among the targets, and the typed GRIP command does not find its grips. A grip edit with no grip left does not start. --- Cargo.lock | 1 + crates/engine/src/cmd/griphot.rs | 27 ++- crates/engine/tests/it/hidden_grip_targets.rs | 161 ++++++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/canvas/grips.rs | 6 +- crates/ui-egui/src/canvas/tests.rs | 18 ++ crates/ui-egui/src/dyngrips.rs | 60 ++++++- crates/ui-egui/tests/hidden_object_grips.rs | 78 +++++++++ 10 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 crates/engine/tests/it/hidden_grip_targets.rs create mode 100644 crates/ui-egui/tests/hidden_object_grips.rs diff --git a/Cargo.lock b/Cargo.lock index 0bb4e536d..eddd0e389 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -796,6 +796,7 @@ dependencies = [ "cadcraft-engine", "cadcraft-fonts", "cadcraft-geom", + "cadcraft-io", "cadcraft-render", "chrono", "craft-ui", diff --git a/crates/engine/src/cmd/griphot.rs b/crates/engine/src/cmd/griphot.rs index 802f65e69..df466aa1c 100644 --- a/crates/engine/src/cmd/griphot.rs +++ b/crates/engine/src/cmd/griphot.rs @@ -43,7 +43,8 @@ pub(crate) struct GripEdit { hot: Vec<(Handle, usize)>, /// Typed `GRIP`: still collecting grips ("Select grips"). picking: bool, - /// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the selection. + /// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the objects of the + /// selection that the drawing shows. targets: Vec, base: Vec2, mode: GripMode, @@ -69,6 +70,12 @@ fn positive(v: Option) -> Result { v.filter(|f| f.is_finite() && *f > 1e-12).ok_or_else(|| other("Requires a positive number.")) } +/// Whether the drawing shows `h`. A hidden object (HIDEOBJECTS, an off or frozen layer) takes no +/// part in a grip edit, even when Undo or a selection command put it back into the selection. +fn shown(s: &Session, h: Handle) -> bool { + s.doc().is_ok_and(|d| d.entity(h).is_some_and(|e| d.is_visible(e))) +} + impl GripEdit { fn new(s: &Session, hot: Vec<(Handle, usize)>) -> Result { let mut g = GripEdit { @@ -87,16 +94,21 @@ impl GripEdit { Ok(g) } - /// Make grip `index` of `h` hot (the first one becomes the base point). + /// Make grip `index` of `h` hot (the first one becomes the base point). The grip of an object + /// the drawing does not show is left out, and so are the hidden objects of the selection. fn add_hot(&mut self, s: &Session, (h, i): (Handle, usize)) -> Result<()> { if self.hot.contains(&(h, i)) || self.hot.len() >= MAX_HOT { return Ok(()); } let p = s.grips_of(h)?.get(i).copied().ok_or_else(|| other("no such grip"))?; + // After the lookup: a missing object or grip stays the error it was. + if !shown(s, h) { + return Ok(()); + } if self.hot.is_empty() { self.base = p; self.targets.push(h); - self.targets.extend(s.selection().into_iter().filter(|x| *x != h)); + self.targets.extend(s.selection().into_iter().filter(|x| *x != h && shown(s, *x))); } else if !self.targets.contains(&h) { self.targets.push(h); } @@ -113,7 +125,7 @@ impl GripEdit { if super::curves::is_locked(s, h) { continue; } - let Some(e) = d.entity(h) else { continue }; + let Some(e) = d.entity(h).filter(|e| d.is_visible(e)) else { continue }; let gs = e.kind.grips(); // The nearest grip of this object within the tolerance. let best = gs @@ -470,7 +482,9 @@ impl Interactive for GripEdit { impl Session { /// Make grips hot and start editing them (the UI's click on a grip; Shift+click collects /// several first). `hot` lists (object, grip index); the first is the base grip. The grip - /// modes and their options then run at the command line like any prompt. + /// modes and their options then run at the command line like any prompt. Grips of objects the + /// drawing does not show are left out (the first grip left is the base grip), and it is an + /// error when none is left. pub fn start_grip_edit(&mut self, hot: &[(Handle, usize)]) -> Result<()> { if self.running.is_some() { self.cancel(); @@ -479,6 +493,9 @@ impl Session { return Err(other("no grip")); } let g = GripEdit::new(self, hot.to_vec())?; + if g.hot.is_empty() { + return Err(other("no grip")); + } let label = g.label(); let machine = crate::guard::Guarded::wrap(Box::new(g)); let st = self.state()?; diff --git a/crates/engine/tests/it/hidden_grip_targets.rs b/crates/engine/tests/it/hidden_grip_targets.rs new file mode 100644 index 000000000..6db222435 --- /dev/null +++ b/crates/engine/tests/it/hidden_grip_targets.rs @@ -0,0 +1,161 @@ +//! A hidden object that is back in the selection takes no part in a grip edit: Undo, `sssetfirst` +//! or a control call can restore it there, and the grips it shares with a visible object must not +//! drag it along. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle}; +use cadcraft_engine::geom::Vec2; +use serde_json::json; + +/// Draws a line from `a` to `b` and returns its handle. +fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle { + let r = s.execute("line", &json!({ "points": [a, b] })).unwrap(); + Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap() +} + +/// The two ends of the line `h`. +fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) { + match &s.doc().unwrap().entity(h).unwrap().kind { + EntityKind::Line(l) => (l.a.xy(), l.b.xy()), + k => panic!("not a line: {k:?}"), + } +} + +/// Two lines sharing the end (0, 0), the second one hidden, both in the selection. +fn shared_corner() -> (Session, Handle, Handle) { + let mut s = Session::new(); + let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + let hidden = line(&mut s, [0.0, 0.0], [0.0, 10.0]); + s.execute("hideobjects", &json!({ "handles": [hidden.hex()] })).unwrap(); + let d = s.doc().unwrap(); + assert!(!d.is_visible(d.entity(hidden).unwrap()), "the second line must be hidden"); + s.set_selection(vec![shown, hidden]); + (s, shown, hidden) +} + +/// The index of the grip of `h` at `p`. +fn grip_at(s: &Session, h: Handle, p: Vec2) -> usize { + s.grips_of(h).unwrap().iter().position(|g| g.near(p, 1e-9)).unwrap() +} + +/// Stretching the shared corner moves the visible line only; the hidden one stays where it was. +#[test] +fn a_hidden_selected_line_is_not_stretched_with_a_shared_grip() { + let (mut s, shown, hidden) = shared_corner(); + let i = grip_at(&s, shown, Vec2::new(0.0, 0.0)); + s.start_grip_edit(&[(shown, i)]).unwrap(); + s.cmdline("3,4").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let (start, end) = ends(&s, shown); + assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}"); + let (start, end) = ends(&s, hidden); + assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}"); +} + +/// The Move mode of a grip edit moves the visible selection only. +#[test] +fn a_hidden_selected_line_is_not_moved_by_the_move_mode_of_a_grip_edit() { + let (mut s, shown, hidden) = shared_corner(); + let i = grip_at(&s, shown, Vec2::new(10.0, 0.0)); + s.start_grip_edit(&[(shown, i)]).unwrap(); + s.cmdline("").unwrap(); + assert!(s.prompt_text().contains("** MOVE **"), "{}", s.prompt_text()); + s.cmdline("12,5").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let (start, end) = ends(&s, shown); + assert!(start.near(Vec2::new(2.0, 5.0), 1e-9) && end.near(Vec2::new(12.0, 5.0), 1e-9), "{start:?} {end:?}"); + let (start, end) = ends(&s, hidden); + assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}"); +} + +/// The grip of a hidden object is never made hot, so a grip edit cannot start on it. +#[test] +fn the_grip_of_a_hidden_object_cannot_be_made_hot() { + let (mut s, _, hidden) = shared_corner(); + let before = s.state().unwrap().doc.clone(); + let err = s.start_grip_edit(&[(hidden, 0)]).unwrap_err().to_string(); + assert!(err.contains("no grip"), "{err}"); + assert!(s.running.is_none()); + assert!(std::sync::Arc::ptr_eq(&before, &s.state().unwrap().doc), "nothing may change"); +} + +/// A hidden object's grip among the hot grips is left out, wherever it stands in the list: the +/// first grip the drawing shows is the base grip, and only the visible line is stretched. +#[test] +fn a_hidden_grip_among_the_hot_grips_is_left_out() { + for hidden_first in [false, true] { + let (mut s, shown, hidden) = shared_corner(); + let mut hot = vec![(shown, grip_at(&s, shown, Vec2::new(0.0, 0.0))), (hidden, grip_at(&s, hidden, Vec2::new(0.0, 10.0)))]; + if hidden_first { + hot.reverse(); + } + s.start_grip_edit(&hot).unwrap(); + s.cmdline("3,4").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let (start, end) = ends(&s, shown); + assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "hidden first: {hidden_first}: {start:?} {end:?}"); + let (start, end) = ends(&s, hidden); + assert!( + start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), + "hidden first: {hidden_first}: the hidden line moved: {start:?} {end:?}" + ); + } +} + +/// Only what the drawing does not show is left out: a grip of an object that does not exist is +/// still an error, also after a good one. +#[test] +fn a_missing_object_among_the_hot_grips_is_still_an_error() { + let (mut s, shown, _) = shared_corner(); + let i = grip_at(&s, shown, Vec2::new(0.0, 0.0)); + let err = s.start_grip_edit(&[(shown, i), (Handle(0xFFFF), 0)]).unwrap_err().to_string(); + assert!(err.contains("no such object"), "{err}"); + assert!(s.running.is_none()); +} + +/// A selected line on a layer that is off is not shown either, and is not stretched with a +/// shared grip. +#[test] +fn a_selected_line_on_a_layer_that_is_off_is_not_stretched_with_a_shared_grip() { + let mut s = Session::new(); + let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]); + s.execute("layer.new", &json!({ "name": "Guides", "current": true })).unwrap(); + let off = line(&mut s, [0.0, 0.0], [0.0, 10.0]); + s.execute("layer.current", &json!({ "name": "0" })).unwrap(); + s.execute("layer.set", &json!({ "name": "Guides", "on": false })).unwrap(); + let d = s.doc().unwrap(); + assert!(!d.is_visible(d.entity(off).unwrap()), "the second line must be on a layer that is off"); + s.set_selection(vec![shown, off]); + let i = grip_at(&s, shown, Vec2::new(0.0, 0.0)); + s.start_grip_edit(&[(shown, i)]).unwrap(); + s.cmdline("3,4").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let (start, end) = ends(&s, shown); + assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}"); + let (start, end) = ends(&s, off); + assert!( + start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), + "the line on the layer that is off moved: {start:?} {end:?}" + ); +} + +/// The typed GRIP command finds no grip where only a hidden object has one, and at the shared +/// corner it makes only the visible line's grip hot. +#[test] +fn the_typed_grip_command_finds_no_grip_of_a_hidden_object() { + let (mut s, shown, hidden) = shared_corner(); + s.cmdline("grip").unwrap(); + assert!(s.prompt_text().contains("Select grip"), "{}", s.prompt_text()); + // Only the hidden line has a grip at (0, 10): the command says so and asks again. + s.cmdline("0,10").unwrap(); + assert!(s.log.iter().any(|l| l.contains("No grip of a selected object there")), "{:?}", s.log); + s.cmdline("0,0").unwrap(); + s.cmdline("").unwrap(); + assert!(s.prompt_text().contains("** STRETCH **"), "{}", s.prompt_text()); + s.cmdline("3,4").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let (start, end) = ends(&s, shown); + assert!(start.near(Vec2::new(3.0, 4.0), 1e-9) && end.near(Vec2::new(10.0, 0.0), 1e-9), "{start:?} {end:?}"); + let (start, end) = ends(&s, hidden); + assert!(start.near(Vec2::new(0.0, 0.0), 1e-9) && end.near(Vec2::new(0.0, 10.0), 1e-9), "the hidden line moved: {start:?} {end:?}"); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 78571b219..fc7edd4a3 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -62,6 +62,7 @@ mod hatch_dialog_helpers; mod hatch_numeric_validation; mod hatch_options; mod helix; +mod hidden_grip_targets; mod inquiry_keywords; mod inquiry_variables; mod inquiry_z; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 916ab6456..034bb1d4d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -40,6 +40,8 @@ craft-ui = { workspace = true, features = ["native-windows"] } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } # Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses). naga = { version = "30", features = ["wgsl-in", "glsl-out"] } +# Reads the synthetic dynamic-block fixture of the io tests for the dynamic grips tests. +cadcraft-io = { workspace = true } [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 612dfa738..fba52d2cc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1103,7 +1103,9 @@ fn paint_canvas(frame: CanvasPaint<'_>) { { let gs = app.session.settings.gripsize as f32; for h in sel.iter().take(200) { - if let Some(e) = st.doc.entity(*h) { + if let Some(e) = st.doc.entity(*h) + && st.doc.is_visible(e) + { let grips = e.kind.grips(); let count = grips.len(); for (gi, g) in grips.into_iter().enumerate() { diff --git a/crates/ui-egui/src/canvas/grips.rs b/crates/ui-egui/src/canvas/grips.rs index 8bd0e6467..ce3de5538 100644 --- a/crates/ui-egui/src/canvas/grips.rs +++ b/crates/ui-egui/src/canvas/grips.rs @@ -3,6 +3,10 @@ use super::{CadApp, Handle, Input, Pos2, Rect, Xf, vec2}; /// A click on grip `g` (idle): Shift+click makes it hot or cold again; a plain click starts /// editing it, with the other hot grips when it is one of them. pub(super) fn click_grip(app: &mut CadApp, g: (Handle, usize), shift: bool) { + // A hot grip whose object was hidden since (a layer turned off, HIDEOBJECTS) is no longer + // painted, so it must not be stretched along with the visible ones. + let Ok(st) = app.session.state() else { return }; + app.canvas.hot_grips.retain(|(h, _)| st.doc.entity(*h).is_some_and(|e| st.doc.is_visible(e))); let hot = &mut app.canvas.hot_grips; if shift { match hot.iter().position(|x| *x == g) { @@ -85,7 +89,7 @@ pub(super) fn grip_at(app: &CadApp, xf: &Xf, hover: Option) -> Option<(Han } for h in sel.iter().take(200) { let e = st.doc.entity(*h)?; - if st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) { + if !st.doc.is_visible(e) || st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) { continue; } let grips = e.kind.grips(); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index db99e70e4..15d28b56c 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -230,3 +230,21 @@ fn dynamic_input_distance_follows_the_drawing_units() { let text = painted_text(&mut app, &ctx, at); assert!(text.contains(&format!(" {} <", arch(c.len()))), "no {} in:\n{text}", arch(c.len())); } + +/// A hot grip whose object was hidden since (HIDEOBJECTS, a layer turned off) is no longer +/// painted, so clicking another hot grip must not stretch the hidden object along with it. +#[test] +fn clicking_a_hot_grip_leaves_hidden_objects_out_of_the_stretch() { + let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default()); + for y in [0, 30] { + app.run("line", serde_json::json!({ "points": [[0, y], [100, y]] })).unwrap(); + } + let handles = app.session.doc().unwrap().model.handles(); + let (shown, hidden) = (handles[0], handles[1]); + app.session.set_selection(vec![shown, hidden]); + app.canvas.hot_grips = vec![(shown, 0), (hidden, 0)]; + app.run("hideobjects", serde_json::json!({ "handles": [hidden.hex()] })).unwrap(); + app.session.set_selection(vec![shown, hidden]); + click_grip(&mut app, (shown, 0), false); + assert_eq!(app.canvas.hot_grips, vec![(shown, 0)], "the hidden object's grip must leave the hot set"); +} diff --git a/crates/ui-egui/src/dyngrips.rs b/crates/ui-egui/src/dyngrips.rs index 7c30447c4..ab118d379 100644 --- a/crates/ui-egui/src/dyngrips.rs +++ b/crates/ui-egui/src/dyngrips.rs @@ -34,7 +34,7 @@ pub(crate) fn of_selection(app: &mut CadApp) -> Arc> { let grips: Vec<(Handle, DynGrip)> = sel .iter() .filter(|h| matches!(st.doc.entity(**h).map(|e| &e.kind), Some(cadcraft_engine::doc::EntityKind::Insert(_)))) - .filter(|h| st.doc.entity(**h).is_some_and(|e| !st.doc.layer(&e.common.layer).is_some_and(|l| l.locked))) + .filter(|h| st.doc.entity(**h).is_some_and(|e| st.doc.is_visible(e) && !st.doc.layer(&e.common.layer).is_some_and(|l| l.locked))) .take(MAX_REFERENCES) .flat_map(|h| cadcraft_engine::doc::dynblock::grips(&st.doc, *h).into_iter().map(move |g| (*h, g))) .collect(); @@ -152,3 +152,61 @@ pub(crate) fn menu(app: &mut CadApp, ctx: &egui::Context, xf: &Xf) { app.canvas.dyn_menu = None; } } + +#[cfg(test)] +mod tests { + use cadcraft_engine::Session; + use cadcraft_engine::doc::{Handle, Space}; + use serde_json::json; + + use super::of_selection; + use crate::{CadApp, Services}; + + /// DXF text from lines of `code value`. + fn dxf(src: &str) -> String { + src.lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(|l| { + let (code, value) = l.split_once(' ').unwrap_or((l, "")); + format!("{code}\n{value}\n") + }) + .collect() + } + + /// A session with the synthetic DOOR block of the io tests (Width's end grip shown in the + /// Open state, plus the visibility grip) and a reference to it at (100, 0). + fn door() -> (Session, Handle) { + let grip = + "0 BLOCKLINEARGRIP\n5 68\n330 61\n100 AcDbEvalExpr\n90 9\n98 33\n99 232\n100 AcDbBlockElement\n300 End Grip\n98 33\n99 232\n1071 0\n"; + let src = include_str!("../../io/tests/data/dynblock_door.txt") + .replace("\r\n", "\n") + .replace("170 4\n91 0\n91 0\n", "170 4\n91 8\n91 9\n") + .replace("1030 0\n93 0\n170 0\n171 0\n100 AcDbBlockVisibilityParameter", "1030 0\n93 10\n170 0\n171 0\n100 AcDbBlockVisibilityParameter") + .replace("91 5\n93 32\n95 6\n360 67", "91 5\n93 32\n95 6\n360 67\n92 -1\n92 -1\n92 -1\n92 -1\n91 6\n93 32\n95 9\n360 68") + .replace("95 1\n333 63\n", "95 2\n333 63\n333 68\n") + .replace("0 BLOCKFLIPGRIP", &format!("{grip}0 BLOCKFLIPGRIP")); + let d = cadcraft_io::read_dxf(dxf(&src).as_bytes()).unwrap(); + let mut s = Session::empty(); + s.open_drawing(d, "door", None); + let r = s.execute("insert", &json!({ "name": "Door", "at": [100, 0] })).unwrap(); + let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap(); + assert!(s.doc().unwrap().space_of(h) == Some(Space::Model)); + (s, h) + } + + /// A hidden reference restored to the selection (Undo, or another selection source) shows + /// and takes no dynamic grips, as ordinary grips are not shown for it either. + #[test] + fn a_hidden_reference_has_no_dynamic_grips() { + let (session, h) = door(); + let mut app = CadApp::new(session, Services::default()); + app.session.set_selection(vec![h]); + assert_eq!(of_selection(&mut app).len(), 2, "a visible reference shows its two grips"); + app.run("hideobjects", json!({ "handles": [h.hex()] })).unwrap(); + app.session.set_selection(vec![h]); + let st = app.session.state().unwrap(); + assert!(!st.doc.is_visible(st.doc.entity(h).unwrap()), "the reference must be hidden"); + assert!(of_selection(&mut app).is_empty(), "a hidden reference must not offer dynamic grips"); + } +} diff --git a/crates/ui-egui/tests/hidden_object_grips.rs b/crates/ui-egui/tests/hidden_object_grips.rs new file mode 100644 index 000000000..6d3c49c0d --- /dev/null +++ b/crates/ui-egui/tests/hidden_object_grips.rs @@ -0,0 +1,78 @@ +//! An ordinary hidden object restored to the selection must not paint grips. + +use cadcraft_engine::doc::Handle; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::{Session, View}; +use cadcraft_ui_egui::{CadApp, Services, theme::Tokens}; +use serde_json::json; + +/// An app with two lines, the second hidden by HIDEOBJECTS, and an empty selection. +fn drawing_with_hidden_object() -> (CadApp, Handle, Handle) { + let mut app = CadApp::new(Session::new(), Services::default()); + app.ui.start_tab = false; + app.ui.in_window_menu = false; + app.integrated_titlebar = true; + for y in [0, 30] { + app.run("line", json!({ "points": [[0, y], [100, y]] })).unwrap(); + } + let handles = app.session.doc().unwrap().model.handles(); + assert_eq!(handles.len(), 2); + let (regular, hidden) = (handles[0], handles[1]); + app.run("hideobjects", json!({ "handles": [hidden.hex()] })).unwrap(); + assert!(app.session.doc().unwrap().is_hidden_object(hidden)); + app.session.set_selection(Vec::new()); + (app, regular, hidden) +} + +/// The grip rectangles (filled with `color`) inside `canvas` among the painted shapes. +fn count_grips(shape: &egui::Shape, canvas: egui::Rect, color: egui::Color32) -> usize { + match shape { + egui::Shape::Rect(rect) => usize::from(rect.fill == color && canvas.contains(rect.rect.center())), + egui::Shape::Vec(shapes) => shapes.iter().map(|shape| count_grips(shape, canvas, color)).sum(), + _ => 0, + } +} + +/// Runs one frame of the app and counts the grip rectangles painted on its canvas. +fn painted_grips(app: &mut CadApp, ctx: &egui::Context) -> usize { + app.logic(ctx); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), + events: vec![egui::Event::PointerGone], + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| app.ui(ui)); + output.textures_delta.clear(); + let canvas = app.canvas.rect.unwrap(); + let color = Tokens::get().grip; + output.shapes.iter().map(|shape| count_grips(&shape.shape, canvas, color)).sum() +} + +/// A selected ordinary line paints its grips; the same selection of a hidden line paints none. +#[test] +fn a_hidden_selection_paints_no_grips_while_an_ordinary_selection_does() { + let (mut app, regular, hidden) = drawing_with_hidden_object(); + let ctx = egui::Context::default(); + // Settle the dock layout, then keep both objects comfortably within the canvas. + painted_grips(&mut app, &ctx); + painted_grips(&mut app, &ctx); + assert!(app.session.state_mut().unwrap().set_view(View { center: Vec2::new(50.0, 15.0), height: 120.0, twist: 0.0 })); + app.session.set_selection(vec![regular]); + painted_grips(&mut app, &ctx); + let ordinary_grips = painted_grips(&mut app, &ctx); + let state = app.session.state().unwrap(); + let xf = app.canvas.xf.unwrap(); + assert!(state.doc.entity(regular).unwrap().kind.grips().iter().all(|point| xf.rect.contains(xf.to_screen(*point)))); + assert!(ordinary_grips >= 2, "the counter must find ordinary selected-line grips, got {ordinary_grips}"); + + // Undo/Redo or another public selection source can restore a still-hidden handle. + app.session.set_selection(vec![hidden]); + painted_grips(&mut app, &ctx); + let hidden_grips = painted_grips(&mut app, &ctx); + assert!(app.session.selection().contains(&hidden), "the forced hidden selection must reach the paint loop"); + let state = app.session.state().unwrap(); + assert!(!state.doc.is_visible(state.doc.entity(hidden).unwrap())); + let xf = app.canvas.xf.unwrap(); + assert!(state.doc.entity(hidden).unwrap().kind.grips().iter().all(|point| xf.rect.contains(xf.to_screen(*point)))); + assert_eq!(hidden_grips, 0, "a hidden object leaked blue grip rectangles into the canvas"); +}