//! Context menus, one look everywhere. //! //! The viewport's right-click opens a radial marking menu (eight commands around the cursor, //! Repeat at the top) with a list underneath of what fits the selection or the thing under the //! cursor: faces, edges, bodies, sketch entities or empty space. Holding the right button opens //! it too; releasing over a direction picks that command (a flick). The browser's right-click //! opens the same list styling for a body, sketch or component. //! //! Items are commands (by id, with parameters filled from the target) or `ui.*` actions on view //! state; [`items`] lists them and [`run_item`] runs one, so the control channel (`ui.menu`, //! `ui.menuPick`) and tests reach everything a click does. use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2}; use serde::Serialize; use serde_json::{Value, json}; use solvecraft_engine::Sel; use crate::theme::Tokens; use crate::{SolveApp, icons}; /// What a menu is about. #[derive(Clone, Debug, Default, PartialEq, Serialize, serde::Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Target { /// The viewport: the selection, or empty space. #[default] Viewport, Body { name: String, }, Sketch { id: u64, }, Component { id: u64, }, /// A browser group (folder) of items. Group { id: u64, }, } /// One menu entry: a command id (run with `params` when given, else started like a toolbar /// click) or a `ui.*` action. `-` is a separator, `#…` a section heading. #[derive(Clone, Debug, Serialize)] pub struct Item { pub id: String, pub label: String, pub shortcut: String, pub enabled: bool, pub icon: String, #[serde(skip_serializing_if = "Option::is_none")] pub params: Option, } impl Item { fn sep() -> Item { Item { id: "-".into(), label: String::new(), shortcut: String::new(), enabled: false, icon: String::new(), params: None } } fn heading(label: &str) -> Item { Item { id: format!("#{label}"), label: label.into(), shortcut: String::new(), enabled: false, icon: String::new(), params: None } } fn is_sep(&self) -> bool { self.id == "-" } fn is_heading(&self) -> bool { self.id.starts_with('#') } fn with(mut self, params: Value) -> Item { self.params = Some(params); self } fn on(mut self, enabled: bool) -> Item { self.enabled &= enabled; self } fn key(mut self, k: &str) -> Item { self.shortcut = k.into(); self } } /// A command entry: label, icon, shortcut and enablement come from the registry. fn cmd(app: &SolveApp, id: &str, label: &str) -> Item { let info = solvecraft_engine::find_command(id).map(|c| c.info(&app.session)); let shortcut = match id { "UndoCommand" => "Ctrl+Z".to_string(), "RedoCommand" => "Ctrl+Y".to_string(), _ => info.as_ref().and_then(|i| i.shortcut).map(str::to_string).unwrap_or_default(), }; Item { id: id.into(), label: label.into(), shortcut, enabled: info.as_ref().is_some_and(|i| i.enabled), icon: info.map(|i| i.icon.to_string()).unwrap_or_default(), params: None, } } /// A view action (`ui.*`). fn act(id: &str, label: &str, icon: &str) -> Item { Item { id: id.into(), label: label.into(), shortcut: String::new(), enabled: true, icon: icon.into(), params: None } } /// Menu state kept between frames. The anchor is `app.viewport.context_menu`. #[derive(Default)] pub struct MenuState { pub target: Target, /// The radial ring is shown (viewport menus). pub radial: bool, /// The right button is held through the menu: releasing over a direction picks it. pub flick: bool, /// A right press in the viewport that may become a press-and-hold: where and when. press: Option<(Pos2, f64)>, /// `ui.menu` asked to open at a point once the pointer got there. pending: Option<(Pos2, Target)>, /// An inline rename in progress. pub rename: Option, /// The Properties window: title and measurements. pub props: Option<(String, Value)>, } #[derive(Clone, Debug, PartialEq)] pub enum RenameWhat { Body(String), Feature(u64), Component(u64), Group(u64), } #[derive(Clone, Debug)] pub struct Rename { pub what: RenameWhat, pub text: String, pub at: Pos2, focused: bool, } /// Radial slots, clockwise from the top. const DIRS: usize = 8; /// Half axes of the ring of radial items. const RX: f32 = 118.0; const RY: f32 = 84.0; // --------------------------------------------------------------------------------------------- // What the menus offer // --------------------------------------------------------------------------------------------- fn sel_bodies(app: &SolveApp) -> Vec { let mut v: Vec = Vec::new(); for s in &app.session.selection { if let Sel::Body { name } = s && !v.contains(name) { v.push(name.clone()); } } v } /// Bodies the selection touches (bodies, and the bodies of selected faces and edges). fn touched_bodies(app: &SolveApp) -> Vec { let mut v: Vec = Vec::new(); for s in &app.session.selection { let b = match s { Sel::Body { name } => name, Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => body, _ => continue, }; if !v.contains(b) { v.push(b.clone()); } } v } fn body_feature(app: &SolveApp, body: &str) -> Option { app.session.model.state().body(body).map(|b| b.feature) } /// The bodies a target stands for. pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec { match target { Target::Viewport => touched_bodies(app), Target::Body { name } => vec![name.clone()], Target::Component { id } => component_bodies(app, *id), Target::Group { id } => crate::browser::group_bodies(app, *id), Target::Sketch { .. } => Vec::new(), } } /// Bodies in a component and its subcomponents. pub fn component_bodies(app: &SolveApp, id: u64) -> Vec { let doc = &app.session.doc; app.session .model .state() .bodies .iter() .filter(|b| doc.component_within(doc.body_component(&b.name, b.feature), id)) .map(|b| b.name.clone()) .collect() } /// The eight radial commands, clockwise from the top. pub fn radial_items(app: &SolveApp) -> Vec { let repeat = match &app.last_command { Some((_, label)) => act("ui.repeat", &format!("Repeat {label}"), "redo"), None => act("ui.repeat", "Repeat", "redo").on(false), }; let delete = act("ui.delete", "Delete", "delete").key("Del").on(!app.session.selection.is_empty()); if app.session.active_sketch.is_some() { return vec![ repeat, delete, cmd(app, "DrawPolyline", "Line"), cmd(app, "ShapeRectangleTwoPoint", "Rectangle"), act("ui.finishSketch", "Finish Sketch", "finish"), cmd(app, "CircleCenterRadius", "Circle"), cmd(app, "UndoCommand", "Undo").on(!app.session.undo.is_empty()), cmd(app, "SketchDimension", "Sketch Dimension"), ]; } vec![ repeat, delete, cmd(app, "FusionPressPullCommand", "Press Pull"), cmd(app, "FusionHoleCommand", "Hole"), cmd(app, "SketchCreate", "Create Sketch"), cmd(app, "Extrude", "Extrude"), cmd(app, "UndoCommand", "Undo").on(!app.session.undo.is_empty()), cmd(app, "FusionMoveCommand", "Move/Copy"), ] } /// The list of a menu (under the ring for the viewport). pub fn items(app: &SolveApp, target: &Target) -> Vec { match target { Target::Viewport => viewport_items(app), Target::Body { name } => body_items(app, std::slice::from_ref(name), true), Target::Sketch { id } => sketch_items(app, *id), Target::Component { id } => component_items(app, *id), Target::Group { id } => crate::browser::group_items(app, *id), } } fn viewport_items(app: &SolveApp) -> Vec { let sel = &app.session.selection; let has = |f: fn(&Sel) -> bool| sel.iter().any(f); let mut v = Vec::new(); if app.session.active_sketch.is_some() { v.extend(sketch_entity_items(app)); v.push(Item::sep()); v.push(cmd(app, "TrimSketchCmd", "Trim")); v.push(cmd(app, "Offset", "Offset")); v.push(Item::sep()); v.push(act("ui.finishSketch", "Finish Sketch", "finish")); } else if has(|s| matches!(s, Sel::Face { .. })) { let face = sel.iter().find_map(|s| if let Sel::Face { body, .. } = s { Some(body.clone()) } else { None }).unwrap_or_default(); v.push(cmd(app, "SketchCreate", "Create Sketch")); v.push(cmd(app, "ConstructionPlaneOffsetFromPlaneCommand", "Offset Plane")); v.push(cmd(app, "FusionPressPullCommand", "Press Pull")); v.push(cmd(app, "Extrude", "Extrude")); v.push(cmd(app, "MeasureCommand", "Measure")); v.push(act("ui.appearance", "Appearance…", "").on(false)); v.push(Item::sep()); v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": face }))); v.push(act("ui.findTimeline", "Find in Timeline", "").with(json!({ "body": face }))); v.push(act("ui.hide", "Hide Body", "eye").with(json!({ "bodies": [face] }))); } else if has(|s| matches!(s, Sel::Edge { .. })) { v.push(cmd(app, "FusionFilletEdgesCommand", "Fillet")); v.push(cmd(app, "FusionChamferCommand", "Chamfer")); v.push(act("ui.tangentChain", "Select Tangent Chain", "")); v.push(cmd(app, "MeasureCommand", "Measure")); } else if has(|s| matches!(s, Sel::Body { .. })) { v.extend(body_items(app, &sel_bodies(app), false)); } else if has(|s| matches!(s, Sel::Profile { .. })) { v.push(cmd(app, "Extrude", "Extrude")); v.push(cmd(app, "Revolve", "Revolve")); } if !v.is_empty() { v.push(Item::sep()); } // Empty space (and always, at the end): undo, view and selection helpers. if let Some((_, label)) = &app.last_command && v.is_empty() { v.push(act("ui.repeat", &format!("Repeat {label}"), "redo")); } v.push(cmd(app, "UndoCommand", "Undo").on(!app.session.undo.is_empty())); v.push(cmd(app, "RedoCommand", "Redo").on(!app.session.redo.is_empty())); v.push(Item::sep()); if app.session.section.is_some() { v.push(act("ui.unsection", "Remove Section", "section")); } if !app.ui.hidden_bodies.is_empty() { v.push(act("ui.showAll", "Show All Bodies", "eye")); } v.push(act("ui.origin", if app.ui.show_origin { "Hide Origin" } else { "Show Origin" }, "origin")); if !sel.is_empty() { v.push(act("ui.clear", "Clear Selection", "").key("Esc")); } v.push(act("ui.fit", "Fit", "fit")); v.push(act("ui.home", "Home View", "home")); if app.session.active_sketch.is_some() { // Finish Sketch is already in the list above. } else if v.last().is_some_and(Item::is_sep) { v.pop(); } v } fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec { let names = json!(bodies); let all_hidden = bodies.iter().all(|b| app.ui.hidden_bodies.contains(b)); let one = bodies.len() == 1; let locked = bodies.iter().all(|b| app.ui.locked_bodies.contains(b)); let mut v = vec![ act("ui.moveBodies", "Move/Copy", "move").key("M").with(json!({ "bodies": names })).on(!locked), act(if all_hidden { "ui.show" } else { "ui.hide" }, if all_hidden { "Show Body" } else { "Hide Body" }, "eye") .with(json!({ "bodies": names })), act("ui.isolate", "Isolate", "").with(json!({ "bodies": names })), act("ui.rename", "Rename", "").with(json!({ "body": bodies.first() })).on(one), cmd(app, "SoftDeleteCommand", "Delete").key("Del").with(json!({ "bodies": names })).on(!locked), act("ui.lock", if locked { "Unlock" } else { "Lock" }, "").with(json!({ "bodies": names })), Item::sep(), act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })), cmd(app, "FusionCreateComponentsFromBodiesCommand", "Create Components from Bodies").with(json!({ "bodies": names })), act("ui.export", "Export…", "export").with(json!({ "bodies": names })), ]; if one { let b = bodies.first().cloned().unwrap_or_default(); v.push(Item::sep()); if !browser { v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": b }))); } v.push(act("ui.findTimeline", "Find in Timeline", "").with(json!({ "body": b }))); } v } fn sketch_items(app: &SolveApp, id: u64) -> Vec { let visible = crate::browser::sketch_visible(app, id); let profiles = !app.ui.hidden_profiles.contains(&id); let editing = app.session.active_sketch == Some(id); vec![ act("ui.editSketch", "Edit Sketch", "sketch").with(json!({ "sketch": id })).on(!editing), act("ui.redefineSketch", "Redefine Sketch Plane", "plane").with(json!({ "sketch": id })), act(if visible { "ui.hideSketch" } else { "ui.showSketch" }, if visible { "Hide" } else { "Show" }, "eye").with(json!({ "sketch": id })), act("ui.sketchProfile", if profiles { "Hide Profile" } else { "Show Profile" }, "").with(json!({ "sketch": id })), act("ui.rename", "Rename", "").with(json!({ "feature": id })), cmd(app, "FusionDeleteCommand", "Delete").key("Del").with(json!({ "features": [id.to_string()] })), Item::sep(), act("ui.findTimeline", "Find in Timeline", "").with(json!({ "feature": id })), ] } fn component_items(app: &SolveApp, id: u64) -> Vec { let doc = &app.session.doc; let occ = doc.occurrence_of(id); let active = app.session.active_component == id; let bodies = component_bodies(app, id); let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.ui.hidden_bodies.contains(b)); let mut v = vec![ act("ui.activate", "Activate Component", "component").with(json!({ "component": id })).on(!active), cmd(app, "FusionCreateNewComponentCommand", "New Component").with(json!({ "parent": id })), cmd(app, "FusionCreateComponentsFromBodiesCommand", "Create Components from Bodies") .with(json!({ "bodies": bodies })) .on(!bodies.is_empty() && id == 0), act("ui.newGroup", "New Group", "folder").with(json!({ "component": id })), Item::sep(), act(if hidden { "ui.show" } else { "ui.hide" }, if hidden { "Show" } else { "Hide" }, "eye") .with(json!({ "bodies": bodies })) .on(!bodies.is_empty()), act("ui.isolate", "Isolate", "").with(json!({ "bodies": bodies })).on(!bodies.is_empty()), act("ui.rename", "Rename", "").with(json!({ "component": id })), ]; if id != 0 { let grounded = occ.is_some_and(|o| o.grounded); let oid = occ.map(|o| o.id).unwrap_or(0); v.push(cmd(app, "component.delete", "Delete").key("Del").with(json!({ "component": id }))); v.push(Item::sep()); v.push(act("ui.moveOccurrence", "Move/Copy", "move").with(json!({ "occurrence": oid })).on(!grounded && occ.is_some())); v.push(cmd(app, "occurrence.ground", if grounded { "Unground" } else { "Ground" }).with(json!({ "occurrence": oid, "grounded": !grounded }))); v.push(cmd(app, "SnapshotCmd", "Capture Position").on(!app.session.pending_moves.is_empty())); v.push(cmd(app, "AsBuiltPositionsCmd", "Revert Position").on(!app.session.pending_moves.is_empty())); v.push(Item::sep()); v.push(cmd(app, "occurrence.copy", "Paste (Instance)").with(json!({ "component": id, "translate": [10, 10, 0] }))); v.push(cmd(app, "component.paste_new", "Paste New").with(json!({ "component": id, "translate": [10, 10, 0] }))); } v } /// Sketch entity items: construction, fix, delete, dimension and the constraints the selection /// can take. fn sketch_entity_items(app: &SolveApp) -> Vec { use solvecraft_engine::sketch::CurveKind; let Some(sid) = app.session.active_sketch else { return Vec::new() }; let st = app.session.model.state(); let Some(ss) = st.sketch(sid) else { return Vec::new() }; let mut lines = Vec::new(); let mut rounds = Vec::new(); let mut curves = Vec::new(); let mut points = Vec::new(); for s in &app.session.selection { match s { Sel::SketchCurve { id } => { curves.push(id.clone()); match ss.sketch.curve_index(id).and_then(|i| ss.sketch.curves.get(i)).map(|c| &c.kind) { Some(CurveKind::Line { .. }) => lines.push(id.clone()), Some(CurveKind::Circle { .. } | CurveKind::Arc { .. }) => rounds.push(id.clone()), _ => {} } } Sel::SketchPoint { id } => points.push(id.clone()), _ => {} } } let mut v = Vec::new(); if curves.is_empty() && points.is_empty() { v.push(cmd(app, "DrawPolyline", "Line")); v.push(cmd(app, "ShapeRectangleTwoPoint", "Rectangle")); v.push(cmd(app, "CircleCenterRadius", "Circle")); v.push(cmd(app, "SketchDimension", "Sketch Dimension")); return v; } let ents: Vec = curves.iter().chain(points.iter()).cloned().collect(); if !curves.is_empty() { v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves }))); } v.push(act("ui.fix", "Fix/Unfix", "c_fix").with(json!({ "entities": ents }))); v.push(act("ui.delete", "Delete", "delete").key("Del")); v.push(cmd(app, "SketchDimension", "Sketch Dimension").with(json!({ "entities": ents })).on(ents.len() <= 2)); let mut c = Vec::new(); let n = ents.len(); let (l, r, p) = (lines.len(), rounds.len(), points.len()); if l == 1 && n == 1 { c.push(cmd(app, "ConstraintHorizontalVertical", "Horizontal/Vertical").with(json!({ "line": lines[0] }))); } if p == 2 && n == 2 { c.push(cmd(app, "ConstraintCoincident", "Coincident").with(json!({ "a": points[0], "b": points[1] }))); c.push(cmd(app, "ConstraintHorizontalVertical", "Horizontal/Vertical").with(json!({ "points": points }))); } if p == 1 && curves.len() == 1 && n == 2 { c.push(cmd(app, "ConstraintCoincident", "Coincident").with(json!({ "a": points[0], "b": curves[0] }))); if l == 1 { c.push(cmd(app, "ConstraintMidPoint", "MidPoint").with(json!({ "point": points[0], "line": lines[0] }))); } } if l == 2 && n == 2 { let ab = json!({ "a": lines[0], "b": lines[1] }); c.push(cmd(app, "ConstraintParallel", "Parallel").with(ab.clone())); c.push(cmd(app, "ConstraintPerpendicular", "Perpendicular").with(ab.clone())); c.push(cmd(app, "ConstraintCollinear", "Collinear").with(ab.clone())); c.push(cmd(app, "ConstraintEqual", "Equal").with(ab)); } if r == 2 && n == 2 { let ab = json!({ "a": rounds[0], "b": rounds[1] }); c.push(cmd(app, "ConstraintConcentric", "Concentric").with(ab.clone())); c.push(cmd(app, "ConstraintEqual", "Equal").with(ab.clone())); c.push(cmd(app, "ConstraintTangent", "Tangent").with(ab)); } if l == 1 && r == 1 && n == 2 { c.push(cmd(app, "ConstraintTangent", "Tangent").with(json!({ "a": lines[0], "b": rounds[0] }))); } if p == 2 && l == 1 && n == 3 { c.push(cmd(app, "ConstraintSymmetry", "Symmetry").with(json!({ "a": points[0], "b": points[1], "line": lines[0] }))); } if !c.is_empty() { v.push(Item::heading("Constraints")); v.extend(c); } v } // --------------------------------------------------------------------------------------------- // Running an item // --------------------------------------------------------------------------------------------- fn strs(p: &Value, k: &str) -> Vec { p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() } fn id_of(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_u64) } /// Run a menu item (what a click on it does). `at` places a rename box. pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { if !item.enabled || item.is_sep() || item.is_heading() { return; } let p = item.params.clone().unwrap_or(Value::Null); let bodies = strs(&p, "bodies"); match item.id.as_str() { "ui.repeat" => { if let Some((id, _)) = app.last_command.clone() { app.start(&id); } } "ui.delete" => delete(app), "ui.finishSketch" => app.finish_sketch(), "ui.hide" => { for b in bodies { if !app.ui.hidden_bodies.contains(&b) { app.ui.hidden_bodies.push(b); } } } "ui.show" => app.ui.hidden_bodies.retain(|b| !bodies.contains(b)), "ui.showAll" => app.ui.hidden_bodies.clear(), "ui.isolate" => { app.ui.hidden_bodies = app.session.model.state().bodies.iter().map(|b| b.name.clone()).filter(|b| !bodies.contains(b)).collect(); } "ui.lock" => { let locked = bodies.iter().all(|b| app.ui.locked_bodies.contains(b)); if locked { app.ui.locked_bodies.retain(|b| !bodies.contains(b)); } else { app.ui.locked_bodies.extend(bodies.into_iter().filter(|b| !app.ui.locked_bodies.contains(b)).collect::>()); } } "ui.moveBodies" => { let items: Vec = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect(); let _ = app.run("select.set", json!({ "items": items })); app.start("FusionMoveCommand"); } "ui.rename" => { let what = if let Some(b) = p.get("body").and_then(Value::as_str) { RenameWhat::Body(b.to_string()) } else if let Some(f) = id_of(&p, "feature") { RenameWhat::Feature(f) } else if let Some(c) = id_of(&p, "component") { RenameWhat::Component(c) } else if let Some(g) = id_of(&p, "group") { RenameWhat::Group(g) } else { return; }; start_rename(app, what, at); } "ui.properties" => match app.session.execute("MeasureCommand", &json!({ "bodies": bodies })) { Ok(v) => app.menu.props = Some((if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) }, v)), Err(e) => app.set_status(e.to_string(), true), }, "ui.export" => { let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone()); if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.step"), &["step", "stp", "stl", "3mf", "obj"])) { let _ = app.run("ExportCommand", json!({ "path": path, "bodies": bodies })); } } "ui.findBrowser" => { if let Some(b) = p.get("body").and_then(Value::as_str) { app.ui.show_browser = true; app.tree.reveal = Some(b.to_string()); let _ = app.run("select.set", json!({"items": [{"type": "body", "name": b}]})); } } "ui.findTimeline" => { let f = id_of(&p, "feature").or_else(|| p.get("body").and_then(Value::as_str).and_then(|b| body_feature(app, b))); if let Some(f) = f { app.ui.show_timeline = true; let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": f}]})); } } "ui.tangentChain" => tangent_chain(app), "ui.origin" => app.ui.show_origin = !app.ui.show_origin, "ui.clear" => drop(app.run("select.clear", json!({}))), "ui.unsection" => drop(app.run("FusionHalfSectionViewCommand", json!({"clear": true}))), "ui.fit" => app.animate_view("fit"), "ui.home" => app.animate_view("home"), "ui.fix" => { for e in strs(&p, "entities") { let _ = app.run("ConstraintFix", json!({ "entity": e })); } } "ui.editSketch" => { if let Some(id) = id_of(&p, "sketch") { app.edit_sketch(id); } } "ui.redefineSketch" => { if let Some(id) = id_of(&p, "sketch") { crate::browser::redefine_sketch(app, id); } } "ui.hideSketch" | "ui.showSketch" => { if let Some(id) = id_of(&p, "sketch") { crate::browser::set_sketch_visible(app, id, item.id == "ui.showSketch"); } } "ui.sketchProfile" => { if let Some(id) = id_of(&p, "sketch") { if app.ui.hidden_profiles.contains(&id) { app.ui.hidden_profiles.retain(|x| *x != id); } else { app.ui.hidden_profiles.push(id); } } } "ui.activate" => { if let Some(id) = id_of(&p, "component") { let _ = app.run("component.activate", json!({ "component": id })); } } "ui.moveOccurrence" => { if let Some(o) = id_of(&p, "occurrence") { app.tree.occurrence_move = Some(crate::browser::OccMove::new(o)); } } "ui.newGroup" | "ui.ungroup" | "ui.groupSelected" => crate::browser::group_action(app, &item.id, &p), id if item.params.is_some() => { let _ = app.run(id, p); } id => app.start(id), } } /// Delete what is selected: sketch entities, features, and bodies (a Remove feature). pub fn delete(app: &mut SolveApp) { crate::viewport::delete_selection(app); } /// Add the edges that continue the selected edges smoothly to the selection. fn tangent_chain(app: &mut SolveApp) { let st = app.session.model.state(); let mut out: Vec = Vec::new(); for s in &app.session.selection { let Sel::Edge { body, index, .. } = s else { continue }; let Some(b) = st.body(body) else { continue }; let m = b.mesh(); for i in m.tangent_chain(*index, 2f64.to_radians()) { let Some(e) = m.edges.get(i) else { continue }; let Some(mid) = e.get(e.len() / 2).copied() else { continue }; let x = Sel::Edge { body: body.clone(), index: i, point: mid }; if !out.iter().any(|y| matches!(y, Sel::Edge { body: bb, index: ii, .. } if bb == body && *ii == i)) { out.push(x); } } } if !out.is_empty() { let _ = app.run("select.set", json!({ "items": out })); } } pub fn start_rename(app: &mut SolveApp, what: RenameWhat, at: Pos2) { let doc = &app.session.doc; let text = match &what { RenameWhat::Body(b) => b.clone(), RenameWhat::Feature(f) => doc.feature(*f).map(|f| f.name.clone()).unwrap_or_default(), RenameWhat::Component(0) => doc.name.clone(), RenameWhat::Component(c) => doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone()).unwrap_or_default(), RenameWhat::Group(g) => crate::browser::group_name(app, *g).unwrap_or_default(), }; app.menu.rename = Some(Rename { what, text, at, focused: false }); } fn commit_rename(app: &mut SolveApp, r: &Rename) { let name = r.text.trim().to_string(); if name.is_empty() { return; } match &r.what { RenameWhat::Body(old) => { if app.run("body.rename", json!({ "body": old, "name": name })).is_ok() { for list in [&mut app.ui.hidden_bodies, &mut app.ui.locked_bodies] { for b in list.iter_mut().filter(|b| *b == old) { *b = name.clone(); } } } } RenameWhat::Feature(f) => drop(app.run("FusionRenameTimelineEntryCommand", json!({ "feature": f, "name": name }))), RenameWhat::Component(c) => drop(app.run("component.rename", json!({ "component": c, "name": name }))), RenameWhat::Group(g) => crate::browser::group_action(app, "ui.renameGroup", &json!({ "group": g, "name": name })), } } // --------------------------------------------------------------------------------------------- // Opening and showing // --------------------------------------------------------------------------------------------- /// Open the viewport menu at a screen position. A right-click on something not selected /// selects it first, so the menu is about what is under the cursor. pub fn open(app: &mut SolveApp, at: Pos2) { if app.session.active_sketch.is_none() && let Some(h) = app.viewport.hover.clone() && let Some((_, sel)) = crate::viewport::candidate(app, &[h]) && !app.session.selection.contains(&sel) && !matches!(sel, Sel::Plane { .. } | Sel::Axis { .. }) { let _ = app.run("select.set", json!({ "items": [sel] })); } else if app.session.active_sketch.is_some() && let Some(h) = app.viewport.hover.clone() && let Some((_, sel)) = crate::viewport::candidate(app, &[h]) && matches!(sel, Sel::SketchCurve { .. } | Sel::SketchPoint { .. }) && !app.session.selection.contains(&sel) { let _ = app.run("select.set", json!({ "items": [sel] })); } open_for(app, at, Target::Viewport); } /// Open a menu for a target (the radial ring only for the viewport). pub fn open_for(app: &mut SolveApp, at: Pos2, target: Target) { app.menu.radial = target == Target::Viewport; app.menu.target = target; app.menu.flick = false; app.viewport.context_menu = Some(at); } pub fn close(app: &mut SolveApp) { app.viewport.context_menu = None; app.menu.flick = false; } /// Open the menu at a point for automation: the pointer moves there first so what is under it /// counts, then the menu opens on a later frame. pub fn request_open(app: &mut SolveApp, at: Pos2, target: Target) { app.synthetic.push(egui::Event::PointerMoved(at)); app.menu.pending = Some((at, target)); } /// The open menu as JSON (for `ui.menu`). pub fn describe(app: &SolveApp) -> Value { let Some(at) = app.viewport.context_menu else { return json!({ "open": false, "pending": app.menu.pending.is_some() }); }; let radial = if app.menu.radial { radial_items(app) } else { Vec::new() }; json!({ "open": true, "at": [at.x, at.y], "target": app.menu.target, "radial": radial, "items": items(app, &app.menu.target), "rename": app.menu.rename.as_ref().map(|r| r.text.clone()), }) } /// Pick an item of the open menu by id or label (for `ui.menuPick`). pub fn pick(app: &mut SolveApp, key: &str) -> Result { let Some(at) = app.viewport.context_menu else { return Err("no menu is open".into()) }; let target = app.menu.target.clone(); let mut all = if app.menu.radial { radial_items(app) } else { Vec::new() }; all.extend(items(app, &target)); let item = all.into_iter().find(|i| !i.is_sep() && !i.is_heading() && (i.id == key || i.label == key)).ok_or_else(|| format!("no menu item `{key}`"))?; if !item.enabled { return Err(format!("`{}` is disabled", item.label)); } close(app); run_item(app, &item, at); Ok(json!({ "ran": item.id, "label": item.label })) } /// Where the radial menu goes so its ring stays on screen. fn radial_center(at: Pos2, screen: Rect) -> Pos2 { let mx = RX + 165.0; let my = RY + 34.0; let x = if screen.width() > 2.0 * mx { at.x.clamp(screen.left() + mx, screen.right() - mx) } else { screen.center().x }; let y = if screen.height() > 2.0 * my { at.y.clamp(screen.top() + my, screen.bottom() - my) } else { screen.center().y }; pos2(x, y) } /// Height of a list of items as [`list_row`] draws them. fn list_height(items: &[Item]) -> f32 { 8.0 + items .iter() .map(|i| { if i.is_sep() { 7.0 } else if i.is_heading() { 20.0 } else { 24.0 } }) .sum::() } /// Where the list goes next to a ring centred at `c`: below it when it fits, otherwise beside /// it (right, else left), kept on screen. fn list_place(c: Pos2, h: f32, screen: Rect) -> Pos2 { let below = c.y + RY + 26.0; if below + h <= screen.bottom() - 4.0 { return pos2((c.x - LIST_W / 2.0).clamp(screen.left() + 4.0, (screen.right() - LIST_W - 4.0).max(screen.left())), below); } let y = (c.y - h / 2.0).clamp(screen.top() + 4.0, (screen.bottom() - h - 4.0).max(screen.top())); let right = c.x + RX + 170.0; if right + LIST_W <= screen.right() - 4.0 { return pos2(right, y); } pos2((c.x - RX - 170.0 - LIST_W).max(screen.left() + 4.0), y) } /// Width of list menus. pub const LIST_W: f32 = 250.0; /// The radial slot a pointer offset points at (none near the centre). fn sector(d: Vec2) -> Option { if d.length() < 22.0 { return None; } // Clockwise from the top (screen y points down). let a = d.x.atan2(-d.y).to_degrees(); let k = ((a + 360.0 + 22.5) / 45.0).floor() as i64; Some((k.rem_euclid(DIRS as i64)) as usize) } /// The rectangle of radial slot `k` with a label of `size`. fn slot_rect(c: Pos2, k: usize, size: Vec2) -> Rect { let a = (k as f32 * 45.0).to_radians(); let p = c + vec2(a.sin() * RX, -a.cos() * RY); let (sx, sy) = (a.sin(), -a.cos()); let ax = if sx > 0.3 { 0.0 } else if sx < -0.3 { 1.0 } else { 0.5 }; let ay = if sy > 0.9 { 0.0 } else if sy < -0.9 { 1.0 } else { 0.5 }; Rect::from_min_size(p - vec2(size.x * ax, size.y * ay), size) } /// Press-and-hold of the right button in the viewport opens the menu; releasing over a /// direction picks it. fn track_hold(app: &mut SolveApp, ctx: &egui::Context) { let (pressed, down, released, pos, time) = ctx.input(|i| { ( i.pointer.button_pressed(egui::PointerButton::Secondary), i.pointer.secondary_down(), i.pointer.button_released(egui::PointerButton::Secondary), i.pointer.latest_pos(), i.time, ) }); let free = app.tool.is_none() && app.dialog.is_none(); if pressed && free && app.viewport.context_menu.is_none() && app.viewport.mouse.is_some() { app.menu.press = pos.map(|p| (p, time)); } if let Some((p0, t0)) = app.menu.press { let moved = pos.is_some_and(|p| p.distance(p0) > 5.0); if !down || moved { app.menu.press = None; } else if time - t0 > 0.35 { app.menu.press = None; open(app, p0); app.menu.flick = true; } else { ctx.request_repaint_after(std::time::Duration::from_millis(30)); } } if released && app.menu.flick { app.menu.flick = false; if let (Some(at), Some(p)) = (app.viewport.context_menu, pos) { let c = radial_center(at, ctx.content_rect()); if app.menu.radial && let Some(k) = sector(p - c) && p.distance(c) > 36.0 && let Some(item) = radial_items(app).into_iter().nth(k).filter(|i| i.enabled) { close(app); run_item(app, &item, at); } } } } /// Show the open menu, the rename box and the Properties window. pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if let Some((at, target)) = app.menu.pending.clone() && app.synthetic.is_empty() { app.menu.pending = None; if target == Target::Viewport { open(app, at) } else { open_for(app, at, target) } } track_hold(app, ctx); show_rename(app, ctx); show_props(app, ctx); let Some(at) = app.viewport.context_menu else { return }; let t = Tokens::get(); let screen = ctx.content_rect(); let target = app.menu.target.clone(); let list = items(app, &target); let mut picked: Option = None; let mut inside = false; let pointer = ctx.input(|i| i.pointer.latest_pos()); let list_at = if app.menu.radial { let c = radial_center(at, screen); let radial = radial_items(app); let area = Rect::from_center_size(c, vec2(2.0 * (RX + 165.0), 2.0 * (RY + 34.0))); inside |= pointer.is_some_and(|p| area.contains(p)); let hot = pointer.and_then(|p| sector(p - c)); let resp = egui::Area::new(egui::Id::new("sc_marking_menu")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover()); let painter = ui.painter_at(r.expand(4.0)); let font = FontId::proportional(13.0); // Centre: a ring with a pointer toward the hot direction. painter.circle(c, 15.0, t.panel.gamma_multiply(0.92), Stroke::new(1.0, t.border)); if let (Some(k), Some(p)) = (hot, pointer) { let d = (p - c).normalized(); painter.line_segment( [c + d * 6.0, c + d * 15.0], Stroke::new(3.0, if radial.get(k).is_some_and(|i| i.enabled) { t.accent } else { t.border }), ); } else { painter.circle_filled(c, 3.0, t.text_dim); } for (k, item) in radial.iter().enumerate() { let galley = painter.layout_no_wrap(item.label.clone(), font.clone(), if item.enabled { t.text } else { t.text_dim }); let size = vec2(galley.size().x + 40.0, 28.0); let rr = slot_rect(c, k, size); let resp = ui.interact(rr, ui.id().with(("radial", k)), Sense::click()); let lit = item.enabled && (resp.hovered() || hot == Some(k)); painter.rect( rr, 14.0, if lit { t.accent_soft } else { t.panel }, Stroke::new(1.0, if lit { t.accent } else { t.border }), egui::StrokeKind::Inside, ); let ir = Rect::from_center_size(pos2(rr.left() + 16.0, rr.center().y), vec2(16.0, 16.0)); let ink = if item.enabled { t.icon } else { t.border }; if !item.icon.is_empty() { icons::paint(&painter, ir, &item.icon, ink, t.icon_fill, t.accent); } painter.galley(pos2(rr.left() + 30.0, rr.center().y - galley.size().y / 2.0), galley, t.text); if resp.clicked() && item.enabled { picked = Some(item.clone()); } if item.enabled { resp.on_hover_text(item.shortcut.clone()); } } }); inside |= resp.response.contains_pointer(); list_place(c, list_height(&list), screen) } else { at }; let resp = egui::Area::new(egui::Id::new("sc_context_menu")).fixed_pos(list_at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| { menu_frame(ui, |ui| { for item in &list { if let Some(p) = list_row(ui, item) { picked = Some(p); } } }); }); inside |= resp.response.contains_pointer() || resp.response.rect.contains(pointer.unwrap_or(Pos2::ZERO)); let (primary, secondary, esc) = ctx.input(|i| { ( i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_pressed(egui::PointerButton::Secondary), i.key_pressed(egui::Key::Escape), ) }); if secondary && inside && app.menu.radial { // A right press on the ring: drag toward a command and release to pick it. app.menu.flick = true; } else if (primary || secondary) && !inside || esc { close(app); } if let Some(item) = picked { close(app); run_item(app, &item, at); } } /// The frame every menu uses. pub fn menu_frame(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { let t = Tokens::get(); egui::Frame::new() .fill(t.panel) .stroke(Stroke::new(1.0, t.border)) .corner_radius(6.0) .inner_margin(egui::Margin::symmetric(4, 4)) .shadow(egui::Shadow { offset: [0, 3], blur: 12, spread: 0, color: Color32::from_black_alpha(if crate::theme::is_dark() { 110 } else { 50 }), }) .show(ui, |ui| { ui.set_width(LIST_W - 10.0); ui.spacing_mut().item_spacing.y = 0.0; add(ui) }) .inner } /// One list row; the item when it was clicked. pub fn list_row(ui: &mut egui::Ui, item: &Item) -> Option { let t = Tokens::get(); let w = LIST_W - 10.0; if item.is_sep() { let (r, _) = ui.allocate_exact_size(vec2(w, 7.0), Sense::hover()); ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.border)); return None; } if item.is_heading() { let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover()); ui.painter().text( pos2(r.left() + 8.0, r.center().y + 2.0), Align2::LEFT_CENTER, item.label.to_uppercase(), FontId::proportional(10.5), t.text_dim, ); return None; } let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), if item.enabled { Sense::click() } else { Sense::hover() }); if item.enabled && resp.hovered() { ui.painter().rect_filled(r.shrink2(vec2(2.0, 1.0)), 4.0, t.hover); } let ink = if item.enabled { t.icon } else { t.border }; if !item.icon.is_empty() { icons::paint( ui.painter(), Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(16.0, 16.0)), &item.icon, ink, t.icon_fill, t.accent, ); } let text = if item.enabled { t.text } else { t.text_dim.gamma_multiply(0.75) }; ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, &item.label, FontId::proportional(13.0), text); if !item.shortcut.is_empty() { ui.painter().text(pos2(r.right() - 10.0, r.center().y), Align2::RIGHT_CENTER, &item.shortcut, FontId::proportional(11.5), t.text_dim); } (item.enabled && resp.clicked()).then(|| item.clone()) } fn show_rename(app: &mut SolveApp, ctx: &egui::Context) { let Some(mut r) = app.menu.rename.take() else { return }; let t = Tokens::get(); let mut done: Option = None; let resp = egui::Area::new(egui::Id::new("sc_rename")).fixed_pos(r.at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| { menu_frame(ui, |ui| { ui.add_space(2.0); ui.label(egui::RichText::new("Rename").size(11.0).color(t.text_dim)); let te = ui.add(egui::TextEdit::singleline(&mut r.text).desired_width(200.0).id(egui::Id::new("sc_rename_text"))); if !r.focused { // Start with the whole name selected, ready to type over. let id = egui::Id::new("sc_rename_text"); if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), id) { let n = r.text.chars().count(); st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); st.store(ui.ctx(), id); } te.request_focus(); r.focused = true; } if te.lost_focus() { done = Some(ui.input(|i| i.key_pressed(egui::Key::Enter))); } if ui.input(|i| i.key_pressed(egui::Key::Escape)) { done = Some(false); } }) }); if ctx.input(|i| i.pointer.any_pressed()) && !resp.response.contains_pointer() && r.focused { done = Some(true); } match done { Some(true) => commit_rename(app, &r), Some(false) => {} None => app.menu.rename = Some(r), } } /// Finish a rename from automation: commit (true) or cancel. pub fn finish_rename(app: &mut SolveApp, text: Option<&str>, commit: bool) -> bool { let Some(mut r) = app.menu.rename.take() else { return false }; if let Some(s) = text { r.text = s.to_string(); } if commit { commit_rename(app, &r); } true } fn show_props(app: &mut SolveApp, ctx: &egui::Context) { let Some((title, v)) = app.menu.props.clone() else { return }; let mut open = true; let f = |x: &Value, k: &str| x.get(k).and_then(Value::as_f64).unwrap_or(f64::NAN); egui::Window::new(format!("Properties: {title}")) .id(egui::Id::new("sc_props")) .default_pos(ctx.content_rect().center() - vec2(160.0, 140.0)) .open(&mut open) .resizable(false) .collapsible(false) .show(ctx, |ui| { egui::Grid::new("sc_props_grid").num_columns(2).spacing([16.0, 4.0]).show(ui, |ui| { let total = &v["total"]; let bodies = v["bodies"].as_array().cloned().unwrap_or_default(); if let [b] = bodies.as_slice() { ui.label("Material"); ui.label(b["material"].as_str().unwrap_or("Default")); ui.end_row(); ui.label("Density"); ui.label(format!("{:.3} g/cm³", f(b, "density_g_cm3"))); ui.end_row(); } ui.label("Mass"); ui.label(format!("{:.3} g", f(total, "mass_g"))); ui.end_row(); ui.label("Volume"); ui.label(format!("{:.3} mm³", f(total, "volume_mm3"))); ui.end_row(); ui.label("Area"); ui.label(format!("{:.3} mm²", f(total, "area_mm2"))); ui.end_row(); if let [b] = bodies.as_slice() { if let Some(c) = b["center_of_mass"].as_array() { let c: Vec = c.iter().map(|x| format!("{:.3}", x.as_f64().unwrap_or(f64::NAN))).collect(); ui.label("Center of mass"); ui.label(format!("({}) mm", c.join(", "))); ui.end_row(); } let (mn, mx) = (&b["bbox"]["min"], &b["bbox"]["max"]); if let (Some(a), Some(z)) = (mn.as_array(), mx.as_array()) { let d: Vec = a.iter().zip(z).map(|(a, z)| format!("{:.3}", z.as_f64().unwrap_or(0.0) - a.as_f64().unwrap_or(0.0))).collect(); ui.label("Bounding box"); ui.label(format!("{} mm", d.join(" × "))); ui.end_row(); } } ui.label("Faces / edges / vertices"); ui.label(format!("{} / {} / {}", total["faces"], total["edges"], total["vertices"])); ui.end_row(); }); }); if !open { app.menu.props = None; } } #[cfg(test)] mod tests { use super::*; #[test] fn sectors_go_clockwise_from_the_top() { assert_eq!(sector(vec2(0.0, -50.0)), Some(0)); assert_eq!(sector(vec2(50.0, -50.0)), Some(1)); assert_eq!(sector(vec2(50.0, 0.0)), Some(2)); assert_eq!(sector(vec2(0.0, 50.0)), Some(4)); assert_eq!(sector(vec2(-50.0, 0.0)), Some(6)); assert_eq!(sector(vec2(-50.0, -50.0)), Some(7)); assert_eq!(sector(vec2(3.0, 3.0)), None); } #[test] fn list_goes_beside_the_ring_near_the_bottom() { let screen = Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0)); let c = radial_center(pos2(800.0, 300.0), screen); assert!(list_place(c, 300.0, screen).y > c.y + RY, "room below"); for at in [pos2(800.0, 900.0), pos2(1590.0, 900.0), pos2(10.0, 990.0)] { let c = radial_center(at, screen); let p = list_place(c, 400.0, screen); let r = Rect::from_min_size(p, vec2(LIST_W, 400.0)); assert!(screen.contains_rect(r), "{at:?}: {r:?}"); let ring = Rect::from_center_size(c, vec2(2.0 * (RX + 160.0), 2.0 * RY)); assert!(!ring.intersects(r), "{at:?}: list {r:?} over ring {ring:?}"); } } #[test] fn radial_menu_stays_on_screen() { let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0)); for at in [pos2(2.0, 2.0), pos2(1199.0, 799.0), pos2(600.0, 400.0), pos2(-50.0, 900.0)] { let c = radial_center(at, screen); for k in 0..DIRS { let r = slot_rect(c, k, vec2(160.0, 28.0)); assert!(screen.contains_rect(r), "{at:?} slot {k}: {r:?}"); } } } }