Files
OneCAD/crates/ui-egui/src/sketch_dims.rs
T
Tobias Breckle ed943a74e6 A sketch line end on a curve within the axis snap lands where the preview shows (#1122)
A line end snapped onto a curve within the horizontal/vertical snap slides along the curve to where the axis crosses it; the preview, value boxes and click share one placement (#1121).
2026-10-14 13:24:19 -04:00

479 lines
22 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,
}
}
/// The point a click places for the hover `p` (the preview draws this one): typed values win
/// over the cursor and drop its snap; a line's free end snaps to horizontal/vertical, one on
/// sketch geometry stays there, and one on a curve slides along it to where the axis crosses.
pub fn placed(app: &SolveApp, t: &Tool, p: &(Vec2, Option<String>)) -> (Vec2, Option<String>) {
let line = t.cmd == "sketch.line";
if t.dims.iter().any(|b| b.locked) || (line && p.1.is_none()) {
return (effective(app, t, p.0), None);
}
if line && let Some(q) = on_curve_axis(app, t, p) {
return q;
}
p.clone()
}
/// Where the preview and the value boxes put the tool's next point for the hover `p`: for a
/// line, exactly what a click places.
pub fn shown(app: &SolveApp, t: &Tool, p: &(Vec2, Option<String>)) -> Vec2 {
if t.cmd == "sketch.line" { placed(app, t, p).0 } else { effective(app, t, p.0) }
}
/// A line end snapped onto a curve (`on:<curve>:x,y`) within the axis snap: the point where the
/// horizontal/vertical line from the start crosses that curve near the cursor (from a circle's
/// centre, its quadrant point), still on the curve. None: no axis snap there, or the axis misses
/// the curve close by (the curve snap wins as it is).
fn on_curve_axis(app: &SolveApp, t: &Tool, p: &(Vec2, Option<String>)) -> Option<(Vec2, Option<String>)> {
let a = t.pts.last()?.0;
let (curve, _) = p.1.as_deref()?.strip_prefix("on:")?.rsplit_once(':')?;
let v = p.0 - a;
let ang = snap_axis(v.angle());
if ang == v.angle() {
return None;
}
let st = app.session.world_state();
let sk = &st.sketch(app.session.active_sketch?)?.sketch;
let shape = sk.shape(sk.curve_index(curve)?)?;
let dir = Vec2::from_angle(ang);
let horizontal = dir.y.abs() < dir.x.abs();
let axis = solidcraft_engine::sketch::Shape::Line { a, b: a + dir };
let tol = 1e-9 * (1.0 + v.len());
let q = solidcraft_engine::sketch::intersections(&axis, &shape)
.into_iter()
// Exactly on the axis, so the command infers the horizontal/vertical constraint.
.map(|q| if horizontal { Vec2::new(q.x, a.y) } else { Vec2::new(a.x, q.y) })
.filter(|q| (*q - a).dot(dir) > tol && shape.dist(*q) <= tol && q.dist(p.0) <= v.len() * 0.1)
.min_by(|x, y| x.dist(p.0).total_cmp(&y.dist(p.0)))?;
Some((q, Some(format!("on:{curve}:{},{}", q.x, q.y))))
}
/// 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 e = shown(app, t, t.hover.as_ref()?);
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;