Files
OneCAD/crates/ui-egui/src/sketch_dims.rs
T
bherbruckandClaude Opus 5.5 97ff7578c2 Rounded Rectangle sketch tool (#891) (#948)
* Rounded Rectangle sketch tool (#891)

A new SKETCH > CREATE command, sketch.rectangle.rounded, in the Rectangle
family: two opposite corners and a corner radius make a 2-point rectangle
whose four corners are filleted alike, as one undo step. The radius is one
parameter (one radius dimension, the other arcs equal to it), so changing it
changes all four corners. While drawing, Width, Height and Radius boxes take
typed values; typed width and height dimension the overall size between
opposite sides. Without a typed radius the corners get a fifth of the shorter
side.

Fixes #891

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rounded Rectangle in the Simplified Chinese catalog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:52:10 -04:00

432 lines
20 KiB
Rust

//! Inline dimensions while drawing in a sketch. Once a shape's first point is down, value
//! boxes (line length and angle, rectangle width and height, circle diameter…) sit next to the
//! segment they measure and follow the cursor. Typing goes straight into the first box and
//! locks that value; Tab moves to the next box; Enter places the shape with the typed values,
//! which become dimension constraints. Values are expressions ("25", "1 in", "d1 * 2").
use egui::{Pos2, vec2};
use serde_json::{Value, json};
use solidcraft_engine::doc::expr::Kind as ValueKind;
use solidcraft_engine::geom::Vec2;
use crate::SolveApp;
use crate::theme::Tokens;
use crate::tools::Tool;
use crate::viewport::Proj;
/// One value box.
#[derive(Clone, Debug)]
pub struct DimBox {
pub label: &'static str,
pub kind: ValueKind,
/// The expression shown (the live value while not locked).
pub value: String,
/// Typed by the user: the value no longer follows the cursor.
pub locked: bool,
}
impl DimBox {
fn new(label: &'static str, kind: ValueKind) -> DimBox {
DimBox { label, kind, value: String::new(), locked: false }
}
}
/// The boxes a tool shows after `n` points.
pub fn boxes_for(cmd: &str, n: usize) -> Vec<DimBox> {
use ValueKind::*;
let b = DimBox::new;
match (cmd, n) {
("sketch.line", n) if n >= 1 => vec![b("Length", Length), b("Angle", Angle)],
("sketch.rectangle.two_point" | "sketch.rectangle.center", 1) => vec![b("Width", Length), b("Height", Length)],
("sketch.rectangle.rounded", 1) => vec![b("Width", Length), b("Height", Length), b("Radius", Length)],
("sketch.circle.center", 1) => vec![b("Diameter", Length)],
("sketch.arc.center_point", 1) => vec![b("Radius", Length)],
("sketch.arc.center_point", 2) => vec![b("Sweep", Angle)],
("sketch.polygon.inscribed" | "sketch.polygon.circumscribed", 1) => vec![b("Radius", Length), b("Sides", Unitless)],
("sketch.slot.center_to_center" | "sketch.slot.overall", 1) => vec![b("Length", Length)],
("sketch.conic", 2) => vec![b("Rho", Unitless)],
("sketch.slot.center_to_center" | "sketch.slot.overall", 2) => vec![b("Width", Length)],
_ => Vec::new(),
}
}
/// A locked box's value (mm or radians).
fn locked(app: &SolveApp, t: &Tool, label: &str) -> Option<f64> {
let b = t.dims.iter().chain(&t.dim_history).find(|b| b.label == label && b.locked)?;
app.session.doc.eval(&b.value, b.kind).ok().filter(|v| v.is_finite())
}
fn dir_or_x(v: Vec2) -> Vec2 {
v.normalized().unwrap_or(Vec2::X)
}
fn sign(x: f64) -> f64 {
if x < 0.0 { -1.0 } else { 1.0 }
}
/// The next point the tool would place for the cursor at `h`, honouring locked values.
pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 {
let Some(a) = t.pts.last().map(|p| p.0) else { return h };
let lk = |l: &str| locked(app, t, l);
match (t.cmd, t.pts.len()) {
("sketch.line", _) | ("sketch.slot.center_to_center" | "sketch.slot.overall", 1) => {
let v = h - a;
let len = lk("Length").unwrap_or(v.len());
let ang = lk("Angle").unwrap_or_else(|| snap_axis(v.angle()));
a + Vec2::from_angle(ang) * len
}
("sketch.rectangle.two_point" | "sketch.rectangle.rounded", 1) => {
let d = h - a;
Vec2::new(a.x + lk("Width").map_or(d.x, |w| w * sign(d.x)), a.y + lk("Height").map_or(d.y, |hh| hh * sign(d.y)))
}
("sketch.rectangle.center", 1) => {
let d = h - a;
Vec2::new(a.x + lk("Width").map_or(d.x, |w| w * 0.5 * sign(d.x)), a.y + lk("Height").map_or(d.y, |hh| hh * 0.5 * sign(d.y)))
}
("sketch.circle.center", 1) => a + dir_or_x(h - a) * lk("Diameter").map_or((h - a).len(), |d| d * 0.5),
("sketch.arc.center_point", 1) => a + dir_or_x(h - a) * lk("Radius").unwrap_or((h - a).len()),
("sketch.arc.center_point", 2) => {
let c = t.pts.first().map(|p| p.0).unwrap_or(a);
let r = (a - c).len();
let a0 = (a - c).angle();
let sweep = lk("Sweep").unwrap_or_else(|| ((h - c).angle() - a0).rem_euclid(std::f64::consts::TAU));
c + Vec2::from_angle(a0 + sweep) * r
}
("sketch.polygon.inscribed" | "sketch.polygon.circumscribed", 1) => a + dir_or_x(h - a) * lk("Radius").unwrap_or((h - a).len()),
("sketch.slot.center_to_center" | "sketch.slot.overall", 2) => match lk("Width") {
Some(w) => {
let c0 = t.pts.first().map(|p| p.0).unwrap_or(a);
let dir = dir_or_x(a - c0);
let n = dir.perp();
let side = sign(n.dot(h - c0));
c0 + (a - c0) * 0.5 + n * (w * 0.5 * side)
}
None => h,
},
_ => h,
}
}
/// Angles within a few degrees of horizontal or vertical snap to it (the line is drawn
/// horizontal or vertical, and the command infers the constraint).
pub fn snap_axis(ang: f64) -> f64 {
let q = std::f64::consts::FRAC_PI_2;
let k = (ang / q).round();
if (ang - k * q).abs() < 2.5f64.to_radians() { k * q } else { ang }
}
/// Is the segment exactly horizontal or vertical (an inferred constraint)?
pub fn axis_aligned(a: Vec2, b: Vec2) -> Option<bool> {
let d = b - a;
if d.len() < 1e-9 {
return None;
}
if d.y.abs() < 1e-9 * d.len() {
Some(true)
} else if d.x.abs() < 1e-9 * d.len() {
Some(false)
} else {
None
}
}
/// The value each box shows while it follows the cursor, and where it sits (sketch coordinates).
/// `px` is the size of a screen pixel in sketch units: boxes sit a little off the segment they
/// measure so they don't cover it.
fn live(app: &SolveApp, t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> {
let Some(a) = t.pts.last().map(|p| p.0) else { return Vec::new() };
let first = t.pts.first().map(|p| p.0).unwrap_or(a);
let off = 20.0 * px;
match (t.cmd, t.pts.len()) {
("sketch.line", _) | ("sketch.slot.center_to_center" | "sketch.slot.overall", 1) => {
let v = e - a;
let d = dir_or_x(v);
let n = d.perp();
vec![(v.len(), (a + e) * 0.5 + n * off), (v.angle(), a + d * (v.len() * 0.3).min(60.0 * px) - n * off)]
}
("sketch.rectangle.two_point", 1) => {
let (sx, sy) = (sign(e.x - a.x), sign(e.y - a.y));
vec![
((e.x - a.x).abs(), Vec2::new((a.x + e.x) * 0.5, a.y - sy * off)),
((e.y - a.y).abs(), Vec2::new(e.x + sx * 2.0 * off, (a.y + e.y) * 0.5)),
]
}
("sketch.rectangle.rounded", 1) => {
let (sx, sy) = (sign(e.x - a.x), sign(e.y - a.y));
let r = rounded_radius(app, t, a, e);
vec![
((e.x - a.x).abs(), Vec2::new((a.x + e.x) * 0.5, a.y - sy * off)),
((e.y - a.y).abs(), Vec2::new(e.x + sx * 2.0 * off, (a.y + e.y) * 0.5)),
// Outside the first corner, away from the cursor (a box there would take the click).
(r, Vec2::new(a.x - sx * 2.0 * off, a.y - sy * off)),
]
}
("sketch.rectangle.center", 1) => {
let d = e - a;
let (sx, sy) = (sign(d.x), sign(d.y));
vec![(2.0 * d.x.abs(), Vec2::new(a.x, a.y - d.y - sy * off)), (2.0 * d.y.abs(), Vec2::new(a.x + d.x + sx * 2.0 * off, a.y))]
}
("sketch.circle.center", 1) => vec![(2.0 * (e - a).len(), (a + e) * 0.5 + dir_or_x(e - a).perp() * off)],
("sketch.arc.center_point", 1) | ("sketch.polygon.inscribed" | "sketch.polygon.circumscribed", 1) => {
vec![((e - a).len(), (a + e) * 0.5), (6.0, a + (e - a) * 0.5 + (e - a).perp() * 0.3)]
}
("sketch.arc.center_point", 2) => {
let a0 = (a - first).angle();
vec![(((e - first).angle() - a0).rem_euclid(std::f64::consts::TAU), e)]
}
("sketch.conic", 2) => {
// `rho` is the curve's fullness, not a distance: the conic passes through
// `chord midpoint + rho * (apex - chord midpoint)`, so the box sits on that point
// (the part of the curve the value moves) with the default parabola's 0.5 shown
// until a value is typed.
let rho = locked(app, t, "Rho").unwrap_or(0.5);
let mid = (first + a) * 0.5;
let d = e - mid;
vec![(rho, mid + d * rho + dir_or_x(d).perp() * off)]
}
("sketch.slot.center_to_center" | "sketch.slot.overall", 2) => {
let dir = dir_or_x(a - first);
vec![(2.0 * dir.cross(e - first).abs(), e)]
}
_ => Vec::new(),
}
}
fn fmt(app: &SolveApp, label: &str, kind: ValueKind, v: f64) -> String {
match kind {
ValueKind::Angle => format!("{:.1} deg", v.to_degrees()),
// A count rounds to a whole number; a ratio keeps its decimals (0.5, not 1).
ValueKind::Unitless if label == "Rho" => {
let text = format!("{v:.3}");
let text = text.trim_end_matches('0').trim_end_matches('.');
if text.is_empty() { "0".to_string() } else { text.to_string() }
}
ValueKind::Unitless => format!("{}", v.round()),
ValueKind::Length => crate::prefs::show_mm_bare(app, v),
}
}
/// Make the tool's boxes match its stage (new boxes when a point was placed).
pub fn sync(t: &mut Tool) {
if t.dims_stage != t.pts.len() {
if t.dims_stage == 1
&& t.pts.len() == 2
&& matches!(t.cmd, "sketch.arc.center_point" | "sketch.slot.center_to_center" | "sketch.slot.overall")
{
t.dim_history.extend(t.dims.iter().filter(|dimension| dimension.locked).cloned());
} else if t.pts.len() <= t.dims_stage || t.cmd == "sketch.line" {
t.dim_history.clear();
}
t.dims = boxes_for(t.cmd, t.pts.len());
t.dims_stage = t.pts.len();
t.dims_focus = !t.dims.is_empty();
}
}
/// Clear a shape's input immediately, including when another first point arrives before redraw.
pub fn reset(t: &mut Tool) {
t.dims.clear();
t.dim_history.clear();
t.dims_stage = usize::MAX;
t.dims_focus = false;
}
pub fn has_locked(t: &Tool, label: &str) -> bool {
t.dims.iter().chain(&t.dim_history).any(|dimension| dimension.locked && dimension.label == label)
}
/// Original expressions, rather than sampled numbers, become the shape's permanent dimensions.
pub fn dimensions(t: &Tool) -> Value {
let values = t
.dim_history
.iter()
.chain(&t.dims)
// A rounded rectangle's radius is its own parameter (sent with the shape), not a dimension.
.filter(|dimension| {
dimension.locked && !matches!(dimension.label, "Sides" | "Rho") && !(t.cmd == "sketch.rectangle.rounded" && dimension.label == "Radius")
})
.map(|dimension| (dimension.label.to_ascii_lowercase(), json!(dimension.value)))
.collect::<serde_json::Map<_, _>>();
Value::Object(values)
}
/// Invalid values stay in the current field, so users can correct them before another stage.
pub fn validate(app: &SolveApp, t: &Tool) -> Result<(), String> {
for dimension in t.dim_history.iter().chain(&t.dims).filter(|dimension| dimension.locked) {
if dimension.value.len() > 4096 {
return Err(format!("{} expression is too long", dimension.label));
}
let value = app.session.doc.eval(&dimension.value, dimension.kind).map_err(|error| format!("{}: {error}", dimension.label))?;
if !value.is_finite() || value.abs() > 1e9 {
return Err(format!("{} must be finite and within the model's size limits", dimension.label));
}
match dimension.label {
"Angle" => {}
"Sweep" if value.abs() > 1e-9 && value.abs() < std::f64::consts::TAU - 1e-9 => {}
"Sweep" => return Err("Sweep must lie strictly between -360 and 360 degrees, and cannot be zero".into()),
"Sides" if (3.0..=200.0).contains(&value) && value.fract() == 0.0 => {}
"Sides" => return Err("Sides must be an integer from 3 to 200".into()),
"Rho" if value > 1e-6 && value < 1.0 - 1e-6 => {}
"Rho" => return Err("Rho must lie strictly between 0 and 1 (below 0.5 an ellipse, 0.5 a parabola, above 0.5 a hyperbola)".into()),
_ if value > 1e-9 => {}
_ => return Err(format!("{} must be positive", dimension.label)),
}
}
if t.cmd == "sketch.slot.overall"
&& let (Some(length), Some(width)) = (locked(app, t, "Length"), locked(app, t, "Width"))
&& length <= width
{
return Err("Overall slot Length must exceed Width".into());
}
Ok(())
}
/// The corner radius a rounded rectangle from `a` to `b` gets: the typed Radius, or a fifth of
/// the shorter side.
pub fn rounded_radius(app: &SolveApp, t: &Tool, a: Vec2, b: Vec2) -> f64 {
locked(app, t, "Radius").unwrap_or_else(|| 0.2 * (b.x - a.x).abs().min((b.y - a.y).abs()))
}
/// The radius to send with the rounded rectangle: the typed expression (it becomes the radius
/// parameter as written, "d1 / 2" included), or the default as a number.
pub fn rounded_radius_value(app: &SolveApp, t: &Tool, a: Vec2, b: Vec2) -> Value {
match t.dims.iter().chain(&t.dim_history).find(|d| d.label == "Radius" && d.locked) {
Some(d) => json!(d.value),
None => json!(rounded_radius(app, t, a, b)),
}
}
/// A locked box value by label (for the polygon side count).
pub fn locked_value(app: &SolveApp, t: &Tool, label: &str) -> Option<f64> {
locked(app, t, label)
}
/// Draw the boxes of the active tool next to what they measure. Enter places the point.
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, proj: &Proj) {
// Esc in a box ends the shape (the box had the keyboard, so the shortcut didn't see it).
let in_box =
app.tool.as_ref().is_some_and(|t| (0..t.dims.len()).any(|i| ui.ctx().memory(|m| m.had_focus_last_frame(egui::Id::new(("sc_dim", i))))));
if in_box && !app.esc_handled && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.esc_handled = true;
crate::tools::finish(app);
return;
}
let Some(mut t) = app.tool.take() else { return };
sync(&mut t);
let place = if t.dims.is_empty() { None } else { boxes(app, ui, proj, &mut t) };
app.tool = Some(t);
if let Some(p) = place {
crate::tools::place(app, (p, None));
}
}
fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option<Vec2> {
let tk = Tokens::get();
let sid = app.session.active_sketch?;
let st = app.session.world_state();
let plane = st.sketch(sid)?.plane;
let h = t.hover.as_ref().map(|x| x.0)?;
let e = effective(app, t, h);
let px = 2.0 * app.cam.half_height() / f64::from(proj.rect.height().max(1.0));
let lv = live(app, t, e, px);
let focus = std::mem::take(&mut t.dims_focus);
// Egui processes ordinary Tab navigation before these floating text edits are drawn.
// Remember the box that owned the keyboard and restore a focus within this tool after
// drawing, so the last dimension wraps to the first rather than reaching the panels.
let tab = ui.input(|input| {
input.raw.events.iter().find_map(|event| match event {
egui::Event::Key { key: egui::Key::Tab, pressed: true, modifiers, .. } if !modifiers.ctrl && !modifiers.alt && !modifiers.command => {
Some(modifiers.shift)
}
_ => None,
})
});
let source = tab.and_then(|_| {
ui.ctx().memory(|memory| {
(0..t.dims.len())
.find(|i| memory.had_focus_last_frame(egui::Id::new(("sc_dim", i))))
.or_else(|| (0..t.dims.len()).find(|i| memory.has_focus(egui::Id::new(("sc_dim", i)))))
})
});
if source.is_some() {
// Also cancel navigation on the first focused frame, before the text edit's focus
// filter has been installed (e.g. immediately repeated keyboard input).
ui.ctx().memory_mut(|memory| memory.move_focus(egui::FocusDirection::None));
}
let mut completed = false;
let mut any_focused = false;
for (i, b) in t.dims.iter_mut().enumerate() {
let Some((v, at)) = lv.get(i).copied() else { continue };
if !b.locked {
b.value = fmt(app, b.label, b.kind, v);
}
let Some(sp) = proj.to_screen(plane.to_world(at)) else { continue };
let id = egui::Id::new(("sc_dim", i));
let has_focus = ui.ctx().memory(|m| m.has_focus(id));
// While a box follows the cursor its whole text is selected, so typing replaces it.
if (has_focus || (focus && i == 0)) && !b.locked {
let mut s = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
let n = b.value.chars().count();
s.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
s.store(ui.ctx(), id);
}
let pos: Pos2 = sp + vec2(-34.0, -11.0);
crate::frame::area(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
// The drawing tool handles Enter. Keep the text edit focused when a
// rejected value needs correction, instead of surrendering the keyboard.
.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0).return_key(None).event_filter(egui::EventFilter {
tab: true,
horizontal_arrows: true,
vertical_arrows: true,
..Default::default()
}))
.on_hover_text(b.label);
// A completion gets first use of forward Tab. Shift+Tab always moves back;
// the box reached by egui's generic traversal must not complete its text.
let changed = (source.is_none() || (source == Some(i) && tab != Some(true))) && crate::params_dialog::complete(ui, &r, &mut b.value);
completed |= changed && source == Some(i);
if r.changed() || changed {
b.locked = true;
}
if focus && i == 0 {
r.request_focus();
}
any_focused |= r.has_focus() || r.lost_focus();
});
});
}
if let Some(source) = source
&& !completed
{
let next = if tab == Some(true) { (source + t.dims.len() - 1) % t.dims.len() } else { (source + 1) % t.dims.len() };
let id = egui::Id::new(("sc_dim", next));
if let Some(dimension) = t.dims.get(next) {
let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(
egui::text::CCursor::new(0),
egui::text::CCursor::new(dimension.value.chars().count()),
)));
state.store(ui.ctx(), id);
}
ui.ctx().memory_mut(|memory| memory.request_focus(id));
}
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let nothing = ui.ctx().memory(|m| m.focused().is_none());
(enter && (any_focused || nothing)).then_some(e)
}
#[cfg(test)]
#[path = "sketch_dims_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "sketch_typed_intent_tests.rs"]
mod typed_intent_tests;
#[cfg(test)]
#[path = "sketch_clover_ui_tests.rs"]
mod clover_ui_tests;