From ed943a74e6c8263e40e50343d0b4b400868114a6 Mon Sep 17 00:00:00 2001 From: Tobias Breckle <25928171+tbreckle@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:24:19 +0200 Subject: [PATCH] A sketch line end on a curve within the axis snap lands where the preview shows (#1122) A line end snapped onto a curve within the horizontal/vertical snap slides along the curve to where the axis crosses it; the preview, value boxes and click share one placement (#1121). --- crates/ui-egui/src/sketch_dims.rs | 51 ++++++++++++++++++- crates/ui-egui/src/sketch_dims_tests.rs | 67 +++++++++++++++++++++++++ crates/ui-egui/src/tools.rs | 10 ++-- 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index 5dadeb32..4394c2c3 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -107,6 +107,54 @@ pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 { } } +/// The point a click places for the hover `p` (the preview draws this one): typed values win +/// over the cursor and drop its snap; a line's free end snaps to horizontal/vertical, one on +/// sketch geometry stays there, and one on a curve slides along it to where the axis crosses. +pub fn placed(app: &SolveApp, t: &Tool, p: &(Vec2, Option)) -> (Vec2, Option) { + let line = t.cmd == "sketch.line"; + if t.dims.iter().any(|b| b.locked) || (line && p.1.is_none()) { + return (effective(app, t, p.0), None); + } + if line && let Some(q) = on_curve_axis(app, t, p) { + return q; + } + p.clone() +} + +/// Where the preview and the value boxes put the tool's next point for the hover `p`: for a +/// line, exactly what a click places. +pub fn shown(app: &SolveApp, t: &Tool, p: &(Vec2, Option)) -> Vec2 { + if t.cmd == "sketch.line" { placed(app, t, p).0 } else { effective(app, t, p.0) } +} + +/// A line end snapped onto a curve (`on::x,y`) within the axis snap: the point where the +/// horizontal/vertical line from the start crosses that curve near the cursor (from a circle's +/// centre, its quadrant point), still on the curve. None: no axis snap there, or the axis misses +/// the curve close by (the curve snap wins as it is). +fn on_curve_axis(app: &SolveApp, t: &Tool, p: &(Vec2, Option)) -> Option<(Vec2, Option)> { + let a = t.pts.last()?.0; + let (curve, _) = p.1.as_deref()?.strip_prefix("on:")?.rsplit_once(':')?; + let v = p.0 - a; + let ang = snap_axis(v.angle()); + if ang == v.angle() { + return None; + } + let st = app.session.world_state(); + let sk = &st.sketch(app.session.active_sketch?)?.sketch; + let shape = sk.shape(sk.curve_index(curve)?)?; + let dir = Vec2::from_angle(ang); + let horizontal = dir.y.abs() < dir.x.abs(); + let axis = solidcraft_engine::sketch::Shape::Line { a, b: a + dir }; + let tol = 1e-9 * (1.0 + v.len()); + let q = solidcraft_engine::sketch::intersections(&axis, &shape) + .into_iter() + // Exactly on the axis, so the command infers the horizontal/vertical constraint. + .map(|q| if horizontal { Vec2::new(q.x, a.y) } else { Vec2::new(a.x, q.y) }) + .filter(|q| (*q - a).dot(dir) > tol && shape.dist(*q) <= tol && q.dist(p.0) <= v.len() * 0.1) + .min_by(|x, y| x.dist(p.0).total_cmp(&y.dist(p.0)))?; + Some((q, Some(format!("on:{curve}:{},{}", q.x, q.y)))) +} + /// Angles within a few degrees of horizontal or vertical snap to it (the line is drawn /// horizontal or vertical, and the command infers the constraint). pub fn snap_axis(ang: f64) -> f64 { @@ -325,8 +373,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option let sid = app.session.active_sketch?; let st = app.session.world_state(); let plane = st.sketch(sid)?.plane; - let h = t.hover.as_ref().map(|x| x.0)?; - let e = effective(app, t, h); + let e = shown(app, t, t.hover.as_ref()?); let px = 2.0 * app.cam.half_height() / f64::from(proj.rect.height().max(1.0)); let lv = live(app, t, e, px); let focus = std::mem::take(&mut t.dims_focus); diff --git a/crates/ui-egui/src/sketch_dims_tests.rs b/crates/ui-egui/src/sketch_dims_tests.rs index 2f04f207..b8e67edc 100644 --- a/crates/ui-egui/src/sketch_dims_tests.rs +++ b/crates/ui-egui/src/sketch_dims_tests.rs @@ -249,3 +249,70 @@ fn rounded_rectangle_clicks_use_a_default_radius() { let want = w * hh - (4.0 - std::f64::consts::PI) * r * r; assert!((sketch.profiles[0].area - want).abs() < 0.05 * want, "area {} want about {want}", sketch.profiles[0].area); } + +/// A line from a circle's centre to the circle, ending a little off the quadrant: the preview +/// snaps to horizontal or vertical, so the click must place exactly that line, on the circle. +#[test] +fn a_line_end_on_a_circle_within_the_axis_snap_lands_on_the_quadrant_the_preview_shows() { + use solidcraft_engine::geom::Vec2; + use solidcraft_engine::sketch::{ConstraintKind, CurveKind}; + let r = 30.0_f64; + for (deg, end, horizontal) in [(-1.5_f64, Vec2::new(r, 0.0), true), (91.8, Vec2::new(0.0, r), false), (178.0, Vec2::new(-r, 0.0), true)] { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center": [0, 0], "radius": r})).unwrap(); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + h.step(&json!({"start": "sketch.line"})).unwrap(); + h.step(&json!({"click": {"world": [0, 0, 0]}})).unwrap(); + let at = Vec2::from_angle(deg.to_radians()) * r; + h.step(&json!({"move": {"world": [at.x, at.y, 0]}})).unwrap(); + let tool = h.app.tool.as_ref().unwrap(); + let hover = tool.hover.clone().unwrap(); + assert!(hover.1.as_deref().is_some_and(|s| s.starts_with("on:")), "{deg}: {hover:?}"); + let shown = crate::sketch_dims::shown(&h.app, tool, &hover); + assert!(shown.dist(end) < 1e-9, "{deg}: preview at {shown:?}"); + h.step(&json!({"click": {"world": [at.x, at.y, 0]}})).unwrap(); + let st = h.app.session.world_state(); + let sk = &st.sketch(sid).unwrap().sketch; + let (line, a, b) = sk + .curves + .iter() + .enumerate() + .find_map(|(i, c)| if let CurveKind::Line { a, b } = c.kind { Some((i, a, b)) } else { None }) + .unwrap_or_else(|| panic!("{deg}: no line, {:?}", h.app.status)); + assert!(sk.point(a).unwrap().dist(Vec2::ZERO) < 1e-9, "{deg}"); + assert!(sk.point(b).unwrap().dist(end) < 1e-9, "{deg}: placed at {:?}", sk.point(b)); + assert!(sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::PointOnCurve { p, c: 0 } if p == b)), "{deg}: end not on the circle"); + let axis = |k: &ConstraintKind| { + if horizontal { + matches!(k, ConstraintKind::Horizontal { l } if *l == line) + } else { + matches!(k, ConstraintKind::Vertical { l } if *l == line) + } + }; + assert!(sk.constraints.iter().any(|c| axis(&c.kind)), "{deg}: axis constraint missing"); + } +} + +/// Outside the axis snap a line end on a circle stays where the cursor meets the circle. +#[test] +fn a_line_end_on_a_circle_outside_the_axis_snap_stays_at_the_cursor() { + use solidcraft_engine::geom::Vec2; + use solidcraft_engine::sketch::CurveKind; + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.run("sketch.circle.center", json!({"center": [0, 0], "radius": 30})).unwrap(); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + h.step(&json!({"start": "sketch.line"})).unwrap(); + h.step(&json!({"click": {"world": [0, 0, 0]}})).unwrap(); + let at = Vec2::from_angle(20f64.to_radians()) * 30.0; + h.step(&json!({"move": {"world": [at.x, at.y, 0]}})).unwrap(); + h.step(&json!({"click": {"world": [at.x, at.y, 0]}})).unwrap(); + let st = h.app.session.world_state(); + let sk = &st.sketch(sid).unwrap().sketch; + let b = sk.curves.iter().find_map(|c| if let CurveKind::Line { b, .. } = c.kind { Some(b) } else { None }).unwrap(); + let end = sk.point(b).unwrap(); + assert!((end.len() - 30.0).abs() < 1e-9 && end.dist(at) < 0.5, "{end:?}"); +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 5a2924c8..eb62739a 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -150,11 +150,9 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { } match tool.kind { Kind::Draw(n) => { - if let Some(mut p) = sketch_point_at(app, proj, pos) { + if let Some(p) = sketch_point_at(app, proj, pos) { // Typed values win over the cursor; lines snap to horizontal/vertical. - if tool.dims.iter().any(|b| b.locked) || (tool.cmd == "sketch.line" && p.1.is_none()) { - p = (crate::sketch_dims::effective(app, &tool, p.0), None); - } + let p = crate::sketch_dims::placed(app, &tool, &p); // A repeated initial point supplies no extent. Keep waiting rather than // attempting a degenerate shape; explicit typed values still validate. if n != 1 && tool.pts.len() == 1 && tool.pts.first().is_some_and(|first| first.0 == p.0) && !tool.dims.iter().any(|b| b.locked) { @@ -691,8 +689,8 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { tk.text_dim, ); } - let Some((h, _)) = t.hover.clone() else { return }; - let h = crate::sketch_dims::effective(app, t, h); + let Some(hover) = t.hover.as_ref() else { return }; + let h = crate::sketch_dims::shown(app, t, hover); if let Some(corners) = polygon_outline(t, h, crate::sketch_dims::locked_value(app, t, "Sides").unwrap_or(6.0)) { let outline: Vec<_> = corners.iter().map(|c| ss.plane.to_world(*c)).collect(); paint_sketch_path(&outline, crate::sketch_palette::mode(app), painter, proj, stroke);