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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 22:46:34 +00:00
1 parent 3229fd9740
commit 79a4194f6e
5 files changed
+208 -4

No files matched your search

+83
View File
@@ -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<Pos2> {
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<Pos2> = (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();
+105
View File
@@ -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<String> = 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!({}))),
_ => {}
}
}
+11
View File
@@ -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<dialogs::Dialog>,
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;
}
+1 -2
View File
@@ -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;
}
+8 -2
View File
@@ -40,6 +40,8 @@ pub struct ViewportState {
/// Timeline item under the cursor: its bodies are highlighted.
pub hover_feature: Option<u64>,
pub build_ms: f64,
/// The right-click menu, open at this screen position.
pub context_menu: Option<Pos2>,
}
/// 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.