From 11a196e6f8b97a779a488238d5e7c4c60c5f9b05 Mon Sep 17 00:00:00 2001 From: Karl Von Richter Date: Sun, 18 Oct 2026 08:00:35 +1000 Subject: [PATCH] sketch(feat): add dimension references and parameter tools (#1548) Show each dimension's parameter name while creating or editing it, and let users insert another driving dimension at the expression cursor or selection. Use the available editor width and keep Apply aligned with the right edge. Add a tools dropdown with Measure, named user parameter expressions, and access to the existing parameter table for adding and editing parameters. Preserve the unfinished dimension expression while either window is open. Cover reference insertion, dependent updates, parameter selection, dialog round trips, applying and cancellation with UI integration tests. Update the parameter guide. Signed-off-by: Karl Von Richter --- crates/ui-egui/src/dim_view.rs | 69 +++++++++++++++++-- .../ui-egui/src/dimension_create_ui_tests.rs | 51 ++++++++++++++ crates/ui-egui/src/viewport.rs | 7 +- docs/book/src/guide/parameters.md | 7 ++ 4 files changed, 128 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index 4c88a7eb..ca2ebd09 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -49,6 +49,7 @@ struct Edit { /// 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, + selection: Option, } #[derive(Clone, Copy, Debug)] @@ -132,7 +133,9 @@ pub fn begin_dimension(app: &mut SolveApp, entities: Vec, at: Vec2) -> R app.set_status("The sketch already determines that dimension, 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), live: None })); + EDIT.with(|edit| { + *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft), live: None, selection: None }) + }); PLACEMENT.with(|preview| *preview.borrow_mut() = None); Ok(()) } @@ -147,7 +150,7 @@ pub fn begin_live(app: &mut SolveApp, entities: Vec, pointer: Option bool { return true; }; let expr = app.session.doc.param(¶m).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, live: None })); + EDIT.with(|e| { + *e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0, draft: None, live: None, selection: None }) + }); true } @@ -577,7 +582,52 @@ 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, live: None })); + EDIT.with(|e| { + *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10, draft: None, live: None, selection: None }) + }); +} + +/// Insert a reference at the expression's selection, using character offsets (not bytes). +fn insert_reference(ctx: &egui::Context, edit: &mut Edit, name: &str) { + let id = egui::Id::new("sc_dim_edit"); + let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default(); + let len = edit.expr.chars().count(); + let (start, end) = edit + .selection + .or_else(|| state.cursor.char_range()) + .map(|r| { + let a = r.primary.index.0.min(len); + let b = r.secondary.index.0.min(len); + (a.min(b), a.max(b)) + }) + .unwrap_or((len, len)); + let before: String = edit.expr.chars().take(start).collect(); + let after: String = edit.expr.chars().skip(end).collect(); + edit.expr = format!("{before}{name}{after}"); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(start + name.chars().count())))); + edit.selection = state.cursor.char_range(); + state.store(ctx, id); + ctx.memory_mut(|m| m.request_focus(id)); + edit.fresh = FRESH - 1; + if let Some(live) = edit.live.as_mut() { + live.typed = true; + } +} + +/// Consume dimension clicks while editing; only a double-click inserts a reference. +pub fn pick_reference(ctx: &egui::Context, pos: Pos2, double: bool) -> bool { + EDIT.with(|editor| { + let mut editor = editor.borrow_mut(); + let Some(edit) = editor.as_mut() else { return false }; + let Some(id) = hit(pos) else { return false }; + let param = DRAWN.with(|d| d.borrow().iter().find(|d| d.id == id).and_then(|d| d.param.clone())); + if double && let Some(param) = param.filter(|p| p != &edit.param) { + insert_reference(ctx, edit, ¶m); + } + // Self references and driven dimensions cannot supply a reference. + edit.fresh = FRESH - 1; + true + }) } fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { @@ -612,8 +662,16 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { crate::frame::area(egui::Id::new("sc_dim_edit_area")).fixed_pos(anchor).order(egui::Order::Foreground).show(ctx, |ui| { 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))); + ui.label(egui::RichText::new(format!("Dimension: {}", e.param)).strong()) + .on_hover_text("Double-click another driving dimension to insert its name"); + let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(popup_width)); + if r.has_focus() { + e.selection = egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range()); + } let completed = crate::params_dialog::complete(ui, &r, &mut e.expr); + if completed { + e.selection = egui::TextEdit::load_state(ctx, id).and_then(|s| s.cursor.char_range()); + } if let Some(live) = e.live.as_mut() { live.typed |= r.changed() || completed; } @@ -625,6 +683,7 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { let n = e.expr.chars().count(); let mut s = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); s.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + e.selection = s.cursor.char_range(); s.store(ui.ctx(), id); } let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); diff --git a/crates/ui-egui/src/dimension_create_ui_tests.rs b/crates/ui-egui/src/dimension_create_ui_tests.rs index fc013b22..6ee556f7 100644 --- a/crates/ui-egui/src/dimension_create_ui_tests.rs +++ b/crates/ui-egui/src/dimension_create_ui_tests.rs @@ -32,6 +32,57 @@ fn type_value(h: &mut Harness, text: &str) { h.step(&json!({"text":text})).unwrap(); } } + +#[test] +fn dimension_editor_names_and_reference_picks_drive_new_and_existing_dimensions() { + let mut h = line([20., 0.]); + let source = + h.app.run("sketch.dimension", json!({"entities":["l1"],"value":20,"text_at":[10,8]})).unwrap()["param"].as_str().unwrap().to_string(); + h.app.run("sketch.line", json!({"points":[[0,-10],[12,-10]],"infer":false})).unwrap(); + h.settle(); + place(&mut h, &[[6., -10.]], [6., -16.]); + let target = h.app.session.doc.next_d_name(); + assert!(h.widget(&format!("Dimension: {target}")).is_some(), "new dimension shows its actual parameter name"); + // Let the preceding placement clicks expire before starting a distinct double-click. + h.frames(40); + h.step(&json!({"click":{"dimension":source},"double":true})).unwrap(); + assert!(crate::dim_view::editing(), "reference click keeps the draft open"); + h.step(&json!({"text":" / 2"})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(!crate::dim_view::editing(), "{}", h.call("ui.inspect", json!({}))); + assert_eq!(h.app.session.doc.param(&target).unwrap().expr, format!("{source} / 2")); + h.app.tool = None; + h.settle(); + crate::dim_view::edit_param(&h.app, &target); + h.frames(5); + assert!(h.widget(&format!("Dimension: {target}")).is_some()); + type_value(&mut h, "2 * "); + h.frames(40); + h.step(&json!({"click":{"dimension":source},"double":true})).unwrap(); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.session.doc.param(&target).unwrap().expr, format!("2 * {source}")); + h.app.run("parameters.change", json!({"name":source,"expression":"15 mm"})).unwrap(); + assert!((h.app.session.doc.eval(&target, solidcraft_engine::doc::expr::Kind::Length).unwrap() - 30.).abs() < 1e-6); + crate::dim_view::cancel(); +} + +#[test] +fn single_clicking_another_dimension_does_not_change_the_expression() { + let mut h = line([20., 0.]); + h.app.run("sketch.dimension", json!({"entities":["l1"],"value":20,"text_at":[10,8]})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,-10],[12,-10]],"infer":false})).unwrap(); + h.app.run("sketch.dimension", json!({"entities":["l2"],"value":12,"text_at":[6,-16]})).unwrap(); + h.settle(); + crate::dim_view::edit_param(&h.app, "d2"); + h.frames(5); + type_value(&mut h, "8"); + h.step(&json!({"click":{"dimension":"d1"}})).unwrap(); + assert!(crate::dim_view::editing()); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.session.doc.param("d2").unwrap().expr, "8"); + crate::dim_view::cancel(); +} + #[test] fn dimension_parameters_use_typing_and_ctrl_space_completion() { for (prefix, expected) in [("wid", "width"), ("", "width"), ("d", "d1")] { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 7017fee4..b0ebc8b5 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2461,7 +2461,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::tools::on_click(app, &proj, a); app.viewport.draw_drag = app.tool.as_ref().is_some_and(|t| t.pts.len() == 1); } - if resp.clicked() && !held { + let reference_pick = (resp.clicked() || resp.double_clicked()) + && !held + && app.dialog.is_none() + && crate::dim_view::pick_reference(ui.ctx(), p, resp.double_clicked()); + if resp.clicked() && !held && !reference_pick { if app.tool.is_some() { crate::tools::on_click(app, &proj, p); } else if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) { @@ -2508,6 +2512,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // Double-click a dimension: edit its value in place. let dim_edit = resp.double_clicked() && !held + && !crate::dim_view::editing() && app.tool.is_none() && app.dialog.is_none() && (crate::sketch_modify_ui::double_click_pattern(app, p) || crate::dim_view::double_click(app, p)); diff --git a/docs/book/src/guide/parameters.md b/docs/book/src/guide/parameters.md index c3edf3ea..369ca633 100644 --- a/docs/book/src/guide/parameters.md +++ b/docs/book/src/guide/parameters.md @@ -5,6 +5,13 @@ are ones you add yourself, with a unit, an expression and an optional comment. C **Change Parameters** (`parameters.change`). The timeline rebuilds from the first feature that uses them. +When creating or editing a sketch dimension, its value editor shows its model parameter +name (`d1`, `d2`, …). Double-click another driving dimension to insert its name at the cursor, +replacing selected text; add arithmetic such as `/ 2` to build a dependent dimension. +Single clicks on dimensions leave the expression unchanged. Press **Enter** to apply the +expression or **Escape** to cancel the edit. +Add and edit user parameters through the existing **Change Parameters** command. + In the SOLID toolbar's CONFIGURE panel, click **Parameters** to open the user and model parameter editor. The separate **Configurations** button opens the configuration table; **Configure** remains in the dropdown for configuring columns.