Sketch: the arc's sweep box no longer covers the end-point pick (#2201) (#2242)

Thanks @DuBearte.
This commit is contained in:
DuBearte authored and GitHub committed 2026-10-18 01:10:53 -04:00
1 parent 6422ef84d5
commit 33cd031701
3 files changed
+100 -3

No files matched your search

+30 -3
View File
@@ -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<egui::Rect>, pointer: Option<Pos2>) -> 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;
+23
View File
@@ -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)));
}
@@ -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::<Vec<_>>());
}
}