380 lines
17 KiB
Rust
380 lines
17 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 solvecraft_engine::doc::expr::Kind as ValueKind;
|
|
use solvecraft_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.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.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", 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(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.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.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(kind: ValueKind, v: f64) -> String {
|
|
match kind {
|
|
ValueKind::Angle => format!("{:.1} deg", v.to_degrees()),
|
|
ValueKind::Unitless => format!("{}", v.round()),
|
|
ValueKind::Length => format!("{v:.2}"),
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
.filter(|dimension| dimension.locked && dimension.label != "Sides")
|
|
.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()),
|
|
_ 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(())
|
|
}
|
|
|
|
/// 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(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(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;
|