From 58c0ed8a90fc3342c55ab6ff98bd3add466ae492 Mon Sep 17 00:00:00 2001 From: lillith <35337584+0xlillith@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:45:04 +0200 Subject: [PATCH] fix: preview sketch dimensions before placement --- crates/ui-egui/src/dim_view.rs | 129 +++++++++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index 4678ea57..3a2e668d 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -97,11 +97,19 @@ impl DimensionDraft { pub fn cancel() { EDIT.with(|edit| *edit.borrow_mut() = None); + PLACEMENT.with(|preview| *preview.borrow_mut() = None); } /// Infer the measured value through the same command as scripts, without creating a real /// parameter or an undo entry. Only accepting the editor executes the command on the design. pub fn begin_dimension(app: &SolveApp, entities: Vec, at: Vec2) -> Result<(), String> { + let (param, expr, draft) = dimension_draft(app, entities, at)?; + EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft) })); + PLACEMENT.with(|preview| *preview.borrow_mut() = None); + Ok(()) +} + +fn dimension_draft(app: &SolveApp, entities: Vec, at: 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() { return Err("Pick one edge or two endpoints, then place the dimension".into()); @@ -126,8 +134,51 @@ pub fn begin_dimension(app: &SolveApp, entities: Vec, at: Vec2) -> Resul if !draft.preview.sketch(sketch).is_some_and(|solved| solved.report.ok()) { return Err("That dimension conflicts with the sketch constraints".into()); } - EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft) })); - Ok(()) + Ok((param, expr, draft)) +} + +/// Cache inference at a stationary pointer, including incomplete/unsupported picks. +#[derive(Clone)] +struct Placement { + baseline: Arc, + sketch: Option, + component: u64, + path: Option>, + pending: std::collections::BTreeMap, + entities: Vec, + at: Vec2, + draft: Option<(String, DimensionDraft)>, +} + +fn placement_preview(app: &SolveApp) -> Option<(String, DimensionDraft)> { + let tool = app.tool.as_ref().filter(|t| t.kind == crate::tools::Kind::Dimension && !t.picks.is_empty())?; + let at = tool.hover.as_ref()?.0; + PLACEMENT.with(|preview| { + let mut preview = preview.borrow_mut(); + let current = preview.as_ref().is_some_and(|p| { + Arc::ptr_eq(&p.baseline, &app.session.doc) + && p.sketch == app.session.active_sketch + && p.component == app.session.active_component + && p.path == app.session.active_occurrence_path + && p.pending == app.session.pending_moves + && p.entities == tool.picks + && p.at == at + }); + if !current { + let draft = dimension_draft(app, tool.picks.clone(), at).ok().map(|(param, _, draft)| (param, draft)); + *preview = Some(Placement { + baseline: app.session.doc.clone(), + sketch: app.session.active_sketch, + component: app.session.active_component, + path: app.session.active_occurrence_path.clone(), + pending: app.session.pending_moves.clone(), + entities: tool.picks.clone(), + at, + draft, + }); + } + preview.as_ref().and_then(|p| p.draft.clone()) + }) } thread_local! { @@ -135,6 +186,7 @@ thread_local! { /// A text being dragged: dimension id, undo depth at the start, grab offset (sketch). static DRAG: RefCell> = const { RefCell::new(None) }; static EDIT: RefCell> = const { RefCell::new(None) }; + static PLACEMENT: RefCell> = const { RefCell::new(None) }; } /// Frames the inline editor holds on to the keyboard after opening. @@ -258,7 +310,7 @@ pub fn label(k: &ConstraintKind, expr: Option<&str>, driven: bool) -> String { /// Draw the active sketch's dimensions and remember where they are for picking. pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) { let mut drawn = Vec::new(); - let draft = EDIT.with(|edit| { + let editor_draft = EDIT.with(|edit| { let mut edit = edit.borrow_mut(); if edit.as_ref().and_then(|e| e.draft.as_ref()).is_some_and(|d| !d.valid(app)) { *edit = None; @@ -282,6 +334,8 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P None } }); + let placing = !editing(); + let draft = editor_draft.or_else(|| placing.then(|| placement_preview(app)).flatten()); let st = app.session.world_state(); let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { DRAWN.with(|d| d.borrow_mut().clear()); @@ -372,13 +426,13 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P .collect(); painter.add(Shape::line(pts, stroke)); } - if is_draft { + if is_draft && !placing { EDIT.with(|edit| { if let Some(e) = edit.borrow_mut().as_mut() { e.at = Some(tc); } }); - } else { + } else if !is_draft { drawn.push(Drawn { id: c.id.clone(), param: c.param.clone(), text: r, segs, at: lay.text }); } } @@ -502,6 +556,71 @@ pub fn drawn() -> Vec<(String, Pos2)> { mod tests { use super::*; + #[test] + fn placement_previews_radial_and_angular_dimensions_and_invalidates_on_document_change() { + cancel(); + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center":[30,30],"radius":5})).unwrap(); + h.app.run("sketch.line", json!({"points":[[10,10],[20,10]],"infer":false,"connect":false})).unwrap(); + h.app.run("sketch.line", json!({"points":[[10,10],[10,20]],"infer":false,"connect":false})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + for (picks, at, angular) in [(vec![[35, 30]], [40, 40], false), (vec![[15, 10], [10, 15]], [18, 18], true)] { + h.step(&json!({"start":"sketch.dimension"})).unwrap(); + for pick in picks { + h.step(&json!({"click":{"sketch":pick}})).unwrap(); + } + h.step(&json!({"move":{"sketch":at}})).unwrap(); + let (param, draft) = placement_preview(&h.app).unwrap(); + let constraint = + draft.preview.sketch(draft.sketch).unwrap().sketch.constraints.iter().find(|c| c.param.as_deref() == Some(¶m)).unwrap(); + assert_eq!(constraint.kind.is_angle(), angular); + assert!(!editing()); + let (_, cached) = placement_preview(&h.app).unwrap(); + assert!(Arc::ptr_eq(&draft.preview_doc, &cached.preview_doc), "stationary pointer reuses inference"); + h.app.run("sketch.point", json!({"point":[50,50]})).unwrap(); + let (_, refreshed) = placement_preview(&h.app).unwrap(); + assert!(!Arc::ptr_eq(&draft.preview_doc, &refreshed.preview_doc)); + h.step(&json!({"key":"Escape"})).unwrap(); + } + cancel(); + } + + #[test] + fn placement_follows_real_pointer_without_editing_or_mutating_design() { + cancel(); + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.step(&json!({"start":"sketch.dimension"})).unwrap(); + h.step(&json!({"click":{"sketch":[20,10]}})).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); + let sk = &draft.preview.sketch(draft.sketch).unwrap().sketch; + let c = sk.constraints.iter().find(|c| c.param.as_deref() == Some(¶m)).unwrap(); + assert!(matches!(c.kind, ConstraintKind::Length {value,..} if (value-20.).abs()<1e-6)); + assert!(!editing()); + assert!(drawn().is_empty(), "preview must not become a selectable committed dimension"); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.undo.len(), undo); + } + h.step(&json!({"click":{"sketch":[20,-5]}})).unwrap(); + assert!(editing()); + assert_eq!(h.app.session.doc.to_json(), baseline); + h.step(&json!({"key":"Escape"})).unwrap(); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(placement_preview(&h.app).is_none()); + assert_eq!(h.app.session.doc.to_json(), baseline); + assert_eq!(h.app.session.undo.len(), undo); + cancel(); + } + #[test] fn labels_show_the_value_or_fx() { let k = ConstraintKind::Length { l: 0, value: 25.0 };