diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index 4394c2c3..5e8addb1 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -220,7 +220,9 @@ fn live(app: &SolveApp, t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> { } ("sketch.arc.center_point", 2) => { let a0 = (a - first).angle(); - vec![(((e - first).angle() - a0).rem_euclid(std::f64::consts::TAU), e)] + // Out along the radius, clear of the cursor: a box on it would take the hover and + // the click meant for a point there (#2201). + vec![(((e - first).angle() - a0).rem_euclid(std::f64::consts::TAU), e + dir_or_x(e - first) * (2.5 * off))] } ("sketch.conic", 2) => { // `rho` is the curve's fullness, not a distance: the conic passes through @@ -234,7 +236,9 @@ fn live(app: &SolveApp, t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> { } ("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)] + let side = if dir.cross(e - first) < 0.0 { -1.0 } else { 1.0 }; + // Beyond the cursor, away from the centre line, as for the arc (#2201). + vec![(2.0 * dir.cross(e - first).abs(), e + dir.perp() * (side * 2.5 * off))] } _ => Vec::new(), } @@ -402,6 +406,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option } let mut completed = false; let mut any_focused = false; + let view = app.viewport.layout.as_ref().map(|l| l.drawable).filter(|r| r.is_finite() && r.is_positive()); for (i, b) in t.dims.iter_mut().enumerate() { let Some((v, at)) = lv.get(i).copied() else { continue }; if !b.locked { @@ -417,7 +422,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option 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); + let pos = in_view(sp + vec2(-34.0, -11.0), view, ui.ctx().pointer_latest_pos()); 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| { @@ -465,6 +470,28 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option (enter && (any_focused || nothing)).then_some(e) } +/// About the size of a value box, for keeping it in view. +const BOX: egui::Vec2 = egui::vec2(76.0, 24.0); + +/// Where a value box whose top left would be `pos` goes so it stays in the drawing area `view`: +/// as it is when it fits; else mirrored through the pointer (a box set off the cursor, past the +/// view's edge, goes to the cursor's other side, still clear of it, #2201); else pushed inside. +fn in_view(pos: Pos2, view: Option, pointer: Option) -> Pos2 { + let Some(view) = view else { return pos }; + let fits = |p: Pos2| view.contains_rect(egui::Rect::from_min_size(p, BOX)); + if fits(pos) { + return pos; + } + if let Some(c) = pointer { + let mirrored = c + (c - (pos + BOX * 0.5)) - BOX * 0.5; + if fits(mirrored) { + return mirrored; + } + } + let max = (view.max - BOX).max(view.min); + pos.clamp(view.min, max) +} + #[cfg(test)] #[path = "sketch_dims_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/sketch_dims_tests.rs b/crates/ui-egui/src/sketch_dims_tests.rs index b8e67edc..5f6033c9 100644 --- a/crates/ui-egui/src/sketch_dims_tests.rs +++ b/crates/ui-egui/src/sketch_dims_tests.rs @@ -2,6 +2,7 @@ use serde_json::json; +use super::{BOX, in_view}; use crate::scenario::Harness; fn drawing(command: &str) -> Harness { @@ -316,3 +317,25 @@ fn a_line_end_on_a_circle_outside_the_axis_snap_stays_at_the_cursor() { let end = sk.point(b).unwrap(); assert!((end.len() - 30.0).abs() < 1e-9 && end.dist(at) < 0.5, "{end:?}"); } + +/// A value box set off the cursor stays in the drawing area (#2201): past an edge it goes to the +/// cursor's other side, still clear of it; with no room either side it is pushed inside. +#[test] +fn value_boxes_stay_in_view_and_off_the_cursor() { + let view = egui::Rect::from_min_max(egui::pos2(250.0, 130.0), egui::pos2(1650.0, 960.0)); + let cursor = egui::pos2(565.0, 142.0); + // 50 px above the cursor: over the ribbon. + let above = cursor + egui::vec2(-34.0, -11.0 - 50.0); + let p = in_view(above, Some(view), Some(cursor)); + let rect = egui::Rect::from_min_size(p, BOX); + assert!(view.contains_rect(rect), "{rect:?} in {view:?}"); + assert!(!rect.expand(2.0).contains(cursor), "{rect:?} clear of {cursor:?}"); + assert!(rect.min.y > cursor.y, "below the cursor now"); + // A box that fits stays where it is; without a view nothing moves. + let inside = egui::pos2(700.0, 500.0); + assert_eq!(in_view(inside, Some(view), Some(cursor)), inside); + assert_eq!(in_view(above, None, Some(cursor)), above); + // No room on either side: inside the view all the same. + let tiny = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(80.0, 30.0)); + assert!(tiny.contains_rect(egui::Rect::from_min_size(in_view(egui::pos2(-200.0, -200.0), Some(tiny), Some(egui::pos2(40.0, 15.0))), BOX))); +} diff --git a/crates/ui-egui/src/sketch_typed_intent_tests.rs b/crates/ui-egui/src/sketch_typed_intent_tests.rs index a849cec3..3321611b 100644 --- a/crates/ui-egui/src/sketch_typed_intent_tests.rs +++ b/crates/ui-egui/src/sketch_typed_intent_tests.rs @@ -182,3 +182,50 @@ fn typed_drawing_preserves_construction_and_centerline_modes_at_undo_limit() { crate::sketch_palette::set_mode(&mut h.app, 0); } } + +/// The third pick of a Center Point Arc reaches existing geometry under the pointer: the value +/// box (the arc's sweep, a slot's width) stays clear of the cursor instead of sitting on it +/// (#2201). +#[test] +fn the_arc_end_pick_snaps_to_a_point_under_the_value_box() { + for (command, picks, end) in [ + ("sketch.arc.center_point", [[0.0, 0.0], [10.0, 0.0]], [0.0, 10.0]), + ("sketch.slot.center_to_center", [[-10.0, 0.0], [10.0, 0.0]], [0.0, 10.0]), + ] { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0, 0], end], "infer": false})).unwrap(); + h.app.run("sketch.construction", json!({"curves":["l1"]})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.app.cam.distance = 60.0; + h.app.cam.target = solidcraft_engine::geom::Vec3::new(0.0, 5.0, 0.0); + h.settle(); + h.step(&json!({"start":command})).unwrap(); + for p in picks { + h.step(&json!({"click":{"world":[p[0], p[1], 0]}})).unwrap(); + } + h.step(&json!({"move":{"world":[end[0], end[1], 0]}})).unwrap(); + let cursor = h.at(&json!({"world":[end[0], end[1], 0]})).unwrap(); + let cursor = egui::pos2(cursor[0] as f32, cursor[1] as f32); + let blocking = h.ctx.memory(|m| m.area_rect(egui::Id::new(("sc_dim_area", 0usize)))).filter(|r| r.expand(2.0).contains(cursor)); + assert!(blocking.is_none(), "{command}: the value box {blocking:?} sits under the cursor {cursor:?}"); + h.step(&json!({"click":{"world":[end[0], end[1], 0]}})).unwrap(); + // A slot's third pick sets its width only; the arc's end takes the point it lands on. + if command != "sketch.arc.center_point" { + continue; + } + let state = h.app.session.world_state(); + let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + let line_end = sketch.curves.iter().find(|c| c.id == "l1").and_then(|c| match c.kind { + CurveKind::Line { b, .. } => Some(b), + _ => None, + }); + let snapped = + sketch.constraints.iter().any(|c| matches!(&c.kind, ConstraintKind::Coincident { p, q } if Some(*p) == line_end || Some(*q) == line_end)) + || sketch.curves.iter().filter(|c| c.id != "l1").any(|c| match c.kind { + CurveKind::Arc { a, b, .. } => Some(a) == line_end || Some(b) == line_end, + _ => false, + }); + assert!(snapped, "{command}: the third pick snapped to the line's end: {:?}", sketch.constraints.iter().map(|c| &c.kind).collect::>()); + } +}