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:
1 parent
fb55c1341a
commit
29e72806f6
6 files changed
+177
-10
No files matched your search
@@ -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
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user