diff --git a/crates/engine/src/cmd/sketch_constraints_tests.rs b/crates/engine/src/cmd/sketch_constraints_tests.rs index af5da268a4..3c136dd178 100644 --- a/crates/engine/src/cmd/sketch_constraints_tests.rs +++ b/crates/engine/src/cmd/sketch_constraints_tests.rs @@ -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(); diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index d372a3e292..8750ca543d 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -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 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 { + nearest_crossing_with_extents(x, y, near, [false; 2]) +} + +fn nearest_crossing_with_extents(x: &Shape, y: &Shape, near: Vec2, infinite: [bool; 2]) -> Option { 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 { /// 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 { + 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 { 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(); diff --git a/crates/ui-egui/src/sketch_guidance_ui_tests.rs b/crates/ui-egui/src/sketch_guidance_ui_tests.rs index 2b3d076cc4..df2977d8ba 100644 --- a/crates/ui-egui/src/sketch_guidance_ui_tests.rs +++ b/crates/ui-egui/src/sketch_guidance_ui_tests.rs @@ -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;