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).
This commit is contained in:
Tobias Breckle authored and GitHub committed 2026-10-14 13:24:19 -04:00
1 parent aa2ace81af
commit ed943a74e6
3 files changed
+120 -8

No files matched your search

+49 -2
View File
@@ -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<String>)) -> (Vec2, Option<String>) {
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<String>)) -> 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:<curve>: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<String>)) -> Option<(Vec2, Option<String>)> {
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);
+67
View File
@@ -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:?}");
}
+4 -6
View File
@@ -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);