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.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-15 12:47:15 -04:00
1 parent fb55c1341a
commit 29e72806f6
6 files changed
+177 -10

No files matched your search

+5
View File
@@ -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
{
+70 -5
View File
@@ -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<String>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -259,6 +265,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec<String> {
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<Item> {
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<Item> {
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<Item> {
]
}
/// 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<Item> {
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<Item> {
]
}
/// An origin plane's row in the browser: sketch on it, show or hide.
fn origin_plane_items(app: &SolveApp, name: &str) -> Vec<Item> {
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<Item> {
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<usize> {
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));
+94 -1
View File
@@ -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);
}
}
}
+3 -1
View File
@@ -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| {
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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