Sketch Dimension: D, click, type (#695)

As soon as a pick settles the dimension (one line, circle or arc, or the second point or line of a distance or angle), its value box takes the keyboard with the measured value selected. The text follows the cursor while typing and Enter places it there (or in its default place), as one undo step. A click before typing pins the text and keeps the box focused, a click on another entity makes a distance or angle, Tab hands back to placing first, Esc cancels. Letters go into the box, not to tool shortcuts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-13 09:23:41 -04:00
1 parent 5ef1dcba8e
commit 1e18ca9a46
7 files changed
+400 -32

No files matched your search

+1 -1
View File
@@ -101,7 +101,7 @@ rebuild with no errors, replayed here at 2× speed.
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
step. Fit keeps sketch geometry clear of the palette and navigation controls.
step; with Sketch Dimension it's D, click, type, Enter. Fit keeps sketch geometry clear of the palette and navigation controls.
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
Newly captured complete adaptive boundaries retain their outer and hole source identities
when spline span counts change. Partial or ambiguous boundaries do not qualify.
+126 -24
View File
@@ -46,6 +46,18 @@ struct Edit {
/// Frames left to wait for the dimension to be drawn.
wait: u8,
draft: Option<DimensionDraft>,
/// Type-first creation: the value took the keyboard as soon as the picks determined the
/// dimension, and its text follows the pointer until a click pins it or Enter places it.
live: Option<Live>,
}
#[derive(Clone, Copy, Debug)]
struct Live {
/// The pointer (screen) at the pick: until it moves away, the text keeps its default place.
origin: Option<Pos2>,
moved: bool,
/// Something was typed into the value.
typed: bool,
}
/// Creation stays in a scratch session until the user accepts the value. Retaining the
@@ -58,7 +70,8 @@ struct DimensionDraft {
path: Option<Vec<u64>>,
pending: std::collections::BTreeMap<u64, solidcraft_engine::doc::Mat>,
entities: Vec<String>,
at: Vec2,
/// Where the text goes (None: the dimension's default place).
at: Option<Vec2>,
checked_expr: String,
preview_doc: Arc<solidcraft_engine::doc::Document>,
preview: Arc<solidcraft_engine::doc::ModelState>,
@@ -77,7 +90,11 @@ impl DimensionDraft {
&& app.session.pending_moves == self.pending
}
fn params(&self, expr: &str) -> serde_json::Value {
json!({"entities": self.entities, "text_at": [self.at.x, self.at.y], "value": expr})
let mut params = json!({"entities": self.entities, "value": expr});
if let Some(at) = self.at {
params["text_at"] = json!([at.x, at.y]);
}
params
}
fn evaluate(&self, app: &SolveApp, expr: &str) -> Result<solidcraft_engine::Session, String> {
let mut scratch = app.session.scratch();
@@ -107,21 +124,69 @@ pub fn cancel() {
/// parameter or an undo entry. Only accepting the editor executes the command on the design.
/// A driven dimension has no value to accept, so it is added at once.
pub fn begin_dimension(app: &mut SolveApp, entities: Vec<String>, at: Vec2) -> Result<(), String> {
let (param, expr, draft) = dimension_draft(app, entities, at)?;
let (param, expr, draft) = dimension_draft(app, entities, Some(at))?;
if draft.driven {
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
app.run("sketch.dimension", json!({"entities": draft.entities, "text_at": [at.x, at.y], "driven": true}))?;
app.set_status("That dimension would over-constrain the sketch, so it was added as a driven (reference) dimension", false);
return Ok(());
}
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft) }));
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft), live: None }));
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
Ok(())
}
fn dimension_draft(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(String, String, DimensionDraft), String> {
/// Type-first creation: the picks determine a dimension, so its value takes the keyboard at
/// once (D, click a line, type). The text follows the pointer; a click pins it, Enter places
/// it. False when the dimension would only be a driven one: that keeps the place-first flow,
/// where placing it adds it.
pub fn begin_live(app: &mut SolveApp, entities: Vec<String>, pointer: Option<Pos2>) -> Result<bool, String> {
let (param, expr, draft) = dimension_draft(app, entities, None)?;
if draft.driven {
return Ok(false);
}
let live = Some(Live { origin: pointer, moved: false, typed: false });
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft), live }));
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
Ok(true)
}
/// The type-first value is open: Some(whether anything was typed into it yet).
pub fn live() -> Option<bool> {
EDIT.with(|edit| edit.borrow().as_ref().and_then(|e| e.live.map(|l| l.typed)))
}
/// A click while the type-first value is open places its text there; the value keeps the
/// keyboard (and what was typed).
pub fn pin(app: &SolveApp, at: Vec2) {
EDIT.with(|edit| {
let mut edit = edit.borrow_mut();
let Some(e) = edit.as_mut() else { return };
let Some(live) = e.live.take() else { return };
if let Some(draft) = e.draft.as_mut() {
draft.at = Some(at);
reevaluate(app, draft, &e.expr);
}
// An untouched value stays selected, so typing still replaces it.
e.fresh = if live.typed { FRESH - 1 } else { FRESH };
});
}
fn reevaluate(app: &SolveApp, draft: &mut DimensionDraft, expr: &str) {
draft.checked_expr = expr.to_string();
match draft.evaluate(app, expr) {
Ok(scratch) => {
draft.preview = scratch.world_state();
draft.preview_doc = scratch.doc.clone();
draft.error = None;
}
Err(error) => draft.error = Some(error),
}
}
fn dimension_draft(app: &SolveApp, entities: Vec<String>, at: Option<Vec2>) -> Result<(String, String, DimensionDraft), String> {
let sketch = app.session.active_sketch.ok_or("Edit a sketch to add a dimension")?;
if entities.is_empty() || entities.len() > 2 || !at.x.is_finite() || !at.y.is_finite() {
if entities.is_empty() || entities.len() > 2 || at.is_some_and(|at| !at.x.is_finite() || !at.y.is_finite()) {
return Err("Pick one edge or two endpoints, then place the dimension".into());
}
// A dimension the sketch can't take (it would over-constrain it) becomes a driven one,
@@ -131,10 +196,13 @@ fn dimension_draft(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(S
/// The draft of one dimension: its parameter (for a driven one, its constraint id, since it
/// has no parameter), the parameter's expression, and the scratch session it was added in.
fn draft_of(app: &SolveApp, sketch: u64, entities: &[String], at: Vec2, driven: bool) -> Result<(String, String, DimensionDraft), String> {
fn draft_of(app: &SolveApp, sketch: u64, entities: &[String], at: Option<Vec2>, driven: bool) -> Result<(String, String, DimensionDraft), String> {
let mut scratch = app.session.scratch();
let result =
scratch.execute("sketch.dimension", &json!({"entities": entities, "text_at": [at.x, at.y], "driven": driven})).map_err(|e| e.to_string())?;
let mut params = json!({"entities": entities, "driven": driven});
if let Some(at) = at {
params["text_at"] = json!([at.x, at.y]);
}
let result = scratch.execute("sketch.dimension", &params).map_err(|e| e.to_string())?;
let (param, expr) = if driven {
let before =
app.session.doc.sketch(sketch).ok().map(|sk| sk.constraints.iter().map(|c| c.id.clone()).collect::<Vec<_>>()).unwrap_or_default();
@@ -194,7 +262,7 @@ fn placement_preview(app: &SolveApp) -> Option<(String, DimensionDraft)> {
&& p.at == at
});
if !current {
let draft = dimension_draft(app, tool.picks.clone(), at).ok().map(|(param, _, draft)| (param, draft));
let draft = dimension_draft(app, tool.picks.clone(), Some(at)).ok().map(|(param, _, draft)| (param, draft));
*preview = Some(Placement {
baseline: app.session.doc.clone(),
sketch: app.session.active_sketch,
@@ -282,7 +350,7 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool {
return true;
};
let expr = app.session.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0, draft: None }));
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0, draft: None, live: None }));
true
}
@@ -357,16 +425,25 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
if let Some(e) = edit.as_mut()
&& let Some(draft) = e.draft.as_mut()
{
if e.expr != draft.checked_expr {
draft.checked_expr = e.expr.clone();
match draft.evaluate(app, &e.expr) {
Ok(scratch) => {
draft.preview = scratch.world_state();
draft.preview_doc = scratch.doc.clone();
draft.error = None;
}
Err(error) => draft.error = Some(error),
let mut moved = false;
if let Some(live) = e.live.as_mut() {
// The text follows the pointer once it leaves the pick.
let pointer = ui.input(|i| i.pointer.hover_pos()).filter(|p| proj.rect.contains(*p));
if !live.moved && pointer.is_some_and(|p| live.origin.is_none_or(|o| o.distance(p) > 4.0)) {
live.moved = true;
}
let hover = app.tool.as_ref().and_then(|t| t.hover.as_ref()).map(|h| h.0);
if live.moved
&& let Some(at) = hover
&& draft.at != Some(at)
{
draft.at = Some(at);
moved = true;
}
}
if moved || e.expr != draft.checked_expr {
let expr = e.expr.clone();
reevaluate(app, draft, &expr);
}
Some((e.param.clone(), draft.clone()))
} else {
@@ -501,7 +578,7 @@ fn pos2_mid_top(r: Rect) -> Pos2 {
/// its value is selected, so typing replaces it).
pub fn edit_param(app: &SolveApp, param: &str) {
let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default();
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10, draft: None }));
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10, draft: None, live: None }));
}
fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
@@ -519,7 +596,10 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
};
let id = egui::Id::new("sc_dim_edit");
let mut done: Option<bool> = None;
let mut anchor = at - vec2(45.0, 11.0);
let mut escaped = false;
// A type-first value sits beside its text, clear of the pointer, so a click still reaches
// the sketch (to place the text or pick a second entity).
let mut anchor = if e.live.is_some() { at + vec2(18.0, 10.0) } else { at - vec2(45.0, 11.0) };
let mut popup_width = 220.0;
if let Some(layout) = &app.viewport.layout
&& layout.drawable.is_finite()
@@ -534,7 +614,10 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
ui.set_max_width(popup_width);
let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(popup_width.min(90.0)));
crate::params_dialog::complete(ui, &r, &mut e.expr);
let completed = crate::params_dialog::complete(ui, &r, &mut e.expr);
if let Some(live) = e.live.as_mut() {
live.typed |= r.changed() || completed;
}
if let Some(error) = e.draft.as_ref().and_then(|d| d.error.as_ref()) {
ui.add(egui::Label::new(egui::RichText::new(error).color(Tokens::get().error)).wrap());
}
@@ -557,6 +640,8 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
done = Some(false);
// Esc cancels the whole dimension, picks included.
escaped = true;
}
});
});
@@ -584,14 +669,23 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
app.set_status(err, true);
EDIT.with(|x| *x.borrow_mut() = Some(Edit { fresh: FRESH, ..e }));
} else {
clear_picks(app);
app.set_status("Dimension applied", false);
}
}
Some(false) if escaped => clear_picks(app),
Some(false) => {}
None => EDIT.with(|x| *x.borrow_mut() = Some(e)),
}
}
/// The Dimension tool starts over (its picks became a dimension, or were cancelled).
fn clear_picks(app: &mut SolveApp) {
if let Some(tool) = app.tool.as_mut().filter(|t| t.kind == crate::tools::Kind::Dimension) {
tool.picks.clear();
}
}
/// Is the inline editor open (it has the keyboard)?
pub fn editing() -> bool {
EDIT.with(|e| e.borrow().is_some())
@@ -620,6 +714,11 @@ mod tests {
for pick in picks {
h.step(&json!({"click":{"sketch":pick}})).unwrap();
}
// The picks determine the dimension, so its value took the keyboard; Tab hands
// them back to placing first.
assert_eq!(live(), Some(false));
h.frames(4);
h.step(&json!({"key":"Tab"})).unwrap();
h.step(&json!({"move":{"sketch":at}})).unwrap();
let (param, draft) = placement_preview(&h.app).unwrap();
let constraint =
@@ -676,11 +775,14 @@ mod tests {
let undo = h.app.session.undo.len();
h.step(&json!({"start":"sketch.dimension"})).unwrap();
h.step(&json!({"click":{"sketch":[20,10]}})).unwrap();
assert_eq!(live(), Some(false));
h.frames(4);
h.step(&json!({"key":"Tab"})).unwrap();
for at in [[20., 20.], [20., -5.]] {
h.step(&json!({"move":{"sketch":at}})).unwrap();
h.settle();
let (param, draft) = placement_preview(&h.app).expect("a line has a dimension preview");
assert!((draft.at - Vec2::new(at[0], at[1])).len() < 1e-5);
assert!((draft.at.unwrap() - Vec2::new(at[0], at[1])).len() < 1e-5);
let sk = &draft.preview.sketch(draft.sketch).unwrap().sketch;
let c = sk.constraints.iter().find(|c| c.param.as_deref() == Some(&param)).unwrap();
assert!(matches!(c.kind, ConstraintKind::Length {value,..} if (value-20.).abs()<1e-6));
@@ -378,3 +378,217 @@ fn an_over_constraining_dimension_is_placed_as_a_driven_one() {
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), original);
}
// ---- Type-first: D, click, type, Enter ----
/// A sketch with `setup` drawn, the Top view centred on `centre`, and the Dimension tool on.
fn sketch_with(setup: &[(&str, serde_json::Value)], centre: [f64; 2]) -> Harness {
crate::dim_view::cancel();
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
for (cmd, params) in setup {
h.app.run(cmd, params.clone()).unwrap();
}
h.call("ui.view", json!({"view":"top"}));
h.app.cam.distance = 80.0;
h.app.cam.target = solidcraft_engine::geom::Vec3::new(centre[0], centre[1], 0.0);
h.settle();
h.step(&json!({"start":"sketch.dimension"})).unwrap();
h
}
/// Type as a keyboard does: each character is a key press with its text in the same frame,
/// so a letter that is also a tool shortcut reaches the shortcuts too.
fn keys(h: &mut Harness, text: &str) {
for ch in text.chars() {
let key = egui::Key::from_name(&ch.to_ascii_uppercase().to_string());
let modifiers = egui::Modifiers::default();
if let Some(key) = key {
h.app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
}
h.app.synthetic.push(egui::Event::Text(ch.to_string()));
if let Some(key) = key {
h.app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers });
}
h.frames(1);
}
h.settle();
}
fn solved(h: &Harness) -> solidcraft_engine::sketch::Sketch {
let state = h.app.session.world_state();
state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.clone()
}
/// The dimensions added since `before` (constraints with a parameter or a value).
fn added(h: &Harness, before: usize) -> Vec<solidcraft_engine::sketch::Constraint> {
solved(h).constraints.into_iter().filter(|c| c.kind.is_dimension()).skip(before).collect()
}
fn dimension_count(h: &Harness) -> usize {
solved(h).constraints.iter().filter(|c| c.kind.is_dimension()).count()
}
/// The value took the keyboard straight after the pick that determined the dimension.
fn assert_live(h: &Harness) {
assert!(crate::dim_view::editing(), "the pick opens the value at once");
assert_eq!(crate::dim_view::live(), Some(false));
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")));
}
#[test]
fn type_first_line_circle_arc_distance_and_angle_commit_one_undo_step_each() {
use solidcraft_engine::sketch::ConstraintKind as K;
let line = [("sketch.line", json!({"points":[[0,0],[20,0]],"infer":false}))];
let circle = [("sketch.circle.center", json!({"center":[10,0],"radius":5}))];
let arc = [("sketch.arc.center_point", json!({"center":[10,0],"start":[18,0],"sweep":90}))];
// Two lines joined at a fixed corner: the angle turns one about it.
let corner = [
("sketch.line", json!({"points":[[0,20],[0,0],[20,0]],"infer":false})),
("sketch.constraint.fix", json!({"entity":"l1.end"})),
("sketch.constraint.horizontal_vertical", json!({"line":"l2","mode":"horizontal"})),
];
let half = std::f64::consts::FRAC_1_SQRT_2 * 8.0;
type Check = fn(&K) -> bool;
let cases: [(&[(&str, serde_json::Value)], [f64; 2], Vec<[f64; 2]>, &str, Check); 5] = [
(&line, [10., 0.], vec![[10., 0.]], "25", |k| matches!(k, K::Length { value, .. } if (value - 25.).abs() < 1e-6)),
(&circle, [10., 0.], vec![[15., 0.]], "12", |k| matches!(k, K::Diameter { value, .. } if (value - 12.).abs() < 1e-6)),
(&arc, [10., 0.], vec![[10. + half, half]], "6", |k| matches!(k, K::Radius { value, .. } if (value - 6.).abs() < 1e-6)),
(&line, [10., 0.], vec![[0., 0.], [20., 0.]], "25", |k| k.value().is_some_and(|v| (v - 25.).abs() < 1e-6)),
(&corner, [8., 8.], vec![[12., 0.], [0., 12.]], "60", |k| k.is_angle() && k.value().is_some_and(|v| (v.to_degrees() - 60.).abs() < 1e-6)),
];
for (setup, centre, picks, typed, check) in cases {
let mut h = sketch_with(setup, centre);
let original = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let dims = dimension_count(&h);
for p in &picks {
h.step(&json!({"click":{"world":[p[0],p[1],0]}})).unwrap();
}
assert_live(&h);
assert_eq!(h.app.session.doc.to_json(), original, "nothing changes until Enter");
// The text follows the pointer while typing: Enter places it there.
// (For the angle, inside the wedge both before and after it changes.)
let cursor = if check(&solidcraft_engine::sketch::ConstraintKind::Angle { a: 0, b: 1, value: 60f64.to_radians(), flip: false }) {
[10.4, 6.0]
} else {
[centre[0] + 3.0, centre[1] + 9.0]
};
h.step(&json!({"move":{"world":[cursor[0],cursor[1],0]}})).unwrap();
keys(&mut h, typed);
h.step(&json!({"key":"Enter"})).unwrap();
assert!(!crate::dim_view::editing(), "{picks:?}");
assert_eq!(h.app.session.undo.len(), undo + 1, "{picks:?}");
let new = added(&h, dims);
assert_eq!(new.len(), 1, "{picks:?}: {new:?}");
assert!(check(&new[0].kind), "{picks:?}: {:?}", new[0].kind);
h.settle();
let shown = crate::dim_view::text_at(&new[0].id).expect("the new dimension is drawn");
let [x, y] = h.at(&json!({"world":[cursor[0],cursor[1],0]})).unwrap();
assert!(shown.distance(egui::pos2(x as f32, y as f32)) < 40.0, "{picks:?}: text at {shown:?}, cursor at {x},{y}");
assert_eq!(h.app.tool.as_ref().map(|t| (t.cmd, t.picks.len())), Some(("sketch.dimension", 0)), "ready for the next dimension");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), original);
}
crate::dim_view::cancel();
}
#[test]
fn type_first_without_moving_uses_the_default_place_and_expressions_work() {
let mut h = sketch_with(&[("sketch.line", json!({"points":[[0,0],[20,0]],"infer":false}))], [10., 0.]);
h.app.run("parameters.add", json!({"name":"width","expression":"15 mm"})).unwrap();
h.settle();
let dims = dimension_count(&h);
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
assert_live(&h);
keys(&mut h, "2*width");
h.step(&json!({"key":"Enter"})).unwrap();
let new = added(&h, dims);
assert_eq!(new.len(), 1);
assert!(new[0].text.is_none(), "an unmoved pointer leaves the text in its default place: {:?}", new[0].text);
assert!((new[0].kind.value().unwrap() - 30.).abs() < 1e-6);
assert_eq!(h.app.session.doc.param(new[0].param.as_deref().unwrap()).unwrap().expr, "2*width");
crate::dim_view::cancel();
}
/// Letters typed into the value are not tool shortcuts (L Line, D Dimension, C Circle…).
#[test]
fn type_first_parameter_names_are_not_taken_by_shortcuts() {
for name in ["length", "depth", "clearance", "rim"] {
let mut h = sketch_with(&[("sketch.line", json!({"points":[[0,0],[20,0]],"infer":false}))], [10., 0.]);
h.app.run("parameters.add", json!({"name":name,"expression":"22 mm"})).unwrap();
h.settle();
let dims = dimension_count(&h);
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
assert_live(&h);
keys(&mut h, name);
assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some("sketch.dimension"), "{name}: a letter started another tool");
assert!(h.app.dialog.is_none(), "{name}");
h.step(&json!({"key":"Enter"})).unwrap();
let new = added(&h, dims);
assert_eq!(new.len(), 1, "{name}");
assert!((new[0].kind.value().unwrap() - 22.).abs() < 1e-6, "{name}");
assert_eq!(h.app.session.doc.param(new[0].param.as_deref().unwrap()).unwrap().expr, name);
crate::dim_view::cancel();
}
}
#[test]
fn type_first_escape_cancels_with_no_change() {
let mut h = sketch_with(&[("sketch.line", json!({"points":[[0,0],[20,0]],"infer":false}))], [10., 0.]);
let original = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
for typed in ["", "25"] {
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
assert_live(&h);
keys(&mut h, typed);
h.step(&json!({"key":"Escape"})).unwrap();
assert!(!crate::dim_view::editing());
assert_eq!(h.app.session.doc.to_json(), original);
assert_eq!(h.app.session.undo.len(), undo);
assert_eq!(h.app.tool.as_ref().map(|t| (t.cmd, t.picks.len())), Some(("sketch.dimension", 0)), "Esc drops the picks too");
}
crate::dim_view::cancel();
}
/// Place first still works: a click before typing puts the text there and keeps the value
/// (selected, so typing replaces it); Tab leaves the picks for a click to place, as before.
#[test]
fn type_first_keeps_the_place_first_flow() {
let mut h = sketch_with(&[("sketch.line", json!({"points":[[0,0],[20,0]],"infer":false}))], [10., 0.]);
let dims = dimension_count(&h);
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
assert_live(&h);
h.step(&json!({"click":{"world":[10,-7,0]}})).unwrap();
assert!(crate::dim_view::editing());
assert_eq!(crate::dim_view::live(), None, "the text is pinned");
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")));
h.step(&json!({"move":{"world":[0,12,0]}})).unwrap();
keys(&mut h, "18");
h.step(&json!({"key":"Enter"})).unwrap();
let new = added(&h, dims);
assert_eq!(new.len(), 1);
assert!((new[0].kind.value().unwrap() - 18.).abs() < 1e-6);
h.settle();
let shown = crate::dim_view::text_at(&new[0].id).unwrap();
let [x, y] = h.at(&json!({"world":[10,-7,0]})).unwrap();
assert!(shown.distance(egui::pos2(x as f32, y as f32)) < 40.0, "the pinned place wins over the pointer: {shown:?} vs {x},{y}");
// Tab before typing hands the picks back to the place-first flow.
h.app.run("edit.undo", json!({})).unwrap();
h.settle();
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
assert_live(&h);
h.frames(4);
h.step(&json!({"key":"Tab"})).unwrap();
h.settle();
assert!(!crate::dim_view::editing());
assert_eq!(h.app.tool.as_ref().map(|t| t.picks.len()), Some(1));
h.step(&json!({"click":{"world":[10,6,0]}})).unwrap();
assert!(crate::dim_view::editing());
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")));
keys(&mut h, "21");
h.step(&json!({"key":"Enter"})).unwrap();
let new = added(&h, dims);
assert!((new[0].kind.value().unwrap() - 21.).abs() < 1e-6);
crate::dim_view::cancel();
}
+3 -1
View File
@@ -1146,7 +1146,9 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
if crate::keymap::capturing(app) {
return;
}
if ctx.egui_wants_keyboard_input() {
// A dimension value that is taking the keyboard: letters belong to it (a parameter name
// starting with L or D), even on the frame before its field holds the focus.
if ctx.egui_wants_keyboard_input() || crate::dim_view::editing() {
return;
}
let (keys, mods) = ctx.input(|i| {
+38 -6
View File
@@ -102,7 +102,7 @@ pub fn hint(id: &str) -> String {
"sketch.circle.center" => "Circle: click the centre, then a point on the circle".into(),
"sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(),
"sketch.arc.center_point" => "Arc: click centre and start, then click end or type a signed sweep".into(),
"sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(),
"sketch.dimension" => "Dimension: pick one or two entities and type the value (or click to place it first); Enter applies".into(),
"sketch.constraint.horizontal_vertical" => "Horizontal/Vertical: pick a line or two points to align".into(),
"sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(),
_ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(),
@@ -137,8 +137,9 @@ pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
// A click away dismisses the inline editor; it must not also pick the first entity of
// the next dimension before that cancellation has been processed.
if crate::dim_view::editing() {
// the next dimension before that cancellation has been processed. The type-first value
// of the Dimension tool takes clicks itself (a second pick, or where its text goes).
if crate::dim_view::editing() && crate::dim_view::live().is_none() {
return;
}
let Some(mut tool) = app.tool.take() else { return };
@@ -205,12 +206,43 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
Kind::Dimension => {
let hits = pick(app, proj, pos);
let hit = hits.iter().find_map(|h| match h {
Hit::SketchPoint { id, .. } | Hit::SketchCurve { id, .. } => Some(id.clone()),
Hit::SketchPoint { id, .. } => Some((id.clone(), false)),
Hit::SketchCurve { id, .. } => Some((id.clone(), true)),
_ => None,
});
if let Some(id) = hit {
if !tool.picks.contains(&id) && tool.picks.len() < 2 {
let fresh = |tool: &Tool, id: &String| !tool.picks.contains(id) && tool.picks.len() < 2;
if let Some(typed) = crate::dim_view::live() {
match hit {
// Before anything is typed, another entity joins the dimension, as in the
// place-first flow (a distance, an angle between lines).
Some((id, _)) if !typed && fresh(&tool, &id) => {
let mut picks = tool.picks.clone();
picks.push(id);
match crate::dim_view::begin_live(app, picks.clone(), Some(pos)) {
Ok(true) => tool.picks = picks,
Ok(false) => {
crate::dim_view::cancel();
tool.picks = picks;
}
Err(error) => app.set_status(error, true),
}
}
// Otherwise the click places the text; the value keeps the keyboard.
_ => {
if let Some((at, _)) = sketch_point_at(app, proj, pos) {
crate::dim_view::pin(app, at);
}
}
}
} else if let Some((id, curve)) = hit {
if fresh(&tool, &id) {
tool.picks.push(id);
// As soon as the picks determine a dimension (one line, circle or arc, or a
// second entity), its value takes the keyboard: D, click, type. A pick that
// doesn't keeps the place-first flow; placing reports why.
if curve || tool.picks.len() == 2 {
let _ = crate::dim_view::begin_live(app, tool.picks.clone(), Some(pos));
}
}
} else if !tool.picks.is_empty()
&& let Some((at, _)) = sketch_point_at(app, proj, pos)
+5
View File
@@ -26,6 +26,11 @@ Keys handled by the window itself:
| F6 | fit the view to the model |
| Esc | cancel the running command, or clear the selection |
While a value box has the keyboard (a dimension being added, a dialog field), keys type into
it and single-letter shortcuts don't fire. With Sketch Dimension (D), the box opens as soon as
you click a line, circle or arc (or the second point or line), so D, click, type the value,
Enter.
Mouse and touch:
| Action | Result |
+13
View File
@@ -131,6 +131,19 @@ midpoint, symmetry, fix, curvature (G2) and polygon. **Sketch Dimension** (D) ad
aligned, horizontal, vertical, radius, diameter and angle dimensions. Each is a parameter
(`d1`, `d2`, …), so it can hold an expression.
Press D, click what you want to measure and type the value: as soon as the click settles the
dimension (one line, circle or arc, or the second point or line of a distance or angle), its
value box takes the keyboard with the measured value selected, so typing replaces it. Numbers,
units (`1 in`), expressions (`2*width`) and parameter names all work; letters go into the box,
not to tool shortcuts. While you type, the dimension text follows the cursor, and Enter places
it there (or in its default place if you haven't moved the cursor) as one undo step. Esc
cancels the dimension.
You can still place the text first. Before you type, a click on empty sketch space puts the
text there and keeps the value box focused; a click on another point or line makes the
dimension a distance or angle instead; Tab leaves the picks waiting for a click to place the
text.
Between two points, or on a line, that don't line up with an axis, where you place the
dimension picks what it measures: across from them, the distance between them; above or below
them, the horizontal distance; beside them, the vertical distance.