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:
1 parent
3229fd9740
commit
79a4194f6e
5 files changed
+208
-4
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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!({}))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user