Sketch: snap to infinite construction-line crossings (#10011) (#10285)

* claim: #10011

* Fix snapping at infinite construction-line crossings (#10011)
This commit is contained in:
Colin Faulkingham
2026-10-31 22:23:57 +00:00
committed by GitHub
parent 604515cfb3
commit e0c1b8adbd
4 changed files with 122 additions and 14 deletions
@@ -455,6 +455,27 @@ fn drawing_at_a_curve_crossing_keeps_the_point_on_both() {
assert!(st.y > 0.0, "the upper crossing: {st:?}");
}
#[test]
fn drawing_at_an_infinite_line_crossing_keeps_the_point_on_both() {
let mut s = new_sketch();
let a = ids(&run(&mut s, "sketch.construction_line.vertical", json!({"at": [20, 40]}))["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.construction_line.horizontal", json!({"at": [40, 15]}))["curves"])[0].clone();
let r = run(&mut s, "sketch.line", json!({"points": [format!("x:{a}+{b}:20.4,15.4"), [30, 25]], "infer": false, "connect": false}));
let line = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let start = sk.resolve_point(&format!("{line}.start")).unwrap();
assert!(sk.point(start).unwrap().dist(Vec2::new(20.0, 15.0)) < 1e-9);
for name in [a, b] {
let curve = sk.curve_index(&name).unwrap();
assert!(sk.constraints.iter().any(|k| matches!(k.kind, crate::sketch::ConstraintKind::PointOnCurve { p, c } if p == start && c == curve)));
}
let before_undo = s.doc.to_json();
run(&mut s, "edit.undo", json!({}));
assert_eq!(sketch(&s).curves.len(), 2);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.to_json(), before_undo);
}
#[test]
fn drawing_at_a_curve_crossing_refuses_hostile_arguments() {
let mut s = new_sketch();
+4 -4
View File
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
use solidcraft_doc::project::resolve;
use solidcraft_doc::{Document, FeatureKind};
use solidcraft_geom::{Plane, Vec2, Vec3};
use solidcraft_sketch::{ConstraintKind, LinkKind, LinkSource, Sketch, nearest_crossing};
use solidcraft_sketch::{ConstraintKind, LinkKind, LinkSource, Sketch};
use super::sketch_store;
use super::{CommandSpec, in_sketch};
@@ -685,9 +685,9 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
if ci == cj {
return Err(bad(cmd, "`x:` needs two distinct curves"));
}
let sha = sk.shape(ci).ok_or_else(|| bad(cmd, format!("`{a}` is not a line, circle or arc")))?;
let shb = sk.shape(cj).ok_or_else(|| bad(cmd, format!("`{b}` is not a line, circle or arc")))?;
let at = nearest_crossing(&sha, &shb, near).ok_or_else(|| bad(cmd, "those curves do not meet"))?;
sk.shape(ci).ok_or_else(|| bad(cmd, format!("`{a}` is not a line, circle or arc")))?;
sk.shape(cj).ok_or_else(|| bad(cmd, format!("`{b}` is not a line, circle or arc")))?;
let at = sk.nearest_crossing(ci, cj, near).ok_or_else(|| bad(cmd, "those curves do not meet"))?;
let pi = sk.add_point(at, None)?;
sk.add_constraint(ConstraintKind::PointOnCurve { p: pi, c: ci }, None)?;
sk.add_constraint(ConstraintKind::PointOnCurve { p: pi, c: cj }, None)?;
+72 -10
View File
@@ -141,14 +141,24 @@ const ON_CURVE: f64 = 1e-4;
/// Curves considered for a snap, so a hostile sketch cannot stall the pointer.
const MAX_INTERSECTION_CANDIDATES: usize = 256;
/// Infinite construction lines use their full support; all other curves keep their bounds.
fn snap_distance(shape: &Shape, infinite: bool, p: Vec2) -> f64 {
if infinite && matches!(shape, Shape::Line { .. }) { p.dist(shape.project(p)) } else { shape.dist(p) }
}
/// The crossing of `x` and `y` nearest `near` that lies on both bounded curves.
pub fn nearest_crossing(x: &Shape, y: &Shape, near: Vec2) -> Option<Vec2> {
nearest_crossing_with_extents(x, y, near, [false; 2])
}
fn nearest_crossing_with_extents(x: &Shape, y: &Shape, near: Vec2, infinite: [bool; 2]) -> Option<Vec2> {
if !near.is_finite() {
return None;
}
let mut best: Option<(f64, Vec2)> = None;
let [infinite_x, infinite_y] = infinite;
for q in intersections_within(x, y, 1e-6) {
if !q.is_finite() || x.dist(q) > ON_CURVE || y.dist(q) > ON_CURVE {
if !(q.is_finite() && snap_distance(x, infinite_x, q) <= ON_CURVE && snap_distance(y, infinite_y, q) <= ON_CURVE) {
continue;
}
let d = q.dist(near);
@@ -161,28 +171,29 @@ pub fn nearest_crossing(x: &Shape, y: &Shape, near: Vec2) -> Option<Vec2> {
/// The nearest crossing of two lines, circles or arcs of `sk` to `p` within `tol`.
///
/// Skips a crossing that already has a sketch point. Curves whose bounded distance from `p`
/// is greater than `tol` cannot meet near the cursor and are ignored.
/// Skips a crossing that already has a sketch point. Curves farther than `tol` from `p`
/// cannot meet near the cursor and are ignored. Infinite lines use their full support;
/// ordinary segments and arcs keep their bounds.
pub fn nearest_curve_intersection(sk: &Sketch, p: Vec2, tol: f64) -> Option<(Vec2, usize, usize)> {
if !p.is_finite() || !tol.is_finite() || tol <= 0.0 {
return None;
}
let mut candidates: Vec<(usize, Shape)> = Vec::new();
for i in 0..sk.curves.len() {
let mut candidates: Vec<(usize, Shape, bool)> = Vec::new();
for (i, curve) in sk.curves.iter().enumerate() {
if candidates.len() >= MAX_INTERSECTION_CANDIDATES {
break;
}
let Some(sh) = sk.shape(i) else { continue };
if sh.dist(p) <= tol {
candidates.push((i, sh));
if snap_distance(&sh, curve.infinite, p) <= tol {
candidates.push((i, sh, curve.infinite));
}
}
let mut best: Option<(f64, Vec2, usize, usize)> = None;
for a in 0..candidates.len() {
for b in a + 1..candidates.len() {
let Some((ia, sa)) = candidates.get(a) else { continue };
let Some((ib, sb)) = candidates.get(b) else { continue };
let Some(q) = nearest_crossing(sa, sb, p) else { continue };
let Some((ia, sa, infinite_a)) = candidates.get(a) else { continue };
let Some((ib, sb, infinite_b)) = candidates.get(b) else { continue };
let Some(q) = nearest_crossing_with_extents(sa, sb, p, [*infinite_a, *infinite_b]) else { continue };
let d = q.dist(p);
if d > tol {
continue;
@@ -253,6 +264,12 @@ impl Shape {
}
impl Sketch {
/// The nearest crossing of two analytic curves, respecting infinite construction lines.
pub fn nearest_crossing(&self, a: usize, b: usize, near: Vec2) -> Option<Vec2> {
let infinite = [self.curves.get(a)?.infinite, self.curves.get(b)?.infinite];
nearest_crossing_with_extents(&self.shape(a)?, &self.shape(b)?, near, infinite)
}
/// The analytic shape of a curve (arcs counter-clockwise from their stored start).
pub fn shape(&self, ci: usize) -> Option<Shape> {
match self.curves.get(ci)?.kind {
@@ -286,6 +303,51 @@ mod intersection_snap_tests {
assert_eq!([i.min(j), i.max(j)], [a.min(b), a.max(b)]);
}
#[test]
fn infinite_line_crossing_snaps_beyond_both_defining_segments() {
for reverse in [false, true] {
let mut sk = Sketch::new();
let mut lines = [(v(20.0, 40.0), v(20.0, 50.0)), (v(40.0, 15.0), v(50.0, 15.0))];
if reverse {
lines.reverse();
}
for (a, b) in lines {
let i = sk.add_line(a, b, None, None, None).unwrap();
sk.curves[i].construction = true;
}
let near = v(20.4, 15.4);
assert!(nearest_curve_intersection(&sk, near, 1.0).is_none(), "finite construction segments do not cross");
for curve in &mut sk.curves {
curve.infinite = true;
}
let (at, a, b) = nearest_curve_intersection(&sk, near, 1.0).expect("infinite lines cross");
assert!(at.dist(v(20.0, 15.0)) < 1e-9, "{at:?}");
assert_ne!(a, b);
}
}
#[test]
fn infinite_line_crossing_keeps_the_other_curves_bounds() {
let mut sk = Sketch::new();
let line = sk.add_line(v(-6.0, 30.0), v(-6.0, 40.0), None, None, None).unwrap();
sk.curves[line].infinite = true;
sk.curves[line].construction = true;
sk.add_arc(v(0.0, 0.0), v(10.0, 0.0), v(0.0, 10.0), [None; 3], None).unwrap();
assert!(nearest_curve_intersection(&sk, v(-6.0, 8.0), 1.0).is_none(), "the missing part of the arc is not a crossing");
let CurveKind::Line { a, b } = sk.curves[line].kind else { panic!("line") };
sk.points[a].pos.x = 6.0;
sk.points[b].pos.x = 6.0;
let (at, _, _) = nearest_curve_intersection(&sk, v(6.2, 8.2), 1.0).expect("the drawn arc crosses the infinite line");
assert!(at.dist(v(6.0, 8.0)) < 1e-9, "{at:?}");
let mut sk = Sketch::new();
let vertical = sk.add_line(v(20.0, 40.0), v(20.0, 50.0), None, None, None).unwrap();
sk.curves[vertical].infinite = true;
sk.curves[vertical].construction = true;
sk.add_line(v(40.0, 15.0), v(50.0, 15.0), None, None, None).unwrap();
assert!(nearest_curve_intersection(&sk, v(20.4, 15.4), 1.0).is_none(), "the finite line must not extend");
}
#[test]
fn nearest_curve_intersection_skips_a_crossing_that_already_has_a_point() {
let mut sk = Sketch::new();
@@ -338,6 +338,31 @@ fn a_corner_starts_a_guide_while_drawing_unless_turned_off() {
assert_eq!(h.app.session.doc.to_json(), before, "guides add no geometry or constraints");
}
#[test]
fn a_rectangle_snaps_to_an_infinite_line_crossing() {
use solidcraft_engine::geom::Vec2;
let mut h = Harness::new();
h.app.preferences.look_at_anim = false;
for (command, params) in [
("sketch.create", json!({"plane":"XY"})),
("sketch.construction_line.vertical", json!({"at":[20,40]})),
("sketch.construction_line.horizontal", json!({"at":[40,15]})),
] {
h.app.run(command, params).unwrap();
}
h.call("ui.camera", json!({"target":[20,15,0],"yaw":0,"pitch":std::f64::consts::FRAC_PI_2,"distance":120}));
step(&mut h, json!({"start":"sketch.rectangle.two_point"}));
step(&mut h, json!({"move":{"sketch":[20.4,15.4]}}));
step(&mut h, json!({"click":{"sketch":[20.4,15.4]}}));
let corner = h.app.tool.as_ref().unwrap().pts[0].0;
assert!(corner.dist(Vec2::new(20.0, 15.0)) < 1e-9, "the infinite lines cross beyond their defining segments: {corner:?}");
step(&mut h, json!({"click":{"sketch":[30,25]}}));
step(&mut h, json!({"key":"Escape"}));
let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert_eq!(sk.curves.len(), 6, "two construction lines and the rectangle");
assert!(sk.points.iter().any(|p| p.pos.dist(Vec2::new(20.0, 15.0)) < 1e-9), "the committed rectangle keeps the snapped corner");
}
#[test]
fn two_recent_midpoints_snap_where_their_guides_meet() {
use solidcraft_engine::geom::Vec2;