From 29e72806f62d0939fe6aed6ba1d23a65b269562d Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:47:15 +0200 Subject: [PATCH] Context menus stay in view near the window edge; planes offer Create Sketch (#1398) List menus open below-right of the cursor and flip to end at the cursor where they don't fit (shared context_menu::place / show for viewport, browser, toolbar and Select Other). Right-clicking a plane in the viewport, a construction plane row or an origin plane row (new menu with Create Sketch and Show/Hide) offers Create Sketch on that plane. Fixes #1354. --- crates/ui-egui/src/browser.rs | 5 ++ crates/ui-egui/src/context_menu.rs | 75 +++++++++++++++++++++-- crates/ui-egui/src/menu_tests.rs | 95 +++++++++++++++++++++++++++++- crates/ui-egui/src/select_other.rs | 4 +- docs/book/src/guide/sketching.md | 5 +- docs/book/src/guide/ui-tour.md | 3 +- 6 files changed, 177 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 672e2ab6..e2e69568 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -859,6 +859,11 @@ fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { app.session.visibility.hidden_origin.retain(|h| h != k); } } + if let Some(p) = r.secondary + && icon == "plane" + { + crate::context_menu::open_for(app, p, Target::OriginPlane { name: k.into() }); + } if r.clicked && let Some(x) = sel { diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 61d52534..d517d474 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -37,6 +37,10 @@ pub enum Target { Plane { id: u64, }, + /// An origin plane (XY, XZ or YZ). + OriginPlane { + name: String, + }, Component { id: u64, }, @@ -173,6 +177,8 @@ pub struct MenuState { pub props: Option<(String, Value)>, /// The viewport menu opened over something: it offers Select Other. pub over_geometry: bool, + /// The plane under the cursor when the viewport menu opened: it offers a sketch on it. + pub plane: Option, } #[derive(Clone, Debug, PartialEq)] @@ -259,6 +265,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec { Target::Group { id } => crate::browser::group_bodies(app, *id), Target::Sketch { .. } | Target::Plane { .. } + | Target::OriginPlane { .. } | Target::Folder { .. } | Target::Canvas { .. } | Target::Origin @@ -317,6 +324,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec { Target::Body { name } => body_items(app, std::slice::from_ref(name), true), Target::Sketch { id } => sketch_items(app, *id), Target::Plane { id } => plane_items(app, *id), + Target::OriginPlane { name } => origin_plane_items(app, name), Target::Component { id } => component_items(app, *id), Target::Occurrence { path } => occurrence_items(app, path), Target::Group { id } => crate::browser::group_items(app, *id), @@ -448,7 +456,11 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(cmd(app, "solid.extrude", "Extrude")); v.push(cmd(app, "solid.revolve", "Revolve")); } else { - // Empty space. + // Empty space, or a plane, which leads with a sketch on it. + if let Some(plane) = &app.menu.plane { + v.push(cmd(app, "sketch.create", "Create Sketch").with(json!({ "plane": plane }))); + v.push(Item::sep()); + } v.push(act("ui.nav", "Pan", "pan").with(json!({ "mode": "pan" }))); v.push(act("ui.nav", "Zoom", "zoom").with(json!({ "mode": "zoom" }))); v.push(act("ui.nav", "Orbit", "orbit").with(json!({ "mode": "orbit" }))); @@ -539,11 +551,13 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec { ] } -/// A construction plane's row in the browser: edit, delete, rename, show or hide. +/// A construction plane's row in the browser: sketch on it, edit, delete, rename, show or hide. fn plane_items(app: &SolveApp, id: u64) -> Vec { let Some(f) = app.session.doc.feature(id) else { return Vec::new() }; let visible = !app.session.visibility.hidden_origin.contains(&f.name); vec![ + cmd(app, "sketch.create", "Create Sketch").with(json!({ "plane": f.name })), + Item::sep(), act("ui.editFeature", "Edit Feature", "plane").with(json!({ "feature": id })), Item::sep(), act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })), @@ -555,6 +569,16 @@ fn plane_items(app: &SolveApp, id: u64) -> Vec { ] } +/// An origin plane's row in the browser: sketch on it, show or hide. +fn origin_plane_items(app: &SolveApp, name: &str) -> Vec { + let visible = !app.session.visibility.hidden_origin.iter().any(|h| h == name); + vec![ + cmd(app, "sketch.create", "Create Sketch").with(json!({ "plane": name })), + Item::sep(), + act("ui.originPart", "Show/Hide", "eye").key("V").with(json!({ "keys": [name], "show": !visible })), + ] +} + /// An assembly joint: edit, drive, suppress, delete. fn joint_items(app: &SolveApp, id: u64) -> Vec { let Some(j) = app.session.doc.assembly.joints.iter().find(|j| j.id == id) else { return Vec::new() }; @@ -1119,6 +1143,13 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { } } "ui.newGroup" | "ui.ungroup" | "ui.groupSelected" => crate::browser::group_action(app, &item.id, &p), + // A plane's Create Sketch is the toolbar's with that plane picked first: it replaces a + // dialog in progress, and the dialog starts the sketch at once, facing the plane. + "sketch.create" if item.params.is_some() => { + if app.run("select.set", json!({ "items": [{"type": "plane", "name": p.get("plane")}] })).is_ok() { + app.start("sketch.create"); + } + } id if item.params.is_some() => { let _ = app.run(id, p); } @@ -1280,6 +1311,11 @@ pub(crate) fn commit_rename(app: &mut SolveApp, r: &Rename) { /// selects it first, so the menu is about what is under the cursor. pub fn open(app: &mut SolveApp, at: Pos2) { app.menu.over_geometry = app.viewport.hover.is_some(); + // A right-click leaves a plane unselected, but the menu offers a sketch on it. + app.menu.plane = app.viewport.hover.clone().and_then(|h| crate::viewport::candidate(app, &[h])).and_then(|(_, sel)| match sel { + Sel::Plane { name } => Some(name), + _ => None, + }); crate::select_other::close(app); if app.session.active_sketch.is_none() && let Some(h) = app.viewport.hover.clone() @@ -1361,9 +1397,9 @@ fn radial_center(at: Pos2, screen: Rect) -> Pos2 { pos2(x, y) } -/// Height of a list of items as [`list_row`] draws them. +/// Height of a list of items as [`list_row`] draws them in [`menu_frame`] (margins and border). fn list_height(items: &[Item]) -> f32 { - 8.0 + items + 10.0 + items .iter() .map(|i| { if i.is_sep() { @@ -1395,6 +1431,23 @@ fn list_place(c: Pos2, h: f32, screen: Rect) -> Pos2 { /// Width of list menus. pub const LIST_W: f32 = 250.0; +/// Where a menu of `size` opened at `at` goes: right of and below the point (`gap` away) when it +/// fits in `screen`, flipped to the left or above it when it would run past that edge, and +/// against the far edge when it fits on neither side. A flipped menu ends at the cursor, so it +/// stays in view even when the window reaches past the screen (#1354). +pub fn place(at: Pos2, gap: Vec2, size: Vec2, screen: Rect) -> Pos2 { + let axis = |at: f32, gap: f32, len: f32, lo: f32, hi: f32| { + if at + gap + len <= hi { + at + gap + } else if at - gap - len >= lo { + at - gap - len + } else { + (hi - len).max(lo) + } + }; + pos2(axis(at.x, gap.x, size.x, screen.left(), screen.right()), axis(at.y, gap.y, size.y, screen.top(), screen.bottom())) +} + /// The radial slot a pointer offset points at (none near the centre). fn sector(d: Vec2) -> Option { if d.length() < 22.0 { @@ -1543,7 +1596,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { inside |= resp.response.contains_pointer(); list_place(c, list_height(&list), screen) } else { - at + place(at, Vec2::ZERO, vec2(LIST_W, list_height(&list)), screen) }; // Up/Down choose a row of the list, Enter runs it. @@ -1854,6 +1907,18 @@ mod tests { } } + #[test] + fn list_menus_flip_to_stay_in_the_window() { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0)); + let size = vec2(LIST_W, 300.0); + assert_eq!(place(pos2(100.0, 100.0), Vec2::ZERO, size, screen), pos2(100.0, 100.0), "room right and below"); + assert_eq!(place(pos2(1500.0, 900.0), Vec2::ZERO, size, screen), pos2(1500.0 - LIST_W, 600.0), "ends at the cursor"); + assert_eq!(place(pos2(1500.0, 900.0), vec2(12.0, 12.0), size, screen), pos2(1488.0 - LIST_W, 588.0), "the gap both ways"); + let tall = vec2(LIST_W, 700.0); + assert_eq!(place(pos2(100.0, 500.0), Vec2::ZERO, tall, screen).y, 300.0, "room on neither side: against the bottom"); + assert_eq!(place(pos2(100.0, 500.0), Vec2::ZERO, vec2(LIST_W, 1200.0), screen).y, 0.0, "taller than the window: from the top"); + } + #[test] fn radial_menu_stays_on_screen() { let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0)); diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index e83cc047..405c3157 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -687,7 +687,7 @@ fn construction_plane_row_opens_its_menu() { assert_eq!(menu["open"], true, "{menu}"); assert_eq!(menu["target"], json!({"type": "plane", "id": id}), "{menu}"); let labels: Vec<&str> = menu["items"].as_array().unwrap().iter().filter_map(|i| i["label"].as_str()).filter(|l| !l.is_empty()).collect(); - assert_eq!(labels, ["Edit Feature", "Delete", "Rename", "Show/Hide", "Find in Timeline"]); + assert_eq!(labels, ["Create Sketch", "Edit Feature", "Delete", "Rename", "Show/Hide", "Find in Timeline"]); assert_eq!(h.app.session.selection, vec![solidcraft_engine::Sel::Plane { name: "Cut".into() }], "the right-click picks the plane"); context_menu::close(&mut h.app); @@ -707,3 +707,96 @@ fn construction_plane_row_opens_its_menu() { act(&mut h.app, &t, "Delete"); assert!(h.app.session.doc.feature(id).is_none(), "nothing depends on the plane: it goes at once"); } + +/// A right-click on a plane offers a sketch on it (#1354): an origin plane in the viewport (it +/// stays unselected), an origin plane's browser row and a construction plane's row. Picking it +/// starts the sketch on that plane. +#[test] +fn right_click_on_a_plane_offers_a_sketch_on_it() { + use solidcraft_engine::geom::Vec3; + let normal = |h: &crate::scenario::Harness| h.app.session.world_state().sketches.first().map(|s| s.plane.normal()).unwrap_or_default(); + for (plane, axis) in [("XY", Vec3::Z), ("XZ", Vec3::Y), ("YZ", Vec3::X)] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.settle(); + let p = h.at(&json!({"plane": plane})).unwrap(); + h.call("ui.menu", json!({"x": p[0], "y": p[1]})); + let menu = h.call("ui.menu", json!({}))["result"].clone(); + let items = menu["items"].as_array().unwrap(); + let labels: Vec<&str> = items.iter().filter_map(|i| i["label"].as_str()).filter(|l| !l.is_empty()).collect(); + assert_eq!(labels[..3], ["Select Other…", "Create Sketch", "Pan"], "{plane}: {labels:?}"); + let sketch = items.iter().find(|i| i["label"] == "Create Sketch").unwrap(); + assert_eq!(sketch["params"], json!({"plane": plane})); + assert!(h.app.session.selection.is_empty(), "the right-click leaves the plane unselected"); + assert_eq!(h.call("ui.menuPick", json!({"item": "Create Sketch"}))["ok"], true); + assert!(h.app.session.active_sketch.is_some(), "{plane}: picking it starts a sketch"); + assert!(normal(&h).dot(axis).abs() > 0.999, "{plane}: sketched on {:?}", normal(&h)); + } + + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + crate::browser::toggle(&mut h.app, "origin", false); + h.frames(3); + let at = crate::scenario::handle_at("row:XZ").expect("the Origin folder lists XZ"); + h.call("ui.click", json!({"x": at.x, "y": at.y, "button": "right"})); + h.frames(2); + let menu = h.call("ui.menu", json!({}))["result"].clone(); + assert_eq!(menu["target"], json!({"type": "origin_plane", "name": "XZ"}), "{menu}"); + let t = Target::OriginPlane { name: "XZ".into() }; + act(&mut h.app, &t, "Show/Hide"); + assert!(h.app.session.visibility.hidden_origin.contains(&"XZ".to_string())); + act(&mut h.app, &t, "Show/Hide"); + assert!(!h.app.session.visibility.hidden_origin.contains(&"XZ".to_string())); + context_menu::close(&mut h.app); + // The toolbar's Create Sketch is already waiting for a plane: the menu's replaces it. + h.app.start("sketch.create"); + assert!(h.app.dialog.is_some()); + act(&mut h.app, &t, "Create Sketch"); + h.frames(3); + assert!(h.app.dialog.is_none(), "no Create Sketch dialog is left waiting for a plane"); + assert!(normal(&h).dot(Vec3::Y).abs() > 0.999); + + let mut h = crate::scenario::Harness::new(); + h.app.run("construct.plane.offset", json!({"base": "XY", "offset": 25, "name": "Cut"})).unwrap(); + let id = h.app.session.doc.find_feature("Cut").map(|f| f.id).unwrap(); + act(&mut h.app, &Target::Plane { id }, "Create Sketch"); + h.frames(3); + let sketch = h.app.session.active_sketch.and_then(|s| h.app.session.world_state().sketch(s).map(|s| s.plane.origin.z)); + assert_eq!(sketch, Some(25.0), "the sketch is on the construction plane"); +} + +/// Menus opened near the window's bottom right corner, in the viewport or the browser, open up +/// or to the left where they would not fit, ending at the cursor and staying in the window, at +/// any UI scale (#1354). +#[test] +fn menus_near_the_window_corner_open_inward() { + for scale in [0.7, 1.0, 1.5] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.prefs", json!({"ui_scale": scale})); + let screen = h.ctx.content_rect(); + let vp = h.app.viewport.rect.unwrap(); + let at = vp.right_bottom() - egui::vec2(40.0, 40.0); + for target in [None, Some(json!({"type": "origin"}))] { + let mut params = json!({"x": at.x, "y": at.y}); + if let Some(t) = &target { + params["target"] = t.clone(); + } + h.call("ui.menu", params); + assert_eq!(h.call("ui.menu", json!({}))["result"]["open"], true, "{scale} {target:?}"); + // An auto-sized menu takes a few frames to settle its size. + h.frames(3); + let r = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_context_menu"))).unwrap(); + assert!(screen.contains_rect(r), "{scale} {target:?}: {r:?} outside {screen:?}"); + // Below and right of the cursor where it fits, else ending at it. + let x = if at.x + r.width() <= screen.right() { at.x } else { at.x - r.width() }; + let y = if at.y + r.height() <= screen.bottom() { at.y } else { at.y - r.height() }; + assert!((r.left() - x).abs() <= 2.0 && (r.top() - y).abs() <= 2.0, "{scale} {target:?}: {r:?} for {at:?} in {screen:?}"); + if target.is_none() { + assert!((r.bottom() - at.y).abs() <= 2.0, "{scale}: the empty-space menu is too tall for below and opens above"); + } + context_menu::close(&mut h.app); + h.frames(2); + } + } +} diff --git a/crates/ui-egui/src/select_other.rs b/crates/ui-egui/src/select_other.rs index 5d58f17c..e40f40ac 100644 --- a/crates/ui-egui/src/select_other.rs +++ b/crates/ui-egui/src/select_other.rs @@ -508,8 +508,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut hovered = None; let mut picked = None; let w = crate::context_menu::LIST_W - 10.0; + // Title, rows up to the scroll height, margins and border. + let size = vec2(w + 10.0, 30.0 + (p.rows.len() as f32 * 24.0).min(360.0)); let resp = crate::frame::area(egui::Id::new("sc_select_other")) - .fixed_pos(p.at + vec2(12.0, 12.0)) + .fixed_pos(crate::context_menu::place(p.at, vec2(12.0, 12.0), size, ctx.content_rect())) .constrain(true) .order(egui::Order::Foreground) .show(ctx, |ui| { diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 57283ab2..20aeb985 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -1,8 +1,9 @@ # Sketching and constraints A sketch lies on a plane: an origin plane, a construction plane or a planar face. Start one with -**Create Sketch** (`sketch.create`) and click a plane, or pick a face first. **Finish Sketch** -returns to the model. +**Create Sketch** (`sketch.create`) and click a plane, or pick a face first. Right-clicking a +plane, in the view or on its browser row, offers **Create Sketch** on that plane too. +**Finish Sketch** returns to the model. In scripts, `XY`, `XZ`, `YZ` and their `Top`, `Front`, `Right` aliases name origin planes. These names are reserved: a construction plane cannot be created with or renamed to one diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index 3e29cbe7..423b9ed0 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -45,7 +45,8 @@ home, framing what is shown now (Navigation › **Middle double-click** can make it Fit, which keeps the orientation, or turn it off). Right-click for the marking menu: eight commands around the cursor, plus a - menu for whatever is under it. + menu for whatever is under it. Near the window's bottom or right edge a menu opens above or + to the left of the cursor, so it stays in view. Shaded views omit certified smooth joins between spline spans on extruded walls. Real creases, cap outlines and authored face splits remain visible. Wireframe retains its edge presentation, and hiding a join does not remove its CAD