From 79a4194f6efcf9d304c6bc73793b5bf38a464af8 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 22:43:21 +0000 Subject: [PATCH] Revolve angle rotator (drag the arc end, 5 degree steps), right-click menu in the viewport (Repeat last command, commands for the selection, fit/home/undo), larger inline sketch dimension boxes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/ui-egui/src/canvas.rs | 83 +++++++++++++++++++++++ crates/ui-egui/src/context_menu.rs | 105 +++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 11 +++ crates/ui-egui/src/sketch_dims.rs | 3 +- crates/ui-egui/src/viewport.rs | 10 ++- 5 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/context_menu.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c35ae22d..20b4541a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -115,9 +115,15 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, let signed = matches!(d.kind, Kind::Extrude { .. } | Kind::Move { .. }); let focus = std::mem::take(&mut d.focus); let half_height = app.cam.half_height(); + let axis = revolve_axis(app, d); let Some((label, kind, value)) = d.primary() else { return }; let len = app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()); let mut box_at = bs + vec2(16.0, -30.0); + if let (Some(axis), Some(angle)) = (axis, len.filter(|_| kind == ValueKind::Angle)) + && let Some(at) = rotator(ui, painter, proj, base, axis, angle, value) + { + box_at = at + vec2(14.0, -12.0); + } if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) { let n = n * sign; // A short arrow still shows (and can be grabbed) when the value is near zero. @@ -171,6 +177,83 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, }); } +/// The revolve axis (a point on it and its unit direction). +fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> { + if !matches!(d.kind, Kind::Revolve { .. }) { + return None; + } + match d.inputs.get(1)?.items.first()? { + Sel::Axis { name } => Some(( + Vec3::ZERO, + match name.as_str() { + "X" => Vec3::X, + "Y" => Vec3::Y, + _ => Vec3::Z, + }, + )), + Sel::SketchCurve { id } => { + let st = app.session.model.state(); + let sketch = d.inputs.first()?.items.iter().find_map(|x| if let Sel::Profile { sketch, .. } = x { Some(*sketch) } else { None })?; + let ss = st.sketch(sketch)?; + let c = ss.sketch.curves.get(ss.sketch.curve_index(id)?)?; + let solvecraft_engine::sketch::CurveKind::Line { a, b } = c.kind else { return None }; + let (pa, pb) = (ss.plane.to_world(ss.sketch.point(a)?), ss.plane.to_world(ss.sketch.point(b)?)); + Some((pa, (pb - pa).normalized()?)) + } + _ => None, + } +} + +/// The revolve angle rotator: an arc from the profile around the axis with a handle at its end +/// that drags the angle (5° steps; Alt or Ctrl: free). Returns the handle's screen position. +#[allow(clippy::too_many_arguments)] +fn rotator(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, p0: Vec3, (o, a): (Vec3, Vec3), angle: f64, value: &mut String) -> Option { + let t = Tokens::get(); + let c = o + a * (p0 - o).dot(a); + let r = (p0 - c).len(); + if r < 1e-9 { + return None; + } + let e1 = (p0 - c) / r; + let e2 = a.cross(e1); + let at = |th: f64| c + (e1 * th.cos() + e2 * th.sin()) * r; + let n = 48; + let pts: Vec = (0..=n).filter_map(|i| proj.to_screen(at(angle * i as f64 / n as f64))).collect(); + let end = proj.to_screen(at(angle))?; + let handle = egui::Rect::from_center_size(end, vec2(20.0, 20.0)); + let resp = ui.interact(handle, egui::Id::new("sc_rotator"), egui::Sense::drag()); + let hot = resp.hovered() || resp.dragged(); + let col = if hot { t.accent } else { t.manipulator }; + painter.add(egui::Shape::line(pts, Stroke::new(2.0, col))); + if let (Some(cs), Some(ps)) = (proj.to_screen(c), proj.to_screen(p0)) { + painter.add(egui::Shape::dashed_line(&[cs, ps], Stroke::new(1.0, col), 4.0, 3.0)); + } + painter.circle(end, if hot { 7.0 } else { 5.5 }, col, Stroke::new(1.5, Color32::WHITE)); + if hot { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + { + let (ro, rd) = proj.ray(p); + let den = rd.dot(a); + if den.abs() > 1e-9 { + let q = ro + rd * ((c - ro).dot(a) / den) - c; + let mut th = q.dot(e2).atan2(q.dot(e1)).to_degrees().rem_euclid(360.0); + let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command); + if !free { + th = (th / 5.0).round() * 5.0; + } + if th < 0.5 { + th = 360.0; + } + let s = format!("{th:.1}"); + *value = format!("{} deg", s.trim_end_matches('0').trim_end_matches('.')); + } + } + Some(end) +} + fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) { let v = b - a; let l = v.length(); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs new file mode 100644 index 00000000..11a33e2d --- /dev/null +++ b/crates/ui-egui/src/context_menu.rs @@ -0,0 +1,105 @@ +//! 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. + +use egui::{Pos2, vec2}; +use serde_json::json; +use solvecraft_engine::Sel; + +use crate::SolveApp; + +/// 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")); + } + 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. +pub fn open(app: &mut SolveApp, at: Pos2) { + app.viewport.context_menu = Some(at); +} + +/// Show the menu if it is open; a pick or a click elsewhere closes it. +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + 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(); + } + for (id, label) in for_selection(app) { + if item(ui, label) { + pick = Some(id.to_string()); + } + } + ui.separator(); + 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"); + } + }); + }); + 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; + } + if let Some(id) = pick { + app.start(&id); + } + match other { + Some("clear") => drop(app.run("select.clear", json!({}))), + Some("fit") => app.animate_view("fit"), + Some("home") => app.animate_view("home"), + Some("undo") => drop(app.run("UndoCommand", json!({}))), + _ => {} + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7f4c5609..0a248ba6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -10,6 +10,7 @@ pub mod browser; pub mod canvas; +pub mod context_menu; pub mod control; pub mod dialogs; pub mod gpu; @@ -99,6 +100,8 @@ pub struct SolveApp { pub dialog: Option, pub palette: palette::Palette, pub preview: preview::PreviewState, + /// The last command started interactively (id, label), for Repeat. + pub last_command: Option<(String, String)>, pub status: Option<(String, f64, bool)>, pub quit_requested: bool, pub frame_ms: f64, @@ -127,6 +130,7 @@ impl SolveApp { dialog: None, palette: palette::Palette::default(), preview: preview::PreviewState::default(), + last_command: None, status: None, quit_requested: false, frame_ms: 0.0, @@ -222,6 +226,12 @@ impl SolveApp { return; } } + if let Some(spec) = solvecraft_engine::find_command(id) + && (tools::Tool::for_command(id).is_some() || dialogs::Dialog::for_command(self, id).is_some()) + && id != "SketchCreate" + { + self.last_command = Some((spec.id.to_string(), spec.label.to_string())); + } if let Some(t) = tools::Tool::for_command(id) { self.tool = Some(t); self.set_status(tools::hint(id), false); @@ -486,6 +496,7 @@ impl SolveApp { viewport::show(self, ui); }); dialogs::show(self, ui.ctx()); + context_menu::show(self, ui.ctx()); palette::popup(self, ui.ctx()); self.frame_ms = now_ms() - t0; } diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index 9f920e05..a5b6f6ca 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -225,8 +225,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option egui::Area::new(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| { let fill = if b.locked { tk.accent_soft } else { tk.panel }; egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| { - let r = - ui.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(62.0).font(egui::TextStyle::Small)).on_hover_text(b.label); + let r = ui.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0)).on_hover_text(b.label); if r.changed() { b.locked = true; } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index b0743e70..2eb1cbb2 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -40,6 +40,8 @@ pub struct ViewportState { /// Timeline item under the cursor: its bodies are highlighted. pub hover_feature: Option, pub build_ms: f64, + /// The right-click menu, open at this screen position. + pub context_menu: Option, } /// Something under the cursor. @@ -868,8 +870,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { select(app, cand.and_then(|c| c.1), add); } } - if resp.secondary_clicked() && delta == egui::Vec2::ZERO && app.tool.is_some() { - crate::tools::finish(app); + if resp.secondary_clicked() && delta == egui::Vec2::ZERO { + if app.tool.is_some() { + crate::tools::finish(app); + } else if app.dialog.is_none() { + crate::context_menu::open(app, p); + } } } // Box selection: a primary drag on the model when no navigation mode is on.