//! Context menus, one look everywhere. //! //! The viewport's right-click opens a list of commands for the selection or the thing under the //! cursor: faces, edges, bodies, sketch entities or empty space. The optional radial marking //! menu adds eight commands around the cursor, with Repeat at the top. With that preference on, //! holding the right button opens it too; releasing over a direction picks a 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 solidcraft_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, }, Occurrence { path: Vec, }, /// A browser group of items. Group { id: u64, }, /// The Origin folder. Origin, /// Document Settings › Units. Units, /// An assembly joint. Joint { id: u64, }, /// A command (toolbar button, panel drop-down row, S box row). ToolbarCommand { id: String, }, /// The toolbar's empty space. Toolbar, /// The workspace switcher. Workspace, /// The Named Views folder, and one view (saved or standard) in it. NamedViews, NamedView { name: String, }, /// A canvas (reference image). Canvas { id: u64, }, /// A component's Bodies, Sketches or Construction folder. Folder { component: u64, folder: String, }, } /// 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 { /// A view action (`ui.*`). pub fn action(id: &str, label: &str, icon: &str) -> Item { act(id, label, icon) } pub 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('#') } pub fn with(mut self, params: Value) -> Item { self.params = Some(params); self } pub fn on_if(self, enabled: bool) -> Item { self.on(enabled) } fn on(mut self, enabled: bool) -> Item { self.enabled &= enabled; self } pub 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 = solidcraft_engine::find_command(id).map(|c| c.info(&app.session)); let shortcut = match id { "edit.undo" => "Ctrl+Z".to_string(), "edit.redo" => "Ctrl+Y".to_string(), _ => crate::keymap::effective(app, id).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 list row chosen with the arrow keys (Enter runs it). pub key_row: Option, /// 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, /// A delete waiting for its confirmation (it takes or breaks other features). pub confirm: Option, /// The Properties window: title and measurements. pub props: Option<(String, Value)>, /// The viewport menu opened over something: it offers Select Other. pub over_geometry: bool, } #[derive(Clone, Debug, PartialEq)] pub enum RenameWhat { Body(String), Feature(u64), Component(u64), Group(u64), TimelineGroup(u64), Canvas(u64), View(String), Joint(u64), } #[derive(Clone, Debug)] pub struct Rename { pub what: RenameWhat, pub text: String, pub at: Pos2, pub(crate) 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 } /// The sketch a selected sketch curve or point belongs to. Geometry of a sketch that is not /// being edited selects as `ExternalSketchCurve` or `ExternalSketchPoint` /// (`viewport::hit_candidate`). Those two carry the owning sketch's feature id. The entities of /// the sketch being edited are `SketchCurve` and `SketchPoint` instead. The sketch-editing menu /// already covers that case. fn sel_sketch(app: &SolveApp) -> Option { app.session.selection.iter().find_map(|s| match s { Sel::ExternalSketchCurve { sketch, .. } | Sel::ExternalSketchPoint { sketch, .. } => Some(*sketch), _ => None, }) } /// 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.world_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::Occurrence { path } => app.session.occurrence_bodies(path, true).unwrap_or_default(), Target::Group { id } => crate::browser::group_bodies(app, *id), Target::Sketch { .. } | Target::Folder { .. } | Target::Canvas { .. } | Target::Origin | Target::Units | Target::Joint { .. } | Target::ToolbarCommand { .. } | Target::Toolbar | Target::Workspace | Target::NamedViews | Target::NamedView { .. } => 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.model_body_component(b), 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, "sketch.line", "Line"), cmd(app, "sketch.rectangle.two_point", "Rectangle"), act("ui.finishSketch", "Finish Sketch", "finish"), cmd(app, "sketch.circle.center", "Circle"), cmd(app, "edit.undo", "Undo").on(!app.session.undo.is_empty()), cmd(app, "sketch.dimension", "Sketch Dimension"), ]; } vec![ repeat, delete, cmd(app, "solid.press_pull", "Press Pull"), cmd(app, "solid.hole", "Hole"), cmd(app, "sketch.create", "Create Sketch"), cmd(app, "solid.extrude", "Extrude"), cmd(app, "edit.undo", "Undo").on(!app.session.undo.is_empty()), cmd(app, "solid.move", "Move/Copy"), ] } /// The list of a menu (under the ring for the viewport). pub fn items(app: &SolveApp, target: &Target) -> Vec { let mut v = 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::Occurrence { path } => occurrence_items(app, path), Target::Group { id } => crate::browser::group_items(app, *id), Target::Folder { component, folder } => crate::browser::folder_items(app, *component, folder), Target::Canvas { id } => crate::browser::canvas_items(app, *id), Target::Origin => crate::browser::origin_items(app), Target::NamedViews => vec![act("ui.newView", "New Named View", "perspective")], Target::Joint { id } => joint_items(app, *id), Target::ToolbarCommand { id } => crate::toolbar_custom::command_items(app, id), Target::Toolbar => crate::toolbar_custom::toolbar_items(app), Target::Workspace => { let mut v = vec![act("ui.workspace", "Design", "box").key("current")]; for w in ["Render", "Animation", "Simulation", "Manufacture", "Drawing"] { v.push(act("ui.workspace", w, "").key("coming later").on(false)); } v } Target::Units => crate::prefs::UNITS .iter() .map(|u| { let item = cmd(app, "document.units", u).with(json!({ "units": u })); if app.session.doc.units == *u { item.key("current") } else { item } }) .collect(), Target::NamedView { name } => crate::browser::view_items(app, name), }; // Several items of the folder selected: they can be grouped. if let Some(p) = crate::browser::selection_group(app, target) { v.push(Item::sep()); v.push(act("ui.groupSelected", "Group Selected", "folder").with(p)); } v } /// The feature a body was made by, and that feature's sketch if it has one. fn body_feature_and_sketch(app: &SolveApp, body: &str) -> (Option, Option) { let f = body_feature(app, body); let sketch = f .and_then(|f| app.session.doc.feature(f)) .and_then(|f| serde_json::to_value(&f.kind).ok()) .and_then(|v| v.get("sketch").and_then(Value::as_u64).or_else(|| v.as_object()?.values().find_map(|x| x.get("sketch")?.as_u64()))); (f, sketch) } /// The canvas menus follow Fusion's (`plan/fusion/ui/NOTES.md`, feature names only): Repeat on /// top, then what fits the selection, our extras after a separator. 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 let Some((_, label)) = &app.last_command { v.push(act("ui.repeat", &format!("Repeat {label}"), "redo")); v.push(Item::sep()); } // Everything under the cursor, for what lies beneath something else. if app.menu.over_geometry { v.push(act("ui.selectOther", "Select Other…", "").key("Hold click")); v.push(Item::sep()); } if app.session.active_sketch.is_some() { v.extend(sketch_entity_items(app)); v.push(Item::sep()); v.push(cmd(app, "sketch.trim", "Trim")); v.push(cmd(app, "sketch.offset", "Offset")); v.push(Item::sep()); v.push(act("ui.finishSketch", "Finish Sketch", "finish")); v.push(Item::sep()); v.push(cmd(app, "edit.undo", "Undo").on(!app.session.undo.is_empty())); v.push(cmd(app, "edit.redo", "Redo").on(!app.session.redo.is_empty())); return v; } // A sketch curve or point in the viewport gets the sketch's own menu, the one its browser row // opens, with Edit Sketch among it. Solid geometry in the selection keeps the body menu: a // right-click on a face still offers the face's commands. The picker already prefers a sketch // curve to the face it lies on (`pick_hits` offers curves before faces). A right-click that // resolved to a sketch curve therefore replaces any face in the selection with that curve. // The menu then follows the pick. let solid = has(|s| matches!(s, Sel::Face { .. } | Sel::Edge { .. } | Sel::Vertex { .. } | Sel::Body { .. })); if let Some(sketch) = sel_sketch(app).filter(|_| !solid) { v.extend(sketch_items(app, sketch)); } 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(); let (feature, sketch) = body_feature_and_sketch(app, &face); let hidden = app.session.visibility.hidden_bodies.contains(&face); v.push(cmd(app, "sketch.create", "Create Sketch")); v.push(cmd(app, "solid.extrude", "Extrude")); v.push(cmd(app, "construct.plane.offset", "Offset Plane")); v.push(cmd(app, "solid.shell", "Shell")); v.push(act("ui.editFeature", "Edit Feature", "").with(json!({ "feature": feature })).on(feature.is_some())); v.push(act("ui.editSketch", "Edit Profile Sketch", "sketch").with(json!({ "sketch": sketch })).on(sketch.is_some())); v.push(Item::sep()); v.push(act("ui.appearance", "Appearance", "appearance").key("A")); v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": [face] }))); v.push(Item::sep()); v.push(act("ui.delete", "Delete", "delete").key("Del")); v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": [face] }))); 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(Item::sep()); v.push(cmd(app, "solid.press_pull", "Press Pull")); v.push(cmd(app, "inspect.measure", "Measure")); } else if has(|s| matches!(s, Sel::Edge { .. })) { let body = sel.iter().find_map(|s| if let Sel::Edge { body, .. } = s { Some(body.clone()) } else { None }).unwrap_or_default(); let hidden = app.session.visibility.hidden_bodies.contains(&body); v.push(cmd(app, "solid.fillet", "Fillet")); v.push(cmd(app, "solid.chamfer", "Chamfer")); v.push(Item::sep()); v.push(act("ui.delete", "Delete", "delete").key("Del")); v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": [body] }))); v.push(Item::sep()); v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": body }))); v.push(Item::sep()); v.push(act("ui.tangentChain", "Select Tangent Chain", "")); v.push(cmd(app, "inspect.measure", "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, "solid.extrude", "Extrude")); v.push(cmd(app, "solid.revolve", "Revolve")); } else { // Empty space. 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" }))); v.push(act("ui.fit", "Fit", "fit")); v.push(act("ui.home", "Home View", "home")); v.push(Item::sep()); let anything_hidden = !app.session.visibility.hidden_bodies.is_empty() || !app.session.visibility.hidden_sketches.is_empty(); v.push(act("ui.showAll", "Show All", "eye").on(anything_hidden)); v.push(act("ui.showAll", "Unisolate", "").on(!app.session.visibility.hidden_bodies.is_empty())); v.push(Item::sep()); v.push(cmd(app, "solid.extrude", "Extrude")); v.push(cmd(app, "solid.fillet", "Fillet")); v.push(Item::sep()); v.push(cmd(app, "edit.undo", "Undo").on(!app.session.undo.is_empty())); v.push(cmd(app, "edit.redo", "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")); } v.push(act("ui.origin", if app.ui.show_origin { "Hide Origin" } else { "Show Origin" }, "origin")); return v; } if !sel.is_empty() { v.push(Item::sep()); v.push(act("ui.clear", "Clear Selection", "").key("Esc")); } v } fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec { let names = json!(bodies); let all_hidden = bodies.iter().all(|b| app.session.visibility.hidden_bodies.contains(b)); let one = bodies.len() == 1; let locked = bodies.iter().all(|b| app.ui.locked_bodies.contains(b)); let items = json!(bodies.iter().map(|b| json!({"type": "body", "name": b})).collect::>()); let mut v = vec![ act("ui.moveBodies", "Move/Copy", "move").key("M").with(json!({ "bodies": names })).on(!locked), cmd(app, "component.from_bodies", "Create Components from Bodies").with(json!({ "bodies": names })), Item::sep(), act("ui.material", "Physical Material", "").with(json!({ "bodies": names })), act("ui.appearance", "Appearance", "appearance").key("A").with(json!({ "bodies": names })), act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })), Item::sep(), act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })), act("ui.export", "Export…", "export").with(json!({ "bodies": names })), Item::sep(), act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": items })).on(!locked), cmd(app, "solid.remove", "Remove").with(json!({ "bodies": names })).on(!locked), act("ui.rename", "Rename", "").with(json!({ "body": bodies.first() })).on(one), Item::sep(), act(if all_hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": names })), act("ui.lock", if locked { "Unlock" } else { "Lock" }, "").with(json!({ "bodies": names })), act("ui.isolate", "Isolate", "").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 dims = app.ui.shown_dims.contains(&id); let editing = app.session.active_sketch == Some(id); let has_profiles = app.session.world_state().sketch(id).is_some_and(|s| !s.profiles.is_empty()); vec![ act("ui.extrudeSketch", "Extrude", "extrude").key("E").with(json!({ "sketch": id })).on(has_profiles && !editing), Item::sep(), act("ui.editSketch", "Edit Sketch", "sketch").with(json!({ "sketch": id })).on(!editing), act("ui.redefineSketch", "Redefine Sketch Plane", "plane").with(json!({ "sketch": id })), act("ui.exportDxf", "Export DXF…", "export").with(json!({ "sketch": id })), Item::sep(), act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })), act("ui.rename", "Rename", "").with(json!({ "feature": id })), Item::sep(), act("ui.lookAt", "Look At", "").with(json!({ "sketch": id })), act("ui.sketchProfile", if profiles { "Hide Profile" } else { "Show Profile" }, "").with(json!({ "sketch": id })), act("ui.sketchDims", if dims { "Hide Dimension" } else { "Show Dimension" }, "dimension").with(json!({ "sketch": id })), act(if visible { "ui.hideSketch" } else { "ui.showSketch" }, "Show/Hide", "eye").key("V").with(json!({ "sketch": id })), Item::sep(), act("ui.findTimeline", "Find in Timeline", "").with(json!({ "feature": id })), ] } /// 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() }; let rigid = j.kind == solidcraft_engine::doc::joints::JointKind::Rigid; vec![ act("ui.editJoint", "Edit Joint", "joint").with(json!({ "joint": id })), act("ui.editJoint", "Edit Joint Limits", "").with(json!({ "joint": id })).on(!rigid), act("ui.driveJoint", "Drive Joint", "").with(json!({ "joint": id })).on(!rigid), act("ui.animateJoint", "Animate Joint", "").with(json!({ "joint": id })).on(!rigid && !j.suppressed), act("ui.findJoint", "Find in Window", "").with(json!({ "joint": id })), cmd(app, "joint.edit", if j.suppressed { "Unsuppress" } else { "Suppress" }).with(json!({ "joint": id, "suppressed": !j.suppressed })), Item::sep(), cmd(app, "joint.delete", "Delete").key("Del").with(json!({ "joint": id })), act("ui.rename", "Rename", "").with(json!({ "joint": id })), ] } fn occurrence_items(app: &SolveApp, path: &[u64]) -> Vec { let Ok(placement) = app.session.occurrence_placement(path) else { return Vec::new() }; let mut entries = component_items(app, placement.component); if app.session.occurrence_scope_count(path).is_ok_and(|n| n > 1) { entries.insert(0, Item::heading("Shared child: edits affect all copies of its parent")); } let bodies = app.session.occurrence_bodies(path, true).unwrap_or_default(); for item in &mut entries { match item.id.as_str() { "ui.activate" => { item.params = Some(json!({"component": placement.component, "occurrence": placement.occurrence, "path": path})); item.enabled = app.session.active_occurrence_path.as_deref() != Some(path); } "occurrence.ground" => { item.label = if placement.grounded { "Unground" } else { "Ground" }.into(); item.params = Some(json!({"occurrence": placement.occurrence, "grounded": !placement.grounded})); } "ui.moveOccurrence" => { item.params = Some(json!({"occurrence": placement.occurrence, "path": path})); item.enabled = !placement.grounded; } "ui.properties" => item.params = Some(json!({"occurrence": placement.occurrence, "path": path})), "occurrence.copy" => item.params = Some(json!({"occurrence": placement.occurrence, "translate": [10,10,0]})), "component.create" => item.params = Some(json!({"parent": placement.component, "parent_path": path})), "component.paste_new" => { item.label = "Paste New (Own Features)".into(); item.params = Some(json!({"component":placement.component,"occurrence":placement.occurrence,"path":path,"translate":[10,10,0]})); } "ui.rename" => item.label = "Rename Component".into(), "ui.appearance" => item.label = "Component Appearance".into(), "ui.material" => item.label = "Component Physical Material".into(), "ui.delete" => item.params = Some(json!({"occurrences": [placement.occurrence]})), "ui.hide" | "ui.show" => { item.id = "browser.visibility".into(); item.params = Some(json!({"visible": app.session.occurrence_hidden(path), "paths": [path]})); item.enabled = true; } "ui.isolate" => { item.id = "ui.isolateOccurrence".into(); item.params = Some(json!({"path": path})); } "ui.export" | "ui.saveMesh" => item.params = Some(json!({"bodies": bodies})), _ => {} } } entries } 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.session.visibility.hidden_bodies.contains(b)); let names = json!(bodies); let mut v = Vec::new(); if id != 0 { let grounded = occ.is_some_and(|o| o.grounded); let oid = occ.map(|o| o.id).unwrap_or(0); v.push(act("ui.activate", "Activate", "component").with(json!({ "component": id })).on(!active)); v.push(cmd(app, "occurrence.ground", if grounded { "Unground" } else { "Ground" }).with(json!({ "occurrence": oid, "grounded": !grounded }))); v.push(act("ui.moveOccurrence", "Move/Copy", "move").key("M").with(json!({ "occurrence": oid })).on(!grounded && occ.is_some())); v.push(cmd(app, "component.capture_position", "Capture Position").on(!app.session.pending_moves.is_empty())); v.push(cmd(app, "component.revert_position", "Revert Position").on(!app.session.pending_moves.is_empty())); v.push(Item::sep()); } else { v.push(act("ui.activate", "Activate", "component").with(json!({ "component": id })).on(!active)); } v.push(cmd(app, "component.create", "New Component").with(json!({ "parent": id }))); if id == 0 { v.push(cmd(app, "component.from_bodies", "Create Components from Bodies").with(json!({ "bodies": names })).on(!bodies.is_empty())); } v.push(Item::sep()); v.push(act("ui.material", "Physical Material", "").with(json!({ "bodies": names })).on(!bodies.is_empty())); v.push( act("ui.appearance", "Appearance", "appearance") .key("A") .with(if id == 0 { json!({ "bodies": names }) } else { json!({"component": id}) }) .on(id != 0 || !bodies.is_empty()), ); v.push(act("ui.properties", "Properties", "measure").with(json!({ "component": id }))); v.push(Item::sep()); v.push(act("ui.export", "Export…", "export").with(json!({ "bodies": names })).on(!bodies.is_empty())); v.push(act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })).on(!bodies.is_empty())); v.push(Item::sep()); if id != 0 { 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.push(act("ui.delete", "Delete", "delete").key("Del").with(json!({ "occurrences": [occ.map(|o| o.id)] })).on(occ.is_some())); } v.push(act("ui.rename", "Rename", "").with(json!({ "component": id }))); v.push(Item::sep()); v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": names })).on(!bodies.is_empty())); v.push(act("ui.showAll", "Show All Bodies", "eye")); v.push(act("ui.isolate", "Isolate", "").with(json!({ "bodies": names })).on(!bodies.is_empty() && id != 0)); v.push(act("ui.newGroup", "New Group", "folder").with(json!({ "component": id }))); v } /// Sketch entity items: construction, fix, delete, dimension and the constraints the selection /// can take. fn sketch_entity_items(app: &SolveApp) -> Vec { use solidcraft_engine::sketch::CurveKind; let Some(sid) = app.session.active_sketch else { return Vec::new() }; let st = app.session.world_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(); // Selection order: a two-entity constraint keeps the first and moves the second. let mut picked: Vec = Vec::new(); for s in &app.session.selection { match s { Sel::SketchCurve { id } => { picked.push(id.clone()); 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 } => { picked.push(id.clone()); points.push(id.clone()); } _ => {} } } let mut v = Vec::new(); if curves.is_empty() && points.is_empty() { if crate::sketch_text::selected_entity(app).is_some() { v.push(cmd(app, "sketch.edit_text", "Edit Text")); } v.push(cmd(app, "sketch.line", "Line")); v.push(cmd(app, "sketch.rectangle.two_point", "Rectangle")); v.push(cmd(app, "sketch.circle.center", "Circle")); v.push(cmd(app, "sketch.dimension", "Sketch Dimension")); return v; } let ents: Vec = curves.iter().chain(points.iter()).cloned().collect(); if crate::sketch_text::selected_entity(app).is_some() { v.push(cmd(app, "sketch.edit_text", "Edit Text")); } v.push(cmd(app, "sketch.move", "Move/Copy").key("M")); if !curves.is_empty() { v.push(cmd(app, "sketch.construction", "Normal / Construction").with(json!({ "curves": curves }))); } if curves.len() == 1 && ss.sketch.freeform_offsets.iter().any(|offset| offset.output == curves[0]) { v.push(cmd(app, "sketch.offset.break", "Break Offset Association").with(json!({"curve":curves[0]}))); } 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, "sketch.dimension", "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, "sketch.constraint.horizontal_vertical", "Horizontal/Vertical").with(json!({ "line": lines[0] }))); } if p == 2 && n == 2 { c.push(cmd(app, "sketch.constraint.coincident", "Coincident").with(json!({ "a": points[0], "b": points[1] }))); c.push(cmd(app, "sketch.constraint.horizontal_vertical", "Horizontal/Vertical").with(json!({ "points": points }))); } if p == 1 && curves.len() == 1 && n == 2 { c.push(cmd(app, "sketch.constraint.coincident", "Coincident").with(json!({ "a": picked[0], "b": picked[1] }))); if l == 1 { c.push(cmd(app, "sketch.constraint.midpoint", "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, "sketch.constraint.parallel", "Parallel").with(ab.clone())); c.push(cmd(app, "sketch.constraint.perpendicular", "Perpendicular").with(ab.clone())); c.push(cmd(app, "sketch.constraint.collinear", "Collinear").with(ab.clone())); c.push(cmd(app, "sketch.constraint.equal", "Equal").with(ab)); } if r == 2 && n == 2 { let ab = json!({ "a": rounds[0], "b": rounds[1] }); c.push(cmd(app, "sketch.constraint.concentric", "Concentric").with(ab.clone())); c.push(cmd(app, "sketch.constraint.equal", "Equal").with(ab.clone())); c.push(cmd(app, "sketch.constraint.tangent", "Tangent").with(ab)); } if l == 1 && r == 1 && n == 2 { c.push(cmd(app, "sketch.constraint.tangent", "Tangent").with(json!({ "a": picked[0], "b": picked[1] }))); } if p == 2 && l == 1 && n == 3 { c.push(cmd(app, "sketch.constraint.symmetry", "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.selectOther" => { crate::select_other::open(app, at); } "ui.delete" if item.params.is_some() => crate::delete::request(app, p), "ui.delete" => delete(app), "ui.finishSketch" => app.finish_sketch(), "ui.hide" => { for b in bodies { if !app.session.visibility.hidden_bodies.contains(&b) { app.session.visibility.hidden_bodies.push(b); } } } "ui.show" => app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b)), "ui.showAll" => { let _ = app.run("browser.visibility", json!({"folder":"bodies", "visible":true})); app.session.visibility.hidden_sketches.clear(); } "ui.isolate" => { app.session.visibility.hidden_bodies = app.session.world_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("solid.move"); } "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 if let Some(c) = id_of(&p, "canvas") { RenameWhat::Canvas(c) } else if let Some(v) = p.get("view").and_then(Value::as_str) { RenameWhat::View(v.to_string()) } else if let Some(j) = id_of(&p, "joint") { RenameWhat::Joint(j) } else { return; }; start_rename(app, what, at); } "ui.properties" => { let command = if p.get("occurrence").is_some() { "occurrence.properties" } else if p.get("component").is_some() { "component.properties" } else { "inspect.measure" }; let params = if command == "inspect.measure" { json!({ "bodies": bodies }) } else { p.clone() }; match app.session.execute(command, ¶ms) { Ok(v) => { let title = v["name"] .as_str() .map(str::to_string) .unwrap_or_else(|| if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) }); app.menu.props = Some((title, 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", "igs", "stl", "3mf", "obj"])) { let _ = app.run("file.export", 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("inspect.section", json!({"clear": true}))), "ui.fit" => app.animate_view("fit"), "ui.home" => app.animate_view("home"), // One command for the whole selection: all fixed unless all already are. "ui.fix" => drop(app.run("sketch.constraint.fix", json!({ "entities": strs(&p, "entities") }))), "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.sketchDims" => { if let Some(id) = id_of(&p, "sketch") { if app.ui.shown_dims.contains(&id) { app.ui.shown_dims.retain(|x| *x != id); } else { app.ui.shown_dims.push(id); } } } "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 id_of(&p, "component").is_some() { let _ = app.run("component.activate", p); } } "ui.moveOccurrence" => { if let Some(o) = id_of(&p, "occurrence") { let mut movement = crate::browser::OccMove::new(o); movement.had_pending = app.session.pending_moves.contains_key(&o); movement.path = p.get("path").and_then(Value::as_array).map(|p| p.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); movement.baseline = app .session .pending_moves .get(&o) .copied() .or_else(|| app.session.doc.occurrences.iter().find(|occ| occ.id == o).map(|occ| occ.transform)); app.tree.occurrence_move = Some(movement); } } "ui.isolateOccurrence" => { let _ = app.run("browser.visibility", json!({"visible": true, "paths": [p["path"]], "isolate": true})); } "ui.canvasVisible" => drop(app.run("canvas.edit", p)), "ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.toolbarCustomize" | "ui.shortcutPin" => { crate::toolbar_custom::run(app, &item.id, &p) } "ui.workspace" => {} "ui.findJoint" => { if let Some(id) = id_of(&p, "joint") { crate::dialogs_assembly::find_joint(app, id); } } "ui.editJoint" | "ui.driveJoint" | "ui.animateJoint" => { if let Some(id) = id_of(&p, "joint") { let d = match item.id.as_str() { "ui.editJoint" => crate::dialogs_assembly::edit_joint(app, id), "ui.animateJoint" => crate::dialogs_assembly::animate_joint(app, id), _ => crate::dialogs_assembly::drive_joint(app, id), }; if let Some(d) = d { app.tool = None; app.dialog = Some(d); } } } "ui.newView" => drop(crate::browser::save_view(app, None)), "ui.restoreView" => { if let Some(v) = p.get("view").and_then(Value::as_str) { crate::browser::restore_view(app, v); } } "ui.updateView" => { if let Some(v) = p.get("view").and_then(Value::as_str) { crate::browser::save_view(app, Some(v)); } } "ui.folderVisible" => { if let Some(f) = p.get("folder").and_then(Value::as_str) { crate::browser::toggle_folder(app, id_of(&p, "component").unwrap_or(0), f); } } "ui.originAll" => { app.ui.show_origin = true; app.session.visibility.hidden_origin.retain(|h| !["O", "X", "Y", "Z", "XY", "XZ", "YZ"].contains(&h.as_str())); } "ui.originPart" => { let show = p.get("show").and_then(Value::as_bool).unwrap_or(true); for k in strs(&p, "keys") { app.session.visibility.hidden_origin.retain(|h| *h != k); if !show { app.session.visibility.hidden_origin.push(k); } } } "ui.editFeature" => { if let Some(f) = id_of(&p, "feature") { app.edit_feature(f); } } "ui.appearance" => { if item.params.is_some() { let items: Vec = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect(); let _ = app.run("select.set", json!({ "items": items })); } app.start("appearance.assign"); if let Some(name) = id_of(&p, "component").and_then(|id| app.session.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone())) && let Some(d) = app.dialog.as_mut() && let crate::dialogs::Kind::Appearance(k) = &mut d.kind { if let Some(id) = id_of(&p, "component") && let Some(look) = app.session.doc.appearances.components.get(&id) { k.color = look.color; k.opacity = look.opacity; k.look = Some(look.name.clone()); } k.component = Some(name); } } "ui.material" => { let items: Vec = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect(); let _ = app.run("select.set", json!({ "items": items })); app.start("material.assign"); } "ui.saveMesh" => { 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}.stl"), &["stl"])) { let _ = app.run("file.save_mesh", json!({ "path": path, "bodies": bodies })); } } "ui.exportDxf" => { if let Some(id) = id_of(&p, "sketch") { let name = app.session.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default(); if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.dxf"), &["dxf"])) { let _ = app.run("sketch.export_dxf", json!({ "sketch": id, "path": path })); } } } "ui.extrudeSketch" => { if let Some(id) = id_of(&p, "sketch") { let n = app.session.world_state().sketch(id).map_or(0, |s| s.profiles.len()); let items: Vec = (0..n).map(|i| json!({"type": "profile", "sketch": id, "index": i})).collect(); let _ = app.run("select.set", json!({ "items": items })); app.start("solid.extrude"); } } "ui.lookAt" => { let st = app.session.world_state(); if let Some(ss) = id_of(&p, "sketch").and_then(|id| st.sketch(id)) { let mut to = app.cam.looking_from(ss.plane.normal()); to.target = ss.plane.origin; app.animate_to(to); } } "ui.nav" => { app.viewport.nav = match p.get("mode").and_then(Value::as_str) { Some("pan") => Some(crate::viewport::NavMode::Pan), Some("zoom") => Some(crate::viewport::NavMode::Zoom), Some("orbit") => Some(crate::viewport::NavMode::Orbit), _ => None, }; } "ui.editCanvas" | "ui.calibrateCanvas" => { if let Some(c) = id_of(&p, "canvas") { app.tree.canvas_panel = Some(crate::browser::CanvasPanel::new(app, c, item.id == "ui.calibrateCanvas")); } } "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::delete::delete_selection(app); } /// Show/Hide (the V key): what is selected in the viewport or picked in the browser flips its /// visibility (bodies, faces' and edges' bodies, sketches, construction and origin planes and /// axes, components' bodies, canvases). When some are shown and some hidden, all are hidden. pub fn toggle_visibility(app: &mut SolveApp) { use solidcraft_engine::doc::FeatureKind; let paths: Vec> = app.session.selection.iter().filter_map(|s| if let Sel::Occurrence { path } = s { Some(path.clone()) } else { None }).collect(); if !paths.is_empty() { let visible = !paths.iter().any(|p| !app.session.occurrence_hidden(p)); let _ = app.run("browser.visibility", json!({"visible": visible, "paths": paths})); } let mut bodies: Vec = touched_bodies(app); for c in app.tree.picked_components.clone() { bodies.extend(component_bodies(app, c)); } let sketches: Vec = app .session .selection .iter() .filter_map(|s| if let Sel::Feature { id } = s { Some(*id) } else { None }) .filter(|id| app.session.doc.feature(*id).is_some_and(|f| matches!(f.kind, FeatureKind::Sketch { .. }))) .collect(); let origin: Vec = app .session .selection .iter() .filter_map(|s| match s { Sel::Plane { name } | Sel::Axis { name } => Some(name.clone()), _ => None, }) .collect(); let canvases = app.tree.picked_canvases.clone(); let any_visible = bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b)) || sketches.iter().any(|s| crate::browser::sketch_visible(app, *s)) || origin.iter().any(|o| !app.session.visibility.hidden_origin.contains(o)) || app.session.doc.canvases.iter().any(|c| canvases.contains(&c.id) && c.visible); let hide = any_visible; app.session.visibility.hidden_bodies.retain(|b| !bodies.contains(b)); app.session.visibility.hidden_origin.retain(|o| !origin.contains(o)); if hide { app.session.visibility.hidden_bodies.extend(bodies); app.session.visibility.hidden_origin.extend(origin); } for s in sketches { crate::browser::set_sketch_visible(app, s, !hide); } for c in canvases { let _ = app.run("canvas.edit", json!({ "canvas": c, "visible": !hide })); } } /// Rename what is selected (F2): one body, sketch or feature, construction plane, component /// picked in the browser, or canvas. pub fn rename_selection(app: &mut SolveApp, at: Pos2) -> bool { let what = match (app.session.selection.as_slice(), app.tree.picked_components.as_slice(), app.tree.picked_canvases.as_slice()) { ([Sel::Body { name }], [], []) => RenameWhat::Body(name.clone()), ([Sel::Feature { id }], [], []) => RenameWhat::Feature(*id), ([Sel::Occurrence { path }], [], []) => match app.session.occurrence_placement(path) { Ok(p) => RenameWhat::Component(p.component), Err(_) => return false, }, ([Sel::Plane { name }], [], []) => match app.session.doc.find_feature(name) { Some(f) => RenameWhat::Feature(f.id), None => return false, }, ([], [c], []) => RenameWhat::Component(*c), ([], [], [c]) => RenameWhat::Canvas(*c), _ => return false, }; start_rename(app, what, at); true } /// Add the edges that continue the selected edges smoothly to the selection. fn tangent_chain(app: &mut SolveApp) { let st = app.session.world_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(), RenameWhat::TimelineGroup(g) => doc.timeline_groups.iter().find(|group| group.id == *g).map(|group| group.name.clone()).unwrap_or_default(), RenameWhat::Canvas(c) => doc.canvases.iter().find(|x| x.id == *c).map(|x| x.name.clone()).unwrap_or_default(), RenameWhat::View(v) => v.clone(), RenameWhat::Joint(j) => doc.assembly.joints.iter().find(|x| x.id == *j).map(|x| x.name.clone()).unwrap_or_default(), }; app.menu.rename = Some(Rename { what, text, at, focused: false }); } pub(crate) 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.session.visibility.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("timeline.rename", 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 })), RenameWhat::TimelineGroup(g) => drop(app.run("timeline.rename_group", json!({ "group": g, "name": name }))), RenameWhat::Canvas(c) => drop(app.run("canvas.edit", json!({ "canvas": c, "name": name }))), RenameWhat::View(v) => drop(app.run("view.rename", json!({ "view": v, "name": name }))), RenameWhat::Joint(j) => drop(app.run("joint.edit", json!({ "joint": j, "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) { app.menu.over_geometry = app.viewport.hover.is_some(); crate::select_other::close(app); 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 { .. }) || crate::sketch_text::entity_for_selection(app, &sel).is_some()) && !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.key_row = None; app.menu.radial = target == Target::Viewport && app.preferences.radial_context_menu; 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) { if !app.preferences.radial_context_menu { app.menu.press = None; app.menu.flick = false; app.menu.radial = false; return; } 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() && app.preferences.nav != crate::prefs::OPENSCAD_NAV; 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) } } crate::browser::dims_overlay(app, ctx); 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 = crate::frame::area(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 display_label = crate::i18n::tr_label(&item.label); let galley = painter.layout_no_wrap(display_label.to_string(), 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(crate::i18n::tr_label(&item.shortcut).to_string()); } } }); inside |= resp.response.contains_pointer(); list_place(c, list_height(&list), screen) } else { at }; // Up/Down choose a row of the list, Enter runs it. let (up, down, enter) = ctx.input(|i| (i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::Enter))); let usable: Vec = list.iter().enumerate().filter(|(_, i)| i.enabled && !i.is_sep() && !i.is_heading()).map(|(k, _)| k).collect(); if up || down { let at = app.menu.key_row.and_then(|r| usable.iter().position(|u| *u == r)); let next = match (at, down) { (None, true) => Some(0), (None, false) => usable.len().checked_sub(1), (Some(k), true) => Some((k + 1) % usable.len().max(1)), (Some(k), false) => Some((k + usable.len().max(1) - 1) % usable.len().max(1)), }; app.menu.key_row = next.and_then(|k| usable.get(k).copied()); } if enter && let Some(item) = app.menu.key_row.and_then(|r| list.get(r)) { picked = Some(item.clone()); } let key_row = app.menu.key_row; let resp = crate::frame::area(egui::Id::new("sc_context_menu")).fixed_pos(list_at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| { menu_frame(ui, |ui| { for (k, item) in list.iter().enumerate() { if let Some(p) = list_row_lit(ui, item, key_row == Some(k)) { 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 { list_row_lit(ui, item, false) } /// A list row, drawn highlighted when `lit` (the keyboard's choice). pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> 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 text = ui.painter().layout(crate::i18n::tr_label(&item.label).to_uppercase(), FontId::proportional(10.5), t.text_dim, w - 16.0); let (r, _) = ui.allocate_exact_size(vec2(w, (text.size().y + 8.0).max(20.0)), Sense::hover()); ui.painter().galley(pos2(r.left() + 8.0, r.top() + 4.0), text, t.text_dim); return None; } let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), if item.enabled { Sense::click() } else { Sense::hover() }); crate::scenario::publish_handle(&format!("menu:{}", item.id), r.center()); if item.enabled && (resp.hovered() || lit) { 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) }; let display_label = crate::i18n::tr_label(&item.label); ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, display_label, FontId::proportional(13.0), text); if !item.shortcut.is_empty() { let display_shortcut = crate::i18n::tr_label(&item.shortcut); ui.painter().text(pos2(r.right() - 10.0, r.center().y), Align2::RIGHT_CENTER, display_shortcut, FontId::proportional(11.5), t.text_dim); } (item.enabled && resp.clicked()).then(|| item.clone()) } fn show_rename(app: &mut SolveApp, ctx: &egui::Context) { // Feature-history folders own their editor inside the row. Keep the shared rename state so // automation and keyboard handling still use the same path, but do not draw the floating box. if app.preferences.timeline_left && matches!(app.menu.rename.as_ref().map(|rename| &rename.what), Some(RenameWhat::TimelineGroup(_))) { return; } let Some(mut r) = app.menu.rename.take() else { return }; let t = Tokens::get(); let mut done: Option = None; let resp = crate::frame::area(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(crate::i18n::tr_label("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); crate::frame::window(ctx, format!("Properties: {title}"), crate::frame::Width::Normal) .id(egui::Id::new("sc_props")) .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(crate::i18n::tr_label("Material")); ui.label(b["material"].as_str().unwrap_or("Default")); ui.end_row(); ui.label(crate::i18n::tr_label("Density")); ui.label(format!("{:.3} g/cm³", f(b, "density_g_cm3"))); ui.end_row(); } ui.label(crate::i18n::tr_label("Mass")); ui.label(format!("{:.3} g", f(total, "mass_g"))); ui.end_row(); ui.label(crate::i18n::tr_label("Volume")); ui.label(crate::prefs::show_mm(app, f(total, "volume_mm3"), 3)); ui.end_row(); ui.label(crate::i18n::tr_label("Area")); ui.label(crate::prefs::show_mm(app, f(total, "area_mm2"), 2)); ui.end_row(); let geometry = if total.get("center_of_mass").is_some() { Some(total) } else { bodies.first().filter(|_| bodies.len() == 1) }; if let Some(b) = geometry { if let Some(c) = b["center_of_mass"].as_array() { let c: Vec = c.iter().map(|x| crate::prefs::show_mm_bare(app, x.as_f64().unwrap_or(f64::NAN))).collect(); ui.label(crate::i18n::tr_label("Center of mass")); ui.label(format!("({}) {}", c.join(", "), crate::prefs::unit_scale(&app.session.doc.units).1)); 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)| crate::prefs::show_mm_bare(app, z.as_f64().unwrap_or(0.0) - a.as_f64().unwrap_or(0.0))) .collect(); ui.label(crate::i18n::tr_label("Bounding box")); ui.label(format!("{} {}", d.join(" × "), crate::prefs::unit_scale(&app.session.doc.units).1)); ui.end_row(); } } ui.label(crate::i18n::tr_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 viewport_right_click_keeps_list_menu_and_radial_is_opt_in() { let mut h = crate::scenario::Harness::new(); h.app.home.open = false; h.settle(); let point = h.at(&json!({"world":[0,0,0]})).unwrap(); h.step(&json!({"click":point,"button":"right"})).unwrap(); assert!(h.app.viewport.context_menu.is_some(), "ordinary right click must still open a menu"); let list = describe(&h.app); assert!(list["radial"].as_array().unwrap().is_empty()); assert!(list["items"].as_array().unwrap().iter().any(|item| item["id"] == "ui.origin" && item["enabled"] == true)); h.step(&json!({"click":{"handle":"menu:ui.origin"}})).unwrap(); assert!(!h.app.ui.show_origin && h.app.viewport.context_menu.is_none(), "the ordinary menu must execute Hide Origin and close"); h.call("ui.prefs", json!({"radial_context_menu":true})); h.step(&json!({"click":point,"button":"right"})).unwrap(); assert_eq!(describe(&h.app)["radial"].as_array().unwrap().len(), DIRS); open_for(&mut h.app, pos2(point[0] as f32, point[1] as f32), Target::Origin); assert!(!h.app.menu.radial, "browser menus remain lists when radial menus are enabled"); } #[test] fn disabled_radial_menu_does_not_capture_right_button_hold() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default()); let ctx = egui::Context::default(); let point = pos2(600.0, 400.0); app.viewport.mouse = Some(point); for (time, events) in [ ( 0.0, vec![ egui::Event::PointerMoved(point), egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: true, modifiers: egui::Modifiers::NONE, }, ], ), (1.0, vec![]), ] { let mut output = ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |_| track_hold(&mut app, &ctx)); output.textures_delta.clear(); } assert!(app.viewport.context_menu.is_none() && app.menu.press.is_none() && !app.menu.flick); } #[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:?}"); } } } }