From fa8da64613e1bda8e9099e9aa7a7ab9d0ec9f1e2 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 01:28:08 +0000 Subject: [PATCH] Context menus: radial marking menu in the viewport (Repeat on top, Undo, Delete, Press Pull, Create Sketch, Extrude, Move/Copy, Hole; right press-and-hold or a right drag on the ring flicks to pick), contextual lists for faces, edges, bodies, sketch entities (construction, fix, applicable constraints) and empty space; browser menus for bodies, sketches and components; rename box, Properties window; body.rename and sketch.redefine commands; ui.menu / ui.menuPick / ui.rename control methods Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/browser.rs | 156 ++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/sketch.rs | 2 +- crates/ui-egui/src/browser.rs | 857 ++++++++++++++++--- crates/ui-egui/src/context_menu.rs | 1255 ++++++++++++++++++++++++++-- crates/ui-egui/src/control.rs | 40 +- crates/ui-egui/src/icons.rs | 5 + crates/ui-egui/src/lib.rs | 19 + crates/ui-egui/src/menu_tests.rs | 164 ++++ crates/ui-egui/src/viewport.rs | 43 +- docs/control-protocol.md | 7 +- 11 files changed, 2333 insertions(+), 217 deletions(-) create mode 100644 crates/engine/src/cmd/browser.rs create mode 100644 crates/ui-egui/src/menu_tests.rs diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs new file mode 100644 index 00000000..13c81a29 --- /dev/null +++ b/crates/engine/src/cmd/browser.rs @@ -0,0 +1,156 @@ +//! Browser commands: renaming bodies, moving a sketch to another plane. + +use serde_json::{Value, json}; + +use solvecraft_doc::FeatureKind; + +use super::CommandSpec; +use crate::params::{bad, str_}; +use crate::{Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("body.rename", "Rename Body", rename_body).params("body: its name, name: the new name (features that use the body follow)"), + CommandSpec::new("sketch.redefine", "Redefine Sketch Plane", redefine_sketch) + .icon("plane") + .params("sketch: id or name; plane: XY|XZ|YZ | construction plane name | {face: [x,y,z]} | {origin, normal} (the sketch keeps its geometry)"), +]; + +/// Replace every string equal to `old` with `new`; true when something changed. +fn replace_str(v: &mut Value, old: &str, new: &str, depth: usize) -> bool { + if depth > 64 { + return false; + } + match v { + Value::String(s) if s == old => { + *s = new.to_string(); + true + } + Value::Array(a) => a.iter_mut().fold(false, |acc, x| replace_str(x, old, new, depth + 1) | acc), + Value::Object(o) => o.values_mut().fold(false, |acc, x| replace_str(x, old, new, depth + 1) | acc), + _ => false, + } +} + +fn rename_body(s: &mut Session, p: &Value) -> Result { + let cmd = "body.rename"; + let old = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must be a body name"))?.to_string(); + let name = + str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?; + if name == old { + return Ok(json!({"body": old, "name": name})); + } + let st = s.model.state(); + let b = st.body(&old).ok_or_else(|| bad(cmd, format!("no body `{old}`")))?; + if st.body(name).is_some() { + return Err(bad(cmd, format!("another body is called `{name}`"))); + } + // The body is the k-th new body of its feature; the names before it are kept as they are. + let fid = b.feature; + let siblings: Vec = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect(); + let k = siblings.iter().position(|n| *n == old).unwrap_or(0); + let d = s.doc_mut(); + let f = d.feature_mut(fid).ok_or_else(|| bad(cmd, "the body's feature is gone"))?; + while f.body_names.len() <= k { + let i = f.body_names.len(); + f.body_names.push(siblings.get(i).cloned().unwrap_or_default()); + } + if let Some(slot) = f.body_names.get_mut(k) { + *slot = name.to_string(); + } + // Later features refer to bodies by name. + let mut failed = None; + for f in d.features.iter_mut().filter(|f| f.id != fid) { + let Ok(mut v) = serde_json::to_value(&f.kind) else { continue }; + if replace_str(&mut v, &old, name, 0) { + match serde_json::from_value(v) { + Ok(k) => f.kind = k, + Err(e) => failed = Some(e.to_string()), + } + } + } + if let Some(e) = failed { + return Err(bad(cmd, e)); + } + if let Some(m) = d.materials.remove(&old) { + d.materials.insert(name.to_string(), m); + } + if let Some(c) = d.body_components.remove(&old) { + d.body_components.insert(name.to_string(), c); + } + Ok(json!({"body": old, "name": name})) +} + +fn redefine_sketch(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.redefine"; + let key = match p.get("sketch") { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(x)) => x.clone(), + _ => return Err(bad(cmd, "`sketch` must be an id or name")), + }; + let id = match s.doc.find_feature(&key) { + Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => f.id, + _ => return Err(bad(cmd, format!("no sketch `{key}`"))), + }; + if p.get("plane").is_none() { + return Err(bad(cmd, "`plane` is required")); + } + let plane = super::sketch::plane_ref(s, p, cmd)?; + let (vals, _) = s.doc.param_values(); + s.doc.resolve_plane(&vals, &plane, 0)?; + if let Some(FeatureKind::Sketch { plane: slot, .. }) = s.doc_mut().feature_mut(id).map(|f| &mut f.kind) { + *slot = plane; + } + Ok(json!({ "sketch": id })) +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } + } + + fn names(s: &Session) -> Vec { + s.model.state().bodies.iter().map(|b| b.name.clone()).collect() + } + + #[test] + fn renamed_body_keeps_its_users() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10})); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]})); + let [a, b] = [names(&s)[0].clone(), names(&s)[1].clone()]; + run(&mut s, "FusionMoveCommand", json!({"bodies": [b.clone()], "translate": [0, 0, 5]})); + run(&mut s, "body.rename", json!({"body": b, "name": "Lid"})); + assert_eq!(names(&s), vec![a.clone(), "Lid".to_string()]); + assert!(s.model.results.iter().all(|r| r.error.is_none()), "the move still finds its body"); + // Duplicate names and unknown bodies are refused; undo restores the old name. + assert!(s.execute("body.rename", &json!({"body": "Lid", "name": a})).is_err()); + assert!(s.execute("body.rename", &json!({"body": "Nope", "name": "X"})).is_err()); + assert!(s.execute("body.rename", &json!({"body": "Lid", "name": " "})).is_err()); + run(&mut s, "UndoCommand", json!({})); + assert_eq!(names(&s)[1], b); + } + + #[test] + fn redefined_sketch_moves_its_extrude() { + let mut s = Session::default(); + let sk = run(&mut s, "SketchCreate", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 20]})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "Extrude", json!({"distance": 5})); + let zmax = |s: &mut Session| run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"]["max"][2].as_f64().unwrap(); + assert!((zmax(&mut s) - 5.0).abs() < 1e-6); + run(&mut s, "sketch.redefine", json!({"sketch": sk, "plane": "XZ"})); + let z = zmax(&mut s); + assert!(z > 9.0, "the profile stands up on XZ: {z}"); + assert!(s.execute("sketch.redefine", &json!({"sketch": sk, "plane": "nope"})).is_err()); + assert!(s.execute("sketch.redefine", &json!({"sketch": 999, "plane": "XY"})).is_err()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1cfa4273..6c0b44fd 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,6 +2,7 @@ //! toolbar placement (`tab`, `panel`) follows Fusion's Design workspace so the registry doubles //! as the parity metric (`cargo xtask parity`). +mod browser; mod clipboard; mod component; mod edit; @@ -125,6 +126,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(features::COMMANDS.iter()); v.extend(features_more::COMMANDS.iter()); v.extend(component::COMMANDS.iter()); + v.extend(browser::COMMANDS.iter()); v.extend(edit::COMMANDS.iter()); v.extend(clipboard::COMMANDS.iter()); v.extend(parameters::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index df490924..c62321ef 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -284,7 +284,7 @@ pub(super) fn result(sk_out: (Vec, Vec), info: Value) -> Value { // --------------------------------------------------------------------------------------------- // Sketch lifecycle -fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { +pub(super) fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { let base = match p.get("plane") { None => PlaneRef::Origin { name: "XY".into() }, Some(Value::String(n)) => { diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index ba45873a..fcb280c8 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -1,32 +1,481 @@ -//! The browser (left): document settings, origin, bodies and sketches. +//! The browser (left): the design as a tree of components, each with its bodies, sketches and +//! construction planes, plus document settings and the origin. +//! +//! Rows are drawn in code (icons from [`icons`]) and act through commands. A click selects +//! (Ctrl adds, Shift extends over the visible rows), hovering a body row lights it up in the +//! viewport, a double-click on a sketch edits it, and a right-click opens the item's menu +//! ([`crate::context_menu`]). -use egui::{Color32, RichText, Sense, vec2}; -use serde_json::json; +use std::collections::BTreeSet; + +use egui::{Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2}; +use serde_json::{Value, json}; use solvecraft_engine::Sel; use solvecraft_engine::doc::FeatureKind; +use crate::context_menu::{Item, Target}; use crate::theme::Tokens; use crate::{SolveApp, icons}; -fn row(ui: &mut egui::Ui, icon: &str, label: &str, color: Option, selected: bool) -> egui::Response { - let t = Tokens::get(); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::click()); - if selected { - ui.painter().rect_filled(r, 2.0, t.accent_soft); - } else if resp.hovered() { - ui.painter().rect_filled(r, 2.0, t.hover); - } - let ir = egui::Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0)); - icons::paint(ui.painter(), ir, icon, t.icon, t.icon_fill, t.accent); - ui.painter().text(r.min + vec2(22.0, 10.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), color.unwrap_or(t.text)); - resp +/// Browser state kept between frames. +#[derive(Default)] +pub struct TreeState { + /// Folders closed by the user (keys like `c0/bodies`). + pub collapsed: BTreeSet, + /// Folders opened by the user (folders that start closed). + pub expanded: BTreeSet, + /// Scroll to this body's row and open its folders (Find in Browser). + pub reveal: Option, + /// The Move/Copy panel of an occurrence. + pub occurrence_move: Option, + /// The Redefine Sketch Plane panel (sketch id). + pub redefine: Option, + /// Components picked in the tree (they have no viewport selection). + pub picked_components: Vec, + /// The last row clicked (for Shift ranges). + anchor: Option, + /// Row keys in drawing order this frame (for Shift ranges). + order: Vec, } -fn eye(ui: &mut egui::Ui, visible: bool) -> bool { +/// Moving an occurrence: the offsets typed so far (pending until Capture Position). +#[derive(Clone, Debug)] +pub struct OccMove { + pub occurrence: u64, + pub translate: [f64; 3], + pub angle_deg: f64, +} + +impl OccMove { + pub fn new(occurrence: u64) -> Self { + OccMove { occurrence, translate: [0.0; 3], angle_deg: 0.0 } + } +} + +// --------------------------------------------------------------------------------------------- +// Queries used by the menus +// --------------------------------------------------------------------------------------------- + +/// Is a sketch drawn in the viewport? +pub fn sketch_visible(app: &SolveApp, id: u64) -> bool { + if app.ui.hidden_sketches.contains(&id) { + return false; + } + app.ui.shown_sketches.contains(&id) || (app.ui.show_sketches && !solvecraft_engine::view::sketch_consumed(&app.session, id)) +} + +pub fn set_sketch_visible(app: &mut SolveApp, id: u64, on: bool) { + app.ui.hidden_sketches.retain(|x| *x != id); + app.ui.shown_sketches.retain(|x| *x != id); + if on { + app.ui.shown_sketches.push(id); + } else { + app.ui.hidden_sketches.push(id); + } +} + +pub fn redefine_sketch(app: &mut SolveApp, id: u64) { + app.tree.redefine = Some(id); +} + +pub fn group_bodies(_app: &SolveApp, _id: u64) -> Vec { + Vec::new() +} + +pub fn group_items(_app: &SolveApp, _id: u64) -> Vec { + Vec::new() +} + +pub fn group_name(_app: &SolveApp, _id: u64) -> Option { + None +} + +pub fn group_action(_app: &mut SolveApp, _action: &str, _p: &Value) {} + +// --------------------------------------------------------------------------------------------- +// Drawing +// --------------------------------------------------------------------------------------------- + +const ROW_H: f32 = 22.0; +const INDENT: f32 = 14.0; + +/// One row's look. +struct Row<'a> { + depth: usize, + /// Some(open) for rows that fold. + fold: Option, + /// Some(visible) for rows with an eye. + eye: Option, + icon: &'a str, + label: &'a str, + selected: bool, + dim: bool, + color: Option, + /// Component rows: is it the active one? + radio: Option, + /// Trailing badges: "lock", "pin". + badges: &'a [&'a str], +} + +impl Default for Row<'_> { + fn default() -> Self { + Row { depth: 0, fold: None, eye: None, icon: "", label: "", selected: false, dim: false, color: None, radio: None, badges: &[] } + } +} + +/// What happened on a row. +#[derive(Default)] +struct RowResp { + rect: Option, + clicked: bool, + double: bool, + secondary: Option, + fold: bool, + eye: bool, + radio: bool, + hovered: bool, +} + +fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { let t = Tokens::get(); - let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); - icons::paint(ui.painter(), r, "eye", if visible { t.icon } else { t.border }, t.icon_fill, t.accent); - resp.on_hover_text(if visible { "Hide" } else { "Show" }).clicked() + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); + let mut out = RowResp { rect: Some(r), hovered: resp.hovered(), ..Default::default() }; + if row.selected { + ui.painter().rect_filled(r, 3.0, t.accent_soft); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let mut x = r.left() + 2.0 + row.depth as f32 * INDENT; + let cy = r.center().y; + let ink = if row.dim { t.text_dim } else { t.icon }; + // Fold arrow. + if let Some(open) = row.fold { + let ar = Rect::from_center_size(pos2(x + 6.0, cy), vec2(12.0, 12.0)); + let pts = if open { + vec![pos2(ar.left() + 2.0, ar.top() + 4.0), pos2(ar.right() - 2.0, ar.top() + 4.0), pos2(ar.center().x, ar.bottom() - 3.0)] + } else { + vec![pos2(ar.left() + 4.0, ar.top() + 2.0), pos2(ar.right() - 3.0, ar.center().y), pos2(ar.left() + 4.0, ar.bottom() - 2.0)] + }; + ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE)); + out.fold = ui.interact(ar.expand(3.0), id.with("fold"), Sense::click()).clicked(); + } + x += 14.0; + // Eye. + if let Some(vis) = row.eye { + let er = Rect::from_center_size(pos2(x + 8.0, cy), vec2(15.0, 15.0)); + icons::paint(ui.painter(), er, "eye", if vis { ink } else { t.border }, t.icon_fill, t.accent); + if !vis { + ui.painter().line_segment([er.left_bottom() + vec2(2.0, -2.0), er.right_top() + vec2(-2.0, 2.0)], Stroke::new(1.4, t.text_dim)); + } + let e = ui.interact(er.expand(2.0), id.with("eye"), Sense::click()); + out.eye = e.clicked(); + e.on_hover_text(if vis { "Hide" } else { "Show" }); + } + x += 18.0; + // Activation radio (components). + if let Some(active) = row.radio { + let rc = pos2(x + 6.0, cy); + ui.painter().circle_stroke(rc, 5.0, Stroke::new(1.2, if active { t.accent } else { t.text_dim })); + if active { + ui.painter().circle_filled(rc, 2.8, t.accent); + } + let rr = ui.interact(Rect::from_center_size(rc, vec2(14.0, 14.0)), id.with("radio"), Sense::click()); + out.radio = rr.clicked(); + rr.on_hover_text(if active { "Active component" } else { "Activate" }); + x += 15.0; + } + if !row.icon.is_empty() { + let ir = Rect::from_min_size(pos2(x, cy - 8.0), vec2(16.0, 16.0)); + icons::paint(ui.painter(), ir, row.icon, ink, if row.dim { t.panel_header } else { t.icon_fill }, t.accent); + x += 21.0; + } + let col = row.color.unwrap_or(if row.dim { t.text_dim } else { t.text }); + let galley = ui.painter().layout_no_wrap(row.label.to_string(), FontId::proportional(12.5), col); + let lw = galley.size().x; + ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, col); + let mut bx = (x + lw + 6.0).min(r.right() - 16.0); + for b in row.badges { + let br = Rect::from_center_size(pos2(bx + 6.0, cy), vec2(12.0, 12.0)); + badge(ui.painter(), br, b, t.text_dim); + bx += 15.0; + } + out.clicked = resp.clicked(); + out.double = resp.double_clicked(); + if resp.secondary_clicked() { + out.secondary = resp.interact_pointer_pos().or(Some(r.left_bottom())); + } + out +} + +/// Small glyphs after a row's label: a padlock, a pin (grounded). +fn badge(p: &egui::Painter, r: Rect, name: &str, c: Color32) { + let s = Stroke::new(1.3, c); + match name { + "lock" => { + let body = Rect::from_min_max(pos2(r.left() + 1.5, r.center().y - 0.5), pos2(r.right() - 1.5, r.bottom() - 0.5)); + p.rect_filled(body, 1.5, c); + let top = pos2(r.center().x, r.top() + 4.0); + p.add(egui::Shape::line( + (0..=8) + .map(|k| { + let a = std::f32::consts::PI * (k as f32 / 8.0); + pos2(top.x - 3.2 * a.cos(), top.y - 3.2 * a.sin() + 1.0) + }) + .chain([pos2(top.x + 3.2, body.top())]) + .collect(), + s, + )); + p.line_segment([pos2(top.x - 3.2, top.y + 1.0), pos2(top.x - 3.2, body.top())], s); + } + "pin" => { + let head = pos2(r.center().x + 2.0, r.top() + 4.0); + p.circle_filled(head, 3.2, c); + p.line_segment([head, pos2(r.left() + 1.5, r.bottom() - 1.0)], Stroke::new(1.6, c)); + } + _ => {} + } +} + +/// A Ctrl/Shift-aware click on a selectable row. +fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option) { + let mods = ui.input(|i| i.modifiers); + if mods.shift + && let Some(a) = app.tree.anchor.clone() + { + let order = app.tree.order.clone(); + if let (Some(i), Some(j)) = (order.iter().position(|k| *k == a), order.iter().position(|k| *k == key)) { + let (lo, hi) = (i.min(j), i.max(j)); + let items: Vec = order[lo..=hi].iter().filter_map(|k| sel_of_key(k)).collect(); + let _ = app.run("select.set", json!({ "items": items, "add": mods.command || mods.ctrl })); + return; + } + } + app.tree.anchor = Some(key); + let Some(sel) = sel else { return }; + if mods.command || mods.ctrl { + if app.session.selection.contains(&sel) { + let rest: Vec = app.session.selection.iter().filter(|x| **x != sel).cloned().collect(); + let _ = app.run("select.set", json!({ "items": rest })); + } else { + let _ = app.run("select.set", json!({ "items": [sel], "add": true })); + } + } else { + app.tree.picked_components.clear(); + let _ = app.run("select.set", json!({ "items": [sel] })); + } +} + +fn sel_of_key(k: &str) -> Option { + if let Some(b) = k.strip_prefix("b:") { + return Some(Sel::Body { name: b.to_string() }); + } + if let Some(s) = k.strip_prefix("s:") { + return s.parse().ok().map(|id| Sel::Feature { id }); + } + None +} + +fn is_open(app: &SolveApp, key: &str, default: bool) -> bool { + if default { !app.tree.collapsed.contains(key) } else { app.tree.expanded.contains(key) } +} + +fn toggle(app: &mut SolveApp, key: &str, default: bool) { + if default { + if !app.tree.collapsed.remove(key) { + app.tree.collapsed.insert(key.to_string()); + } + } else if !app.tree.expanded.remove(key) { + app.tree.expanded.insert(key.to_string()); + } +} + +/// Everything a frame's rows want done, applied after drawing. +#[derive(Default)] +struct Actions { + menu: Option<(Pos2, Target)>, + hover_bodies: Vec, +} + +pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + app.tree.order.clear(); + let mut acts = Actions::default(); + egui::Panel::left("sc_browser") + .exact_size(250.0) + .resizable(false) + .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(6, 6))) + .show(ui, |ui| { + ui.label(RichText::new("BROWSER").size(11.0).strong().color(t.text_dim)); + ui.add_space(4.0); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + component_rows(app, ui, 0, 0, &mut acts); + }); + }); + app.viewport.hover_bodies = acts.hover_bodies; + if let Some((at, target)) = acts.menu { + crate::context_menu::open_for(app, at, target); + } + occurrence_panel(app, ui.ctx()); + redefine_panel(app, ui.ctx()); +} + +fn component_name(app: &SolveApp, id: u64) -> String { + if id == 0 { + return app.session.doc.name.clone(); + } + let doc = &app.session.doc; + let name = doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default(); + let n = doc.occurrences.iter().filter(|o| o.component == id).count(); + if n > 1 { format!("{name} ({n} instances)") } else { format!("{name}:1") } +} + +fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, acts: &mut Actions) { + let t = Tokens::get(); + let key = format!("c{id}"); + let open = is_open(app, &key, true); + let active = app.session.active_component == id; + let bodies = crate::context_menu::component_bodies(app, id); + let visible = bodies.is_empty() || bodies.iter().any(|b| !app.ui.hidden_bodies.contains(b)); + let grounded = app.session.doc.occurrence_of(id).is_some_and(|o| o.grounded); + let faded = app.session.active_component != 0 && !app.session.doc.component_within(id, app.session.active_component) && !active; + let name = component_name(app, id); + let badges: &[&str] = if grounded { &["pin"] } else { &[] }; + let row = Row { + depth, + fold: Some(open), + eye: (id != 0).then_some(visible), + icon: "component", + label: &name, + selected: app.tree.picked_components.contains(&id), + dim: faded, + radio: Some(active), + badges, + ..Default::default() + }; + let r = draw_row(ui, ui.id().with(("comp", id)), &row); + if r.fold { + toggle(app, &key, true); + } + if r.radio { + let _ = app.run("component.activate", json!({ "component": id })); + } + if r.eye { + if visible { + app.ui.hidden_bodies.extend(bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(b)).cloned().collect::>()); + } else { + app.ui.hidden_bodies.retain(|b| !bodies.contains(b)); + } + } + if r.clicked { + let ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl); + if !ctrl { + app.tree.picked_components.clear(); + let _ = app.run("select.clear", json!({})); + } + if app.tree.picked_components.contains(&id) { + app.tree.picked_components.retain(|c| *c != id); + } else { + app.tree.picked_components.push(id); + } + } + if r.double { + let _ = app.run("component.activate", json!({ "component": id })); + } + if r.hovered { + acts.hover_bodies = bodies.clone(); + } + if let Some(p) = r.secondary { + acts.menu = Some((p, Target::Component { id })); + } + if !open { + return; + } + let d = depth + 1; + if id == 0 { + doc_settings(app, ui, d); + origin_rows(app, ui, d); + } + let _ = t; + body_rows(app, ui, id, d, acts); + sketch_rows(app, ui, id, d, acts); + plane_rows(app, ui, id, d); + let children: Vec = app.session.doc.components.iter().filter(|c| c.parent == id).map(|c| c.id).collect(); + for c in children { + component_rows(app, ui, c, d, acts); + } +} + +fn doc_settings(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { + let key = "docsettings"; + let open = is_open(app, key, false); + let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), icon: "settings", label: "Document Settings", ..Default::default() }); + if r.fold || r.clicked { + toggle(app, key, false); + } + if !open { + return; + } + let units = format!("Units: {}", app.session.doc.units); + draw_row(ui, ui.id().with("units"), &Row { depth: depth + 1, icon: "settings", label: &units, ..Default::default() }); + let params = format!("Parameters ({})", app.session.doc.params.len()); + if draw_row(ui, ui.id().with("params"), &Row { depth: depth + 1, icon: "params", label: ¶ms, ..Default::default() }).clicked { + app.start("ChangeParameterCommand"); + } +} + +fn origin_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { + let key = "origin"; + let open = is_open(app, key, false); + let r = draw_row( + ui, + ui.id().with(key), + &Row { depth, fold: Some(open), eye: Some(app.ui.show_origin), icon: "origin", label: "Origin", ..Default::default() }, + ); + if r.fold || r.clicked { + toggle(app, key, false); + } + if r.eye { + app.ui.show_origin = !app.ui.show_origin; + } + if !open { + return; + } + let items = [ + ("O", "point", "O"), + ("X", "axis", "X"), + ("Y", "axis", "Y"), + ("Z", "axis", "Z"), + ("XY", "plane", "XY"), + ("XZ", "plane", "XZ"), + ("YZ", "plane", "YZ"), + ]; + for (k, icon, label) in items { + let visible = !app.ui.hidden_origin.iter().any(|h| h == k); + let sel = match icon { + "plane" => Some(Sel::Plane { name: k.into() }), + "axis" => Some(Sel::Axis { name: k.into() }), + _ => None, + }; + let selected = sel.as_ref().is_some_and(|x| app.session.selection.contains(x)); + let r = draw_row( + ui, + ui.id().with(("o", k)), + &Row { depth: depth + 1, eye: Some(visible), icon, label, selected, dim: !app.ui.show_origin, ..Default::default() }, + ); + if r.eye { + if visible { + app.ui.hidden_origin.push(k.into()); + } else { + app.ui.hidden_origin.retain(|h| h != k); + } + } + if r.clicked + && let Some(x) = sel + { + pick_from_browser(app, x); + } + } } /// A browser click on a plane or axis: it goes to the open dialog's input if that takes it, @@ -47,110 +496,276 @@ fn pick_from_browser(app: &mut SolveApp, x: Sel) { let _ = app.run("select.set", json!({ "items": [x] })); } -pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { +/// A folder row (Bodies, Sketches, Construction) with an eye for everything in it; true when +/// it is open. +fn folder(app: &mut SolveApp, ui: &mut egui::Ui, key: &str, label: &str, depth: usize, visible: Option) -> (bool, RowResp) { + let open = is_open(app, key, true); + let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), eye: visible, icon: "folder", label, ..Default::default() }); + if r.fold || r.clicked { + toggle(app, key, true); + } + (open, r) +} + +fn body_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, depth: usize, acts: &mut Actions) { + let st = app.session.model.state(); + let doc = app.session.doc.clone(); + let bodies: Vec = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == comp).map(|b| b.name.clone()).collect(); + if bodies.is_empty() { + return; + } + let key = format!("c{comp}/bodies"); + let reveal = app.tree.reveal.as_ref().is_some_and(|b| bodies.contains(b)); + if reveal { + app.tree.collapsed.remove(&key); + app.tree.collapsed.remove(&format!("c{comp}")); + } + let any_visible = bodies.iter().any(|b| !app.ui.hidden_bodies.contains(b)); + let (open, r) = folder(app, ui, &key, "Bodies", depth, Some(any_visible)); + if r.eye { + if any_visible { + app.ui.hidden_bodies.extend(bodies.iter().filter(|b| !app.ui.hidden_bodies.contains(b)).cloned().collect::>()); + } else { + app.ui.hidden_bodies.retain(|b| !bodies.contains(b)); + } + } + if r.hovered { + acts.hover_bodies = bodies.clone(); + } + if !open { + return; + } + for b in &bodies { + let visible = !app.ui.hidden_bodies.contains(b); + let selected = app.session.selection.iter().any(|x| matches!(x, Sel::Body { name } if name == b)); + let locked = app.ui.locked_bodies.contains(b); + let badges: &[&str] = if locked { &["lock"] } else { &[] }; + let key = format!("b:{b}"); + app.tree.order.push(key.clone()); + let r = draw_row( + ui, + ui.id().with(("body", b)), + &Row { depth: depth + 1, eye: Some(visible), icon: "body", label: b, selected, dim: !visible, badges, ..Default::default() }, + ); + if app.tree.reveal.as_deref() == Some(b.as_str()) + && let Some(rect) = r.rect + { + ui.scroll_to_rect(rect, Some(egui::Align::Center)); + app.tree.reveal = None; + } + if r.eye { + if visible { + app.ui.hidden_bodies.push(b.clone()); + } else { + app.ui.hidden_bodies.retain(|n| n != b); + } + } + if r.clicked { + click_select(app, ui, key, Some(Sel::Body { name: b.clone() })); + } + if r.hovered { + acts.hover_bodies = vec![b.clone()]; + } + if let Some(p) = r.secondary { + if !selected { + let _ = app.run("select.set", json!({"items": [{"type": "body", "name": b}]})); + } + acts.menu = Some((p, Target::Body { name: b.clone() })); + } + } +} + +fn sketch_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, depth: usize, acts: &mut Actions) { let t = Tokens::get(); - egui::Panel::left("sc_browser") - .exact_size(230.0) + let sketches: Vec<(u64, String)> = app + .session + .doc + .features + .iter() + .filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. })) + .map(|f| (f.id, f.name.clone())) + .collect(); + if sketches.is_empty() { + return; + } + let key = format!("c{comp}/sketches"); + let (open, r) = folder(app, ui, &key, "Sketches", depth, Some(app.ui.show_sketches)); + if r.eye { + app.ui.show_sketches = !app.ui.show_sketches; + } + if !open { + return; + } + for (id, name) in sketches { + let active = app.session.active_sketch == Some(id); + let visible = sketch_visible(app, id); + let selected = active || app.session.selection.iter().any(|x| matches!(x, Sel::Feature { id: f } if *f == id)); + let label = if active { format!("{name} (editing)") } else { name.clone() }; + let key = format!("s:{id}"); + app.tree.order.push(key.clone()); + let r = draw_row( + ui, + ui.id().with(("sketch", id)), + &Row { + depth: depth + 1, + eye: Some(visible), + icon: "sketch", + label: &label, + selected, + dim: !visible, + color: active.then_some(t.sketch_accent), + ..Default::default() + }, + ); + if r.eye { + set_sketch_visible(app, id, !visible); + } + if r.double { + app.edit_sketch(id); + } else if r.clicked { + click_select(app, ui, key, Some(Sel::Feature { id })); + } + if let Some(p) = r.secondary { + acts.menu = Some((p, Target::Sketch { id })); + } + } +} + +fn plane_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, depth: usize) { + let planes: Vec = app + .session + .doc + .features + .iter() + .filter(|f| f.component == comp && matches!(f.kind, FeatureKind::ConstructionPlane { .. })) + .map(|f| f.name.clone()) + .collect(); + if planes.is_empty() { + return; + } + let key = format!("c{comp}/construction"); + let (open, _) = folder(app, ui, &key, "Construction", depth, None); + if !open { + return; + } + for p in planes { + let visible = !app.ui.hidden_origin.contains(&p); + let sel = Sel::Plane { name: p.clone() }; + let selected = app.session.selection.contains(&sel); + let r = draw_row( + ui, + ui.id().with(("plane", &p)), + &Row { depth: depth + 1, eye: Some(visible), icon: "plane", label: &p, selected, dim: !visible, ..Default::default() }, + ); + if r.eye { + if visible { + app.ui.hidden_origin.push(p.clone()); + } else { + app.ui.hidden_origin.retain(|h| *h != p); + } + } + if r.clicked { + pick_from_browser(app, sel); + } + } +} + +// --------------------------------------------------------------------------------------------- +// Panels opened from the menus +// --------------------------------------------------------------------------------------------- + +/// Move/Copy of an occurrence: offsets and a rotation about Z, pending until Capture Position. +fn occurrence_panel(app: &mut SolveApp, ctx: &egui::Context) { + let Some(mut m) = app.tree.occurrence_move.clone() else { return }; + let name = app.session.doc.occurrences.iter().find(|o| o.id == m.occurrence).map(|o| o.name.clone()).unwrap_or_default(); + let mut open = true; + let mut action: Option<&str> = None; + let before = (m.translate, m.angle_deg); + egui::Window::new(format!("Move: {name}")).id(egui::Id::new("sc_occ_move")).open(&mut open).resizable(false).collapsible(false).show(ctx, |ui| { + egui::Grid::new("sc_occ_grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { + for (k, axis) in ["X distance", "Y distance", "Z distance"].iter().enumerate() { + ui.label(*axis); + if let Some(v) = m.translate.get_mut(k) { + ui.add(egui::DragValue::new(v).speed(0.5).suffix(" mm")); + } + ui.end_row(); + } + ui.label("Z angle"); + ui.add(egui::DragValue::new(&mut m.angle_deg).speed(1.0).suffix(" deg")); + ui.end_row(); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.button("Capture Position").clicked() { + action = Some("capture"); + } + if ui.button("Revert").clicked() { + action = Some("revert"); + } + }); + }); + if (m.translate, m.angle_deg) != before { + // The pending move is the typed offset from where the occurrence is now. + let _ = app.session.execute("AsBuiltPositionsCmd", &json!({})); + let _ = app.run( + "occurrence.move", + json!({ "occurrence": m.occurrence, "translate": m.translate, "axis": [0, 0, 1], "angle": m.angle_deg.to_radians(), "capture": false }), + ); + } + match action { + Some("capture") => { + let _ = app.run("SnapshotCmd", json!({})); + app.tree.occurrence_move = None; + } + Some(_) => { + let _ = app.run("AsBuiltPositionsCmd", json!({})); + app.tree.occurrence_move = None; + } + None if !open => { + let _ = app.run("AsBuiltPositionsCmd", json!({})); + app.tree.occurrence_move = None; + } + None => app.tree.occurrence_move = Some(m), + } +} + +/// Redefine Sketch Plane: pick an origin or construction plane, or the selected planar face. +fn redefine_panel(app: &mut SolveApp, ctx: &egui::Context) { + let Some(id) = app.tree.redefine else { return }; + let name = app.session.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default(); + let mut planes: Vec = vec!["XY".into(), "XZ".into(), "YZ".into()]; + planes.extend(app.session.doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })).map(|f| f.name.clone())); + let face = app.session.selection.iter().find_map(|s| if let Sel::Face { point, .. } = s { Some(*point) } else { None }); + let mut open = true; + let mut pick: Option = None; + egui::Window::new(format!("Redefine Sketch Plane: {name}")) + .id(egui::Id::new("sc_redefine")) + .open(&mut open) .resizable(false) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6))) - .show(ui, |ui| { - ui.label(RichText::new("BROWSER").size(11.0).strong().color(t.text_dim)); + .collapsible(false) + .show(ctx, |ui| { + ui.label(RichText::new("Move the sketch to").color(Tokens::get().text_dim)); + ui.horizontal_wrapped(|ui| { + for p in &planes { + if ui.button(p).clicked() { + pick = Some(json!(p)); + } + } + }); ui.add_space(4.0); - let st = app.session.model.state(); - egui::ScrollArea::vertical().show(ui, |ui| { - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); - icons::paint(ui.painter(), r, "box", t.icon, t.icon_fill, t.accent); - ui.label(RichText::new(&app.session.doc.name).strong()); - }); - ui.indent("doc", |ui| { - egui::CollapsingHeader::new("Document Settings").default_open(false).show(ui, |ui| { - row(ui, "settings", &format!("Units: {}", app.session.doc.units), None, false); - let n = app.session.doc.params.len(); - if row(ui, "params", &format!("Parameters ({n})"), None, false).clicked() { - app.start("ChangeParameterCommand"); - } - }); - ui.horizontal(|ui| { - if eye(ui, app.ui.show_origin) { - app.ui.show_origin = !app.ui.show_origin; - } - egui::CollapsingHeader::new("Origin").default_open(false).show(ui, |ui| { - let items = [("O", "point", "Origin point"), ("X", "axis", "X axis"), ("Y", "axis", "Y axis"), ("Z", "axis", "Z axis")] - .into_iter() - .chain([("XY", "plane", "XY"), ("XZ", "plane", "XZ"), ("YZ", "plane", "YZ")]); - for (key, icon, label) in items { - let visible = !app.ui.hidden_origin.iter().any(|h| h == key); - let sel = match icon { - "plane" => Some(Sel::Plane { name: key.into() }), - "axis" => Some(Sel::Axis { name: key.into() }), - _ => None, - }; - let selected = sel.as_ref().is_some_and(|x| app.session.selection.contains(x)); - ui.horizontal(|ui| { - if eye(ui, visible) { - if visible { - app.ui.hidden_origin.push(key.into()); - } else { - app.ui.hidden_origin.retain(|h| h != key); - } - } - if row(ui, icon, label, None, selected).clicked() - && let Some(x) = &sel - { - pick_from_browser(app, x.clone()); - } - }); - } - }); - }); - egui::CollapsingHeader::new(format!("Bodies ({})", st.bodies.len())).default_open(true).show(ui, |ui| { - for b in &st.bodies { - let visible = !app.ui.hidden_bodies.contains(&b.name); - let selected = app.session.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name)); - ui.horizontal(|ui| { - if eye(ui, visible) { - if visible { - app.ui.hidden_bodies.push(b.name.clone()); - } else { - app.ui.hidden_bodies.retain(|n| n != &b.name); - } - } - if row(ui, "body", &b.name, None, selected).clicked() { - let _ = app.run("select.set", json!({"items": [{"type": "body", "name": b.name}]})); - } - }); - } - }); - let sketches: Vec<(u64, String)> = app - .session - .doc - .features - .iter() - .filter(|f| matches!(f.kind, FeatureKind::Sketch { .. })) - .map(|f| (f.id, f.name.clone())) - .collect(); - egui::CollapsingHeader::new(format!("Sketches ({})", sketches.len())).default_open(true).show(ui, |ui| { - ui.horizontal(|ui| { - if eye(ui, app.ui.show_sketches) { - app.ui.show_sketches = !app.ui.show_sketches; - } - ui.label(RichText::new("show unused sketches").size(11.0).color(t.text_dim)); - }); - for (id, name) in sketches { - let active = app.session.active_sketch == Some(id); - let r = if active { - row(ui, "sketch", &format!("{name} (editing)"), Some(t.sketch_accent), true) - } else { - row(ui, "sketch", &name, None, false) - }; - if r.double_clicked() { - app.edit_sketch(id); - } - r.on_hover_text("Double-click to edit"); - } - }); - }); - }); + let b = ui.add_enabled(face.is_some(), egui::Button::new("Selected face")); + if b.clicked() + && let Some(f) = face + { + pick = Some(json!({ "face": [f.x, f.y, f.z] })); + } + b.on_disabled_hover_text("Select a planar face in the viewport first"); }); + if let Some(plane) = pick { + if app.run("sketch.redefine", json!({ "sketch": id, "plane": plane })).is_ok() { + app.tree.redefine = None; + } + } else if !open { + app.tree.redefine = None; + } } diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index e4e2e0db..ab38c08d 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -1,109 +1,1194 @@ -//! The viewport's right-click menu: repeat the last command, then the commands that fit what is -//! selected (edges: fillet, chamfer; faces: press pull, sketch, hole, shell; bodies: move), then -//! view and selection helpers. +//! 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::{Pos2, vec2}; -use serde_json::json; +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::SolveApp; +use crate::theme::Tokens; +use crate::{SolveApp, icons}; -/// Commands offered for the current selection: (command id, label). -fn for_selection(app: &SolveApp) -> Vec<(&'static str, &'static str)> { - 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.push(("DrawPolyline", "Line")); - v.push(("ShapeRectangleTwoPoint", "Rectangle")); - v.push(("CircleCenterRadius", "Circle")); - v.push(("SketchDimension", "Sketch Dimension")); - if has(|s| matches!(s, Sel::SketchCurve { .. })) { - v.push(("sketch.construction", "Normal / Construction")); +/// 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.push(("SketchStop", "Finish Sketch")); - return v; - } - if has(|s| matches!(s, Sel::Edge { .. })) { - v.push(("FusionFilletEdgesCommand", "Fillet")); - v.push(("FusionChamferCommand", "Chamfer")); - } - if has(|s| matches!(s, Sel::Face { .. } | Sel::Profile { .. })) { - v.push(("Extrude", "Extrude / Press Pull")); - } - if has(|s| matches!(s, Sel::Face { .. })) { - v.push(("SketchCreate", "Create Sketch")); - v.push(("FusionHoleCommand", "Hole")); - v.push(("FusionShellBodyCommand", "Shell")); - } - if has(|s| matches!(s, Sel::Face { .. } | Sel::Edge { .. } | Sel::Body { .. })) { - v.push(("FusionMoveCommand", "Move")); - } - if v.is_empty() { - v.push(("SketchCreate", "Create Sketch")); - v.push(("Extrude", "Extrude")); - v.push(("PrimitiveBox", "Box")); } v } -/// Open the menu at a screen position. +/// 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); } -/// Show the menu if it is open; a pick or a click elsewhere closes it. +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 mut pick: Option = None; - let mut other: Option<&'static str> = None; - let resp = egui::Area::new(egui::Id::new("sc_context_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ctx, |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(180.0); - let item = |ui: &mut egui::Ui, label: &str| ui.add(egui::Button::new(label).frame(false).min_size(vec2(170.0, 22.0))).clicked(); - if let Some((id, label)) = app.last_command.clone() { - if item(ui, &format!("Repeat {label}")) { - pick = Some(id); - } - ui.separator(); + 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 (id, label) in for_selection(app) { - if item(ui, label) { - pick = Some(id.to_string()); + 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()); } } - ui.separator(); - if app.session.section.is_some() && item(ui, "Remove Section") { - other = Some("unsection"); - } - if !app.session.selection.is_empty() && item(ui, "Clear Selection") { - other = Some("clear"); - } - if item(ui, "Fit") { - other = Some("fit"); - } - if item(ui, "Home View") { - other = Some("home"); - } - if item(ui, "Undo") { - other = Some("undo"); + }); + 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); + } } }); }); - let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !resp.response.contains_pointer(); - let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape)); - if pick.is_some() || other.is_some() || clicked_outside || esc { - app.viewport.context_menu = None; + 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(id) = pick { - app.start(&id); - } - match other { - Some("clear") => drop(app.run("select.clear", json!({}))), - Some("unsection") => drop(app.run("FusionHalfSectionViewCommand", json!({"clear": true}))), - Some("fit") => app.animate_view("fit"), - Some("home") => app.animate_view("home"), - Some("undo") => drop(app.run("UndoCommand", json!({}))), - _ => {} + 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:?}"); + } + } } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e69f9277..450e633b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -6,7 +6,8 @@ //! - `engine.commands`: every command with tab, panel, params and enablement //! - `document.inspect {measure?}`: the design (parameters, timeline, bodies, sketches) //! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog -//! - `ui.drag {x0, y0, x1, y1, button?, shift?, ctrl?, steps?}` (box selection, navigation); +//! - `ui.drag {x0, y0, x1, y1, button?, shift?, ctrl?, steps?, hold?}` (box selection, navigation; +//! `hold` keeps the button down that many frames before moving); //! `ui.selection` (selection, dialog inputs, hover); `ui.editFeature {feature}` (edit dialog) //! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit, animate?: bool}` (snaps unless animate) //! - `ui.start {command}`: like clicking the toolbar button (starts tools/dialogs) @@ -14,6 +15,10 @@ //! pointer input in screen points; `ui.key {key, cmd?, shift?}`, `ui.text {text}` //! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, width?, height?}`: headless //! CPU render of the model with the current camera (no window needed) +//! - `ui.menu {x?, y?, target?, close?}`: open a context menu at a point (the viewport's marking +//! menu, or a browser menu with `target: {type: body|sketch|component, …}`); without a point, +//! the open menu and its items. `ui.menuPick {item}` runs an item by id or label; +//! `ui.rename {text?, commit?}` finishes the rename box //! - `ui.resize {width, height}`, `app.quit` use std::sync::mpsc::Sender; @@ -170,6 +175,10 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> let (a, z) = (egui::pos2(x0 as f32, y0 as f32), egui::pos2(x1 as f32, y1 as f32)); app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); + // `hold`: frames to keep the button down before moving (press-and-hold). + for _ in 0..f("hold").unwrap_or(0.0).clamp(0.0, 600.0) as usize { + app.synthetic.push(egui::Event::PointerMoved(a)); + } let steps = f("steps").unwrap_or(10.0).clamp(1.0, 200.0) as usize; for k in 1..=steps { app.synthetic.push(egui::Event::PointerMoved(a + (z - a) * (k as f32 / steps as f32))); @@ -214,6 +223,35 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string())); ok(Value::Null) } + "ui.menu" => { + if b("close") { + crate::context_menu::close(app); + return ok(json!({"open": false})); + } + if let (Some(x), Some(y)) = (f("x"), f("y")) { + let target = match p.get("target") { + Some(v) => match serde_json::from_value::(v.clone()) { + Ok(t) => t, + Err(e) => return err(e), + }, + None => crate::context_menu::Target::Viewport, + }; + crate::context_menu::request_open(app, egui::pos2(x as f32, y as f32), target); + return ok(json!({"pending": true})); + } + ok(crate::context_menu::describe(app)) + } + "ui.menuPick" => { + let Some(key) = s("item") else { return err("missing `item` (id or label)") }; + wrap(crate::context_menu::pick(app, key)) + } + "ui.rename" => { + if crate::context_menu::finish_rename(app, s("text"), p.get("commit").and_then(Value::as_bool).unwrap_or(true)) { + ok(json!({"done": true})) + } else { + err("no rename in progress") + } + } "ui.resize" => { let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") }; ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w.clamp(320.0, 8192.0) as f32, h.clamp(240.0, 8192.0) as f32))); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index ce9517bd..9e198ced 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -424,6 +424,11 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.circle(12.0, 12.0, 2.6, ink, ink); } "body" => pen.iso_box(4.0, 10.0, 8.0, 6.0, 6.0), + // Component: a box with a smaller one stacked on it. + "component" => { + pen.iso_box(2.0, 13.0, 9.0, 6.0, 6.0); + pen.iso_box(9.0, 6.0, 5.0, 4.0, 4.0); + } "folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(236, 200, 110), ink), "origin" => { pen.arrow((6.0, 18.0), (20.0, 18.0), Color32::from_rgb(210, 60, 60)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a45f5b12..e98b1592 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,6 +15,8 @@ pub mod control; pub mod dialogs; pub mod gpu; pub mod icons; +#[cfg(test)] +mod menu_tests; pub mod palette; pub mod preview; pub mod selection; @@ -59,6 +61,13 @@ pub struct UiState { pub pick_off: Vec, /// A click on a face picks its whole body. pub pick_bodies: bool, + /// Sketches hidden one by one, and finished sketches shown although a feature uses them. + pub hidden_sketches: Vec, + pub shown_sketches: Vec, + /// Sketches whose closed profiles are not shaded. + pub hidden_profiles: Vec, + /// Bodies locked in the browser (no move or delete). + pub locked_bodies: Vec, } impl Default for UiState { @@ -77,6 +86,10 @@ impl Default for UiState { dark: true, pick_off: Vec::new(), pick_bodies: false, + hidden_sketches: Vec::new(), + shown_sketches: Vec::new(), + hidden_profiles: Vec::new(), + locked_bodies: Vec::new(), } } } @@ -106,6 +119,10 @@ pub struct SolveApp { pub tool: Option, pub dialog: Option, pub palette: palette::Palette, + /// Context menus, rename box and Properties window. + pub menu: context_menu::MenuState, + /// Browser tree state (reveal, groups being edited, occurrence moves). + pub tree: browser::TreeState, pub preview: preview::PreviewState, /// The last command started interactively (id, label), for Repeat. pub last_command: Option<(String, String)>, @@ -136,6 +153,8 @@ impl SolveApp { tool: None, dialog: None, palette: palette::Palette::default(), + menu: context_menu::MenuState::default(), + tree: browser::TreeState::default(), preview: preview::PreviewState::default(), last_command: None, status: None, diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs new file mode 100644 index 00000000..1238e2bf --- /dev/null +++ b/crates/ui-egui/src/menu_tests.rs @@ -0,0 +1,164 @@ +//! Context menu tests: what the menus offer for each target, and that their items act. + +use egui::pos2; +use serde_json::json; +use solvecraft_engine::Session; + +use crate::context_menu::{self, Item, Target}; +use crate::{Services, SolveApp}; + +fn sample_app() -> SolveApp { + let mut s = Session::default(); + s.run_script(&solvecraft_engine::sample::script()).unwrap(); + SolveApp::new(s, Services::default()) +} + +fn find(items: &[Item], label: &str) -> Item { + items + .iter() + .find(|i| i.label == label) + .cloned() + .unwrap_or_else(|| panic!("no `{label}` in {:?}", items.iter().map(|i| &i.label).collect::>())) +} + +/// Run the item labelled `label` of the target's menu. +fn act(app: &mut SolveApp, t: &Target, label: &str) { + let item = find(&context_menu::items(app, t), label); + context_menu::run_item(app, &item, pos2(0.0, 0.0)); +} + +fn body(app: &SolveApp) -> String { + app.session.model.state().bodies[0].name.clone() +} + +#[test] +fn empty_space_offers_view_items_and_greys_out_undo() { + let mut app = sample_app(); + app.session.undo.clear(); + let items = context_menu::items(&app, &Target::Viewport); + assert!(!find(&items, "Undo").enabled, "nothing to undo"); + assert!(find(&items, "Fit").enabled); + assert_eq!(context_menu::radial_items(&app).len(), 8); + assert!(!context_menu::radial_items(&app)[0].enabled, "no command to repeat yet"); + // Hide Origin toggles. + context_menu::run_item(&mut app, &find(&items, "Hide Origin"), pos2(0.0, 0.0)); + assert!(!app.ui.show_origin); +} + +#[test] +fn face_menu_hides_and_finds_the_body() { + let mut app = sample_app(); + let b = body(&app); + app.run("select.set", json!({"items": [{"type": "face", "body": b, "index": 0, "point": [0, 0, 0]}]})).unwrap(); + let items = context_menu::items(&app, &Target::Viewport); + for l in ["Create Sketch", "Offset Plane", "Press Pull", "Extrude", "Measure", "Find in Browser", "Find in Timeline"] { + assert!(find(&items, l).enabled, "{l}"); + } + assert!(!find(&items, "Appearance…").enabled); + context_menu::run_item(&mut app, &find(&items, "Find in Timeline"), pos2(0.0, 0.0)); + assert!(matches!(app.session.selection.as_slice(), [solvecraft_engine::Sel::Feature { .. }])); + context_menu::run_item(&mut app, &find(&items, "Hide Body"), pos2(0.0, 0.0)); + assert_eq!(app.ui.hidden_bodies, vec![b]); +} + +#[test] +fn body_menu_isolates_renames_locks_and_deletes() { + let mut app = sample_app(); + app.run("PrimitiveBox", json!({"length": 5, "width": 5, "height": 5, "corner": [200, 0, 0]})).unwrap(); + let b = body(&app); + let t = Target::Body { name: b.clone() }; + let items = context_menu::items(&app, &t); + context_menu::run_item(&mut app, &find(&items, "Isolate"), pos2(0.0, 0.0)); + assert_eq!(app.ui.hidden_bodies.len(), 1, "the other body is hidden"); + assert!(!app.ui.hidden_bodies.contains(&b)); + // Rename through the rename box. + context_menu::run_item(&mut app, &find(&items, "Rename"), pos2(10.0, 10.0)); + assert!(context_menu::finish_rename(&mut app, Some("Base"), true)); + assert!(app.session.model.state().body("Base").is_some()); + // Locked bodies can't be moved or deleted. + let t = Target::Body { name: "Base".into() }; + act(&mut app, &t, "Lock"); + let items = context_menu::items(&app, &t); + assert!(!find(&items, "Delete").enabled && !find(&items, "Move/Copy").enabled); + context_menu::run_item(&mut app, &find(&items, "Unlock"), pos2(0.0, 0.0)); + let n = app.session.model.state().bodies.len(); + act(&mut app, &t, "Delete"); + assert_eq!(app.session.model.state().bodies.len(), n - 1); +} + +#[test] +fn properties_measure_the_body() { + let mut app = sample_app(); + let b = body(&app); + let items = context_menu::items(&app, &Target::Body { name: b }); + context_menu::run_item(&mut app, &find(&items, "Properties"), pos2(0.0, 0.0)); + let (_, v) = app.menu.props.clone().unwrap(); + assert!(v["total"]["volume_mm3"].as_f64().unwrap() > 1000.0); + assert!(v["total"]["mass_g"].as_f64().unwrap() > 0.0); +} + +#[test] +fn sketch_menu_hides_and_moves_to_another_plane() { + let mut app = sample_app(); + let id = app.session.doc.features.iter().find(|f| f.name == "Holes").unwrap().id; + let t = Target::Sketch { id }; + let items = context_menu::items(&app, &t); + let vis = crate::browser::sketch_visible(&app, id); + context_menu::run_item(&mut app, &find(&items, if vis { "Hide" } else { "Show" }), pos2(0.0, 0.0)); + assert_eq!(crate::browser::sketch_visible(&app, id), !vis); + context_menu::run_item(&mut app, &find(&items, "Edit Sketch"), pos2(0.0, 0.0)); + assert_eq!(app.session.active_sketch, Some(id)); + app.finish_sketch(); + context_menu::run_item(&mut app, &find(&items, "Redefine Sketch Plane"), pos2(0.0, 0.0)); + assert_eq!(app.tree.redefine, Some(id)); +} + +#[test] +fn sketch_entities_offer_the_constraints_that_fit() { + let mut app = sample_app(); + app.run("SketchCreate", json!({"plane": "XY"})).unwrap(); + app.run("DrawPolyline", json!({"points": [[0, 0], [10, 1]]})).unwrap(); + app.run("DrawPolyline", json!({"points": [[0, 5], [10, 7]]})).unwrap(); + let ids: Vec = { + let st = app.session.model.state(); + let ss = st.sketch(app.session.active_sketch.unwrap()).unwrap(); + ss.sketch.curves.iter().map(|c| c.id.clone()).collect() + }; + app.run("select.set", json!({"items": ids.iter().map(|i| json!({"type": "sketch_curve", "id": i})).collect::>()})).unwrap(); + let items = context_menu::items(&app, &Target::Viewport); + let par = find(&items, "Parallel"); + assert!(find(&items, "Finish Sketch").enabled); + context_menu::run_item(&mut app, &par, pos2(0.0, 0.0)); + let st = app.session.model.state(); + let ss = st.sketch(app.session.active_sketch.unwrap()).unwrap(); + assert!(ss.sketch.constraints.iter().any(|c| format!("{:?}", c.kind).contains("Parallel")), "{:?}", ss.sketch.constraints); +} + +#[test] +fn component_menu_activates_grounds_and_pastes() { + let mut app = sample_app(); + let b = body(&app); + app.run("FusionCreateComponentsFromBodiesCommand", json!({"bodies": [b]})).unwrap(); + let c = app.session.doc.components[0].id; + let t = Target::Component { id: c }; + act(&mut app, &t, "Activate Component"); + assert_eq!(app.session.active_component, c); + act(&mut app, &t, "Ground"); + assert!(app.session.doc.occurrence_of(c).unwrap().grounded); + assert!(!find(&context_menu::items(&app, &t), "Move/Copy").enabled, "grounded"); + act(&mut app, &t, "Paste New"); + assert_eq!(app.session.doc.components.len(), 2); +} + +#[test] +fn pick_runs_an_item_of_the_open_menu() { + let mut app = sample_app(); + assert!(context_menu::pick(&mut app, "Fit").is_err(), "no menu open"); + context_menu::open_for(&mut app, pos2(500.0, 400.0), Target::Viewport); + assert!(context_menu::pick(&mut app, "Nope").is_err()); + app.session.undo.clear(); + assert!(context_menu::pick(&mut app, "Undo").is_err(), "disabled"); + assert!(context_menu::pick(&mut app, "ui.origin").is_ok()); + assert!(app.viewport.context_menu.is_none(), "a pick closes the menu"); + assert!(!app.ui.show_origin); +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index bd716f0a..fb13c046 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -39,6 +39,8 @@ pub struct ViewportState { pub boxsel: Option, /// Timeline item under the cursor: its bodies are highlighted. pub hover_feature: Option, + /// Bodies whose browser row is under the cursor: highlighted the same way. + pub hover_bodies: Vec, pub build_ms: f64, /// The right-click menu, open at this screen position. pub context_menu: Option, @@ -140,7 +142,11 @@ fn visible_sketches(app: &SolveApp) -> Vec { let st = app.session.model.state(); st.sketches .iter() - .filter(|s| app.session.active_sketch == Some(s.feature) || (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature))) + .filter(|s| { + app.session.active_sketch == Some(s.feature) + || (!app.ui.hidden_sketches.contains(&s.feature) + && (app.ui.shown_sketches.contains(&s.feature) || (app.ui.show_sketches && !sketch_consumed(&app.session, s.feature)))) + }) .map(|s| s.feature) .collect() } @@ -151,6 +157,10 @@ fn scene_key(app: &SolveApp) -> u64 { app.ui.show_grid.hash(&mut h); app.ui.show_sketches.hash(&mut h); app.ui.hidden_bodies.hash(&mut h); + app.ui.hidden_sketches.hash(&mut h); + app.ui.shown_sketches.hash(&mut h); + app.ui.hidden_profiles.hash(&mut h); + app.session.active_component.hash(&mut h); app.preview.replaced.hash(&mut h); crate::theme::is_dark().hash(&mut h); app.session.active_sketch.hash(&mut h); @@ -195,13 +205,20 @@ fn build_scene(app: &SolveApp) -> GpuScene { if app.ui.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) { continue; } - let col = c4(s.doc.materials.get(&b.name).and_then(|m| crate::theme::material_color(m)).unwrap_or(tk.body)); + // With a component active, bodies outside it are drawn faded. + let faded = s.active_component != 0 && !s.doc.component_within(s.doc.body_component(&b.name, b.feature), s.active_component); + let base = s.doc.materials.get(&b.name).and_then(|m| crate::theme::material_color(m)).unwrap_or(tk.body); + let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) }; let m = b.mesh(); for t in &m.triangles { for k in t { let i = *k as usize; if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) { - sc.tri(p.to_f32(), n.to_f32(), col); + if faded { + sc.trans_tri(p.to_f32(), n.to_f32(), col); + } else { + sc.tri(p.to_f32(), n.to_f32(), col); + } } } } @@ -219,7 +236,8 @@ fn build_scene(app: &SolveApp) -> GpuScene { let active = s.active_sketch == Some(sid); // Closed profiles are shaded (lifted off a face they may lie on). let lift = ss.plane.normal() * 0.01; - for p in &ss.profiles { + let profiles = if app.ui.hidden_profiles.contains(&sid) { &[][..] } else { &ss.profiles[..] }; + for p in profiles { for tri in p.region.triangulate(0.05) { for q in tri { sc.trans_tri((ss.plane.to_world(q) + lift).to_f32(), [0.0; 3], c4(tk.profile_fill)); @@ -563,6 +581,7 @@ fn highlight_key(app: &SolveApp) -> u64 { serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h); format!("{:?}", app.viewport.hover).hash(&mut h); app.viewport.hover_feature.hash(&mut h); + app.viewport.hover_bodies.hash(&mut h); app.ui.pick_bodies.hash(&mut h); crate::theme::is_dark().hash(&mut h); app.preview.replaced.hash(&mut h); @@ -660,10 +679,12 @@ fn build_highlight(app: &SolveApp) -> GpuScene { _ => {} } } - // Bodies made by the timeline item under the cursor. - if let Some(fid) = app.viewport.hover_feature { + // Bodies made by the timeline item under the cursor, or whose browser row is. + if app.viewport.hover_feature.is_some() || !app.viewport.hover_bodies.is_empty() { let k = t.hover_face_lift; - for b in st.bodies.iter().filter(|b| b.feature == fid && !app.ui.hidden_bodies.contains(&b.name)) { + let lit = + |b: &solvecraft_engine::doc::ModelBody| Some(b.feature) == app.viewport.hover_feature || app.viewport.hover_bodies.contains(&b.name); + for b in st.bodies.iter().filter(|b| lit(b) && !app.ui.hidden_bodies.contains(&b.name)) { let c = body_rgb(app, &b.name); let m = b.mesh(); for f in 0..b.body.face_count() { @@ -783,13 +804,19 @@ pub fn delete_selection(app: &mut SolveApp) { } let mut sketch_ids: Vec = Vec::new(); let mut features: Vec = Vec::new(); + let mut bodies: Vec = Vec::new(); for x in &sel { match x { Sel::SketchCurve { id } | Sel::SketchPoint { id } => sketch_ids.push(id.clone()), Sel::Feature { id } => features.push(*id), + Sel::Body { name } if !app.ui.locked_bodies.contains(name) => bodies.push(name.clone()), _ => {} } } + // Deleting a body removes it from here on in the timeline (a Remove feature). + if !bodies.is_empty() && app.session.active_sketch.is_none() { + let _ = app.run("SoftDeleteCommand", json!({ "bodies": bodies })); + } if !sketch_ids.is_empty() && app.session.active_sketch.is_some() { let _ = app.run("sketch.delete", json!({"entities": sketch_ids})); } @@ -815,6 +842,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { i.pointer.delta(), ) }); + // The right button belongs to the marking menu while it is open. + let secondary_down = secondary_down && app.viewport.context_menu.is_none(); // The view cube handles its own clicks; the model underneath must not see them. let cube = Rect::from_center_size(pos2(rect.right() - 80.0, rect.top() + 80.0), vec2(150.0, 150.0)); let inside = hover.is_some_and(|p| rect.contains(p) && !cube.contains(p)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 41a093b5..67c6b72e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -25,9 +25,12 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.set` | any `UiState` field (`tab`, `perspective`, `showGrid`, `hiddenBodies`, `dark`, …) | `dark: false` switches to the light theme | | `ui.view` | `view`: `front`, `back`, `top`, `bottom`, `left`, `right`, `iso`, `home`, `fit`; `animate` (bool, default false: snap) | | | `ui.start` | `command` | like a toolbar click: starts the sketch tool or dialog for the command | -| `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?` | real pointer input | +| `ui.click` | `x`, `y` (screen points), `button?`: left/right/middle, `shift?`, `ctrl?`, `double?` | real pointer input | | `ui.move` | `x`, `y` | | -| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing) | +| `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it) | +| `ui.menu` | `x?`, `y?`, `target?`, `close?` | with a point: open a context menu there as a right-click would (the viewport's marking menu, or a browser menu with `target: {"type": "body", "name"} \| {"type": "sketch", "id"} \| {"type": "component", "id"}`); without: the open menu (`radial` and `items`: id, label, shortcut, enabled) | +| `ui.menuPick` | `item`: id or label | runs an item of the open menu, as clicking it does | +| `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened | | `ui.selection` | | the selection, the open dialog's inputs and the hovered item | | `ui.editFeature` | `feature` (id or name) | what double-clicking a timeline item does: rolls back to it and opens its dialog filled in (sketches open in sketch mode) | | `ui.scroll` | `x`, `y`, `delta?` | wheel zoom at the cursor |