diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index f26e5037..b054b667 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -9,6 +9,13 @@ use super::{CommandSpec, in_sketch}; use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3}; use crate::{EngineError, Result, Session}; +/// The `connect` parameter every drawing command takes (see `connect_points`). +macro_rules! connect_doc { + () => { + "; connect?: bool (default true: a new point landing exactly on an existing point, the origin too, is made coincident with it)" + }; +} + pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.create", "Create Sketch", create_sketch) .at("SOLID", "CREATE") @@ -21,71 +28,71 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("line") .key("L") .enabled(in_sketch) - .params("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)"), + .params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!())), CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point) .at("SKETCH", "CREATE") .icon("rect") .key("R") .enabled(in_sketch) - .params("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)"), + .params(concat!("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())), CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point) .at("SKETCH", "CREATE") .icon("rect3") .enabled(in_sketch) - .params("p0, p1: first edge; p2: point on the opposite edge"), + .params(concat!("p0, p1: first edge; p2: point on the opposite edge", connect_doc!())), CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center) .at("SKETCH", "CREATE") .icon("rect_center") .enabled(in_sketch) - .params("center, corner. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)"), + .params(concat!("center, corner. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!())), CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center) .at("SKETCH", "CREATE") .icon("circle") .key("C") .enabled(in_sketch) - .params("center: [x,y] or point ref, radius | diameter: number"), + .params(concat!("center: [x,y] or point ref, radius | diameter: number", connect_doc!())), CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two) .at("SKETCH", "CREATE") .icon("circle2") .enabled(in_sketch) - .params("p0, p1: diameter ends"), + .params(concat!("p0, p1: diameter ends", connect_doc!())), CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three) .at("SKETCH", "CREATE") .icon("circle3") .enabled(in_sketch) - .params("p0, p1, p2"), - CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params("start, end, through"), + .params(concat!("p0, p1, p2", connect_doc!())), + CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through", connect_doc!())), CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center) .at("SKETCH", "CREATE") .icon("arc_center") .enabled(in_sketch) - .params("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees"), + .params(concat!("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees", connect_doc!())), CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed) .at("SKETCH", "CREATE") .icon("polygon") .enabled(in_sketch) - .params("center, radius (to vertices), sides, angle?: deg"), + .params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!())), CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed) .at("SKETCH", "CREATE") .icon("polygon") .enabled(in_sketch) - .params("center, radius (to edge midpoints), sides, angle?: deg"), + .params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!())), CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge) .at("SKETCH", "CREATE") .icon("polygon") .enabled(in_sketch) - .params("p0, p1: one edge, sides"), + .params(concat!("p0, p1: one edge, sides", connect_doc!())), CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c) .at("SKETCH", "CREATE") .icon("slot") .enabled(in_sketch) - .params("p0, p1: arc centres, width"), + .params(concat!("p0, p1: arc centres, width", connect_doc!())), CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall) .at("SKETCH", "CREATE") .icon("slot") .enabled(in_sketch) - .params("p0, p1: overall ends, width"), - CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params("point: [x,y], id?"), + .params(concat!("p0, p1: overall ends, width", connect_doc!())), + CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!())), CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params( "entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current)", ), @@ -218,6 +225,47 @@ pub(super) fn edit( Ok((out, json!({"solved": rep.ok(), "dof": rep.dof}))) } +/// How close (mm) a new point must land to an existing one to be connected to it. +const CONNECT_TOL: f64 = 1e-6; + +/// New points (index `first` on) that land on an existing point, the origin included, get a +/// coincident constraint to it, as when a click snaps there; `connect: false` leaves them free. +fn connect_points(sk: &mut Sketch, first: usize, p: &Value) -> Result> { + if !bool_(p, "connect").unwrap_or(true) { + return Ok(Vec::new()); + } + // The existing points by x, for a range lookup per new point. + let mut old: Vec<(Vec2, usize)> = (0..first.min(sk.points.len())).filter_map(|i| Some((sk.point(i)?, i))).collect(); + old.sort_by(|a, b| a.0.x.total_cmp(&b.0.x)); + let mut ties = Vec::new(); + for q in first..sk.points.len() { + let Some(at) = sk.point(q) else { continue }; + let from = old.partition_point(|(o, _)| o.x < at.x - CONNECT_TOL); + let hit = + old.iter().skip(from).take_while(|(o, _)| o.x <= at.x + CONNECT_TOL).filter(|(o, _)| o.dist(at) < CONNECT_TOL).map(|(_, i)| *i).min(); + if let Some(o) = hit { + ties.push((o, q)); + } + } + ties.into_iter().map(|(o, q)| add_c(sk, ConstraintKind::Coincident { p: o, q })).collect() +} + +/// `edit` for drawing commands whose step returns (curve ids, constraint ids): the points it +/// adds are connected to the existing points they land on (see [`connect_points`]). +pub(super) fn draw( + s: &mut Session, + p: &Value, + cmd: &str, + f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<(Vec, Vec)>, +) -> Result<((Vec, Vec), Value)> { + edit(s, p, cmd, false, |sk, doc| { + let first = sk.points.len(); + let (curves, mut cons) = f(sk, doc)?; + cons.extend(connect_points(sk, first, p)?); + Ok((curves, cons)) + }) +} + /// A point argument: `[x, y]` (new point), a point reference string, or `{at, ref}`. #[derive(Clone)] pub(super) enum PArg { @@ -425,7 +473,7 @@ fn draw_line(s: &mut Session, p: &Value) -> Result { let closed = bool_(p, "closed").unwrap_or(false); let infer = bool_(p, "infer").unwrap_or(false); let ids = string_list(p, "ids"); - let ((curves, cons), info) = edit(s, p, cmd, false, |sk, _| { + let ((curves, cons), info) = draw(s, p, cmd, |sk, _| { let args: Vec = list.iter().map(|v| parg(sk, v, cmd)).collect::>()?; let n = args.len(); let segs = if closed { n } else { n - 1 }; @@ -555,9 +603,11 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result { } let (lo, hi) = (Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y))); let ((out, names), info) = edit(s, p, cmd, false, |sk, _| { + let first = sk.points.len(); let (l, pts) = polygon_lines(sk, &[lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)])?; - let cons = rect_constraints(sk, &l)?; + let mut cons = rect_constraints(sk, &l)?; mark_construction(sk, &l, p); + cons.extend(connect_points(sk, first, p)?); Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts))) })?; Ok(rect_result(out, names, info)) @@ -571,6 +621,7 @@ fn rect_center(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "the corner must differ from the centre in x and y")); } let ((out, names), info) = edit(s, p, cmd, false, |sk, _| { + let first = sk.points.len(); let (l, pts) = polygon_lines(sk, &[c - h, Vec2::new(c.x + h.x, c.y - h.y), c + h, Vec2::new(c.x - h.x, c.y + h.y)])?; let mut cons = rect_constraints(sk, &l)?; // Both diagonals (construction), the centre at their crossing. Only the first carries @@ -588,6 +639,7 @@ fn rect_center(s: &mut Session, p: &Value) -> Result { cons.push(add_c(sk, ConstraintKind::Midpoint { p: cp, l: diag })?); } mark_construction(sk, &l, p); + cons.extend(connect_points(sk, first, p)?); Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts))) })?; Ok(rect_result(out, names, info)) @@ -602,7 +654,7 @@ fn rect_three_point(s: &mut Session, p: &Value) -> Result { if w.abs() < 1e-9 { return Err(bad(cmd, "p2 must not be on the first edge")); } - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (out, info) = draw(s, p, cmd, |sk, _| { let (l, _) = polygon_lines(sk, &[a, b, b + n * w, a + n * w])?; let mut cons = Vec::new(); if let [l0, l1, l2, l3] = l[..] { @@ -630,7 +682,7 @@ fn circle_center(s: &mut Session, p: &Value) -> Result { let p = &super::sketch_project::snap_points(s, p, cmd)?; let r = radius_arg(p, cmd)?; let id = str_(p, "id").map(str::to_string); - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (out, info) = draw(s, p, cmd, |sk, _| { let c = req_parg(sk, p, "center", cmd)?; let ci = sk.add_circle(c.pos(), r, c.idx(), id.as_deref())?; mark_construction(sk, &[ci], p); @@ -646,7 +698,7 @@ fn circle_two(s: &mut Session, p: &Value) -> Result { if r < 1e-9 { return Err(bad(cmd, "the points must differ")); } - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (out, info) = draw(s, p, cmd, |sk, _| { let ci = sk.add_circle((a + b) * 0.5, r, None, None)?; Ok((ids_of(sk, &[ci]), Vec::new())) })?; @@ -671,7 +723,7 @@ fn circle_three(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.circle.three_point"; let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?); let (o, r) = circumcircle(a, b, c).ok_or_else(|| bad(cmd, "the points are collinear"))?; - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (out, info) = draw(s, p, cmd, |sk, _| { let ci = sk.add_circle(o, r, None, None)?; Ok((ids_of(sk, &[ci]), Vec::new())) })?; @@ -682,7 +734,7 @@ fn arc_three(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.arc.three_point"; let p = &super::sketch_project::snap_points(s, p, cmd)?; let id = str_(p, "id").map(str::to_string); - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (out, info) = draw(s, p, cmd, |sk, _| { let (a, b) = (req_parg(sk, p, "start", cmd)?, req_parg(sk, p, "end", cmd)?); let t = req_vec2(cmd, p, "through")?; let (o, _) = circumcircle(a.pos(), t, b.pos()).ok_or_else(|| bad(cmd, "the points are collinear"))?; @@ -705,7 +757,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result { let p = &super::sketch_project::snap_points(s, p, cmd)?; let id = str_(p, "id").map(str::to_string); let sweep = num(p, "sweep"); - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (out, info) = draw(s, p, cmd, |sk, _| { let c = req_parg(sk, p, "center", cmd)?; let a = req_parg(sk, p, "start", cmd)?; if let Some(sw) = sweep { @@ -742,7 +794,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result { } fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, start: f64, n: usize, inscribed: bool) -> Result { - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (out, info) = draw(s, p, cmd, |sk, _| { let corners: Vec = (0..n).map(|i| center + Vec2::from_angle(start + std::f64::consts::TAU * i as f64 / n as f64) * r_vertex).collect(); let (l, pts) = polygon_lines(sk, &corners)?; let mut cons = Vec::new(); @@ -818,7 +870,7 @@ pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w: let h = w / 2.0; let d = (c1 - c0).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?; let n = d.perp(); - let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (out, info) = draw(s, p, cmd, |sk, _| { let pts: Vec = [c0 - n * h, c1 - n * h, c1 + n * h, c0 + n * h].iter().map(|q| sk.add_point(*q, None)).collect::>()?; let (p0, p1, p2, p3) = (pts[0], pts[1], pts[2], pts[3]); @@ -858,11 +910,12 @@ fn draw_point(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.point"; let at = req_vec2(cmd, p, "point")?; let id = str_(p, "id").map(str::to_string); - let (pid, info) = edit(s, p, cmd, false, |sk, _| { + let ((pid, cons), info) = edit(s, p, cmd, false, |sk, _| { let i = sk.add_point(at, id.as_deref())?; - Ok(sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default()) + let cons = connect_points(sk, i, p)?; + Ok((sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default(), cons)) })?; - Ok(json!({"point": pid, "sketch": info})) + Ok(json!({"point": pid, "constraints": cons, "sketch": info})) } // --------------------------------------------------------------------------------------------- @@ -1070,6 +1123,15 @@ fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result { fn c_coincident(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.constraint.coincident"; + // Already tied (e.g. connected when it was drawn there): nothing to add. + let sk = s.doc.sketch(target_sketch(s, p, cmd)?)?; + let pt = |k: &str| p.get(k).and_then(Value::as_str).and_then(|r| sk.resolve_point(r)); + if let (Some(a), Some(b)) = (pt("a"), pt("b")) + && let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a))) + { + let rep = solve(&mut sk.clone()); + return Ok(json!({"constraint": c.id, "existing": true, "sketch": {"solved": rep.ok(), "dof": rep.dof}})); + } constrain(s, p, cmd, |sk| { let aref = p.get("a").and_then(Value::as_str).unwrap_or_default(); let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?; diff --git a/crates/engine/src/cmd/sketch_constraints_tests.rs b/crates/engine/src/cmd/sketch_constraints_tests.rs index 9aaf25b6..0c5edd7e 100644 --- a/crates/engine/src/cmd/sketch_constraints_tests.rs +++ b/crates/engine/src/cmd/sketch_constraints_tests.rs @@ -99,7 +99,7 @@ fn curvature_polygon_arc_length_linear_diameter() { #[test] fn glyphs_and_snaps() { let mut s = new_sketch(); - let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})); + let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10], "connect": false})); let l = ids(&r["curves"]); let g = run(&mut s, "sketch.glyphs", json!({})); assert_eq!(g["glyphs"].as_array().unwrap().len(), 4); diff --git a/crates/engine/src/cmd/sketch_rect_tests.rs b/crates/engine/src/cmd/sketch_rect_tests.rs index 1bbb9b34..82bb7b28 100644 --- a/crates/engine/src/cmd/sketch_rect_tests.rs +++ b/crates/engine/src/cmd/sketch_rect_tests.rs @@ -23,7 +23,7 @@ fn rect(kind: &str) -> (Session, Vec) { let r = match kind { "center" => run(&mut s, "sketch.rectangle.center", json!({"center": [3, 2], "corner": [13, 9]})), "two" => run(&mut s, "sketch.rectangle.two_point", json!({"p0": [1, 1], "p1": [21, 15]})), - _ => run(&mut s, "sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15]})), + _ => run(&mut s, "sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [20, 5], "p2": [18, 15], "connect": false})), }; let sides = ids(&r["curves"]); assert_eq!(sides.len(), 4, "{r}"); diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index d58c46ab..7d6e0080 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -71,7 +71,7 @@ fn every_dimension_kind_has_a_drawable_frame() { #[test] fn dimension_text_is_stored_relative_to_the_dimension_and_follows_it() { let mut s = new_sketch(); - run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]]})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]], "connect": false})); let d = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "text_at": [20, -8]})); let param = d["param"].as_str().unwrap_or_default().to_string(); let text = |s: &Session| { @@ -126,7 +126,7 @@ fn dof(s: &mut Session) -> i64 { #[test] fn fixed_points_survive_drags_and_solves_and_unfix_frees_them() { let mut s = new_sketch(); - run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [30, 20]]})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0], [30, 20]], "connect": false})); let free = dof(&mut s); let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "l1.end"})); assert_eq!(r["result"]["fixed"], true); @@ -333,7 +333,7 @@ fn two_fully_constrained_entities_conflict_and_nothing_moves() { fn line_pairs_keep_the_first_line() { let lines = || { let mut s = new_sketch(); - run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 2]]})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 2]], "connect": false})); run(&mut s, "sketch.line", json!({"points": [[5, 20], [25, 35]]})); s }; @@ -360,7 +360,7 @@ fn line_pairs_keep_the_first_line() { fn circle_pairs_keep_the_first_circle() { let circles = || { let mut s = new_sketch(); - run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "connect": false})); run(&mut s, "sketch.circle.center", json!({"center": [30, 10], "radius": 8})); s }; @@ -471,7 +471,7 @@ fn dragging_a_rectangle_side_moves_that_side_only() { #[test] fn dragging_circles_and_arcs() { let mut s = new_sketch(); - run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5, "connect": false})); // The edge changes the radius about a fixed centre; the centre moves the circle. drag(&mut s, "c1", [5.0, 0.0], [0.0, 8.0]); assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9); @@ -553,7 +553,7 @@ fn chain_follows_shared_and_coincident_ends() { #[test] fn constraints_and_dimensions_can_be_selected_and_deleted_together() { let mut s = new_sketch(); - run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]]})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "connect": false})); run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"})); run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30})); let ids: Vec = sketch(&s).constraints.iter().map(|c| c.id.clone()).collect(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index df715a0b..3685ed4c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1887,3 +1887,31 @@ fn rectangle_names_sides_and_corners() { assert!((p.x - 90.0).abs() < 1e-9 && (p.y + 5.0).abs() < 1e-9, "{c}"); assert_eq!(pos(&s, &json!(format!("{}.start", c["sides"]["top"].as_str().unwrap()))), pos(&s, &c["corners"]["top_right"]), "{c}"); } + +/// Points drawn exactly onto an existing point (the origin, a corner) are connected to it; +/// `connect: false` leaves them free, and tying them again by hand is a no-op, not a conflict. +#[test] +fn drawing_on_existing_points_connects_to_them() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + let n = |v: &Value| v["constraints"].as_array().map_or(0, Vec::len); + // Horizontal/vertical x4, plus the corner on the origin. + let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + assert_eq!(n(&r), 5, "{r}"); + run(&mut s, "sketch.dimension", json!({"entities": [r["curves"][0]], "value": 40})); + run(&mut s, "sketch.dimension", json!({"entities": [r["curves"][1]], "value": 30})); + let si = run(&mut s, "sketch.inspect", json!({})); + assert_eq!(si["dof"], 0, "fully constrained without a hand-made coincident: {si}"); + let again = run(&mut s, "sketch.constraint.coincident", json!({"a": "origin", "b": "l1.start"})); + assert_eq!(again["existing"], true, "{again}"); + + // A line from a corner, a circle on a corner and a point on the origin connect too. + assert_eq!(n(&run(&mut s, "sketch.line", json!({"points": [[40, 30], [60, 30]]}))), 1); + assert_eq!(n(&run(&mut s, "sketch.circle.center", json!({"center": [0, 30], "radius": 3}))), 1); + assert_eq!(n(&run(&mut s, "sketch.point", json!({"point": [0, 0]}))), 1); + // Close is not on it, and `connect: false` opts out. + assert_eq!(n(&run(&mut s, "sketch.point", json!({"point": [40.001, 0]}))), 0); + assert_eq!(n(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, -10]], "connect": false}))), 0); + let si = run(&mut s, "sketch.inspect", json!({})); + assert_ne!(si["status"], "conflict", "{si}"); +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index df8a828f..6a89cdea 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -242,38 +242,9 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec { .ok() .map(|v| v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect()) .unwrap_or_default(); - tie_snaps(app, t, &curves); curves } -/// A click that snapped to an existing point (the origin, a corner) but went in as plain -/// coordinates: tie the new shape's point there to it, as Fusion does. -fn tie_snaps(app: &mut SolveApp, t: &Tool, curves: &[String]) { - let mut ties = Vec::new(); - { - let st = app.session.world_state(); - let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return }; - let sk = &ss.sketch; - let new_pts: Vec = - curves.iter().filter_map(|c| sk.curve_index(c)).flat_map(|i| sk.curves.get(i).map(|c| c.kind.point_ids()).unwrap_or_default()).collect(); - for (at, r) in &t.pts { - let Some(r) = r else { continue }; - let Some(target) = sk.resolve_point(r) else { continue }; - if new_pts.contains(&target) { - continue; - } - if let Some(q) = new_pts.iter().copied().find(|q| sk.point(*q).is_some_and(|p| p.dist(*at) < 1e-6)) - && let Some(qid) = sk.points.get(q).map(|p| p.id.clone()) - { - ties.push((r.clone(), qid)); - } - } - } - for (a, b) in ties { - let _ = app.run("sketch.constraint.coincident", json!({"a": a, "b": b})); - } -} - fn run_pick(app: &mut SolveApp, t: &Tool) { let k = &t.picks; let params = match t.cmd { diff --git a/crates/ui-egui/tests/scenarios/constraint_glyphs_both_entities.json b/crates/ui-egui/tests/scenarios/constraint_glyphs_both_entities.json index 2e4a6d1b..6b132dbc 100644 --- a/crates/ui-egui/tests/scenarios/constraint_glyphs_both_entities.json +++ b/crates/ui-egui/tests/scenarios/constraint_glyphs_both_entities.json @@ -1,6 +1,6 @@ [ {"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}}, - {"call": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 20]}}}, + {"call": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 20], "connect": false}}}, {"call": "engine.execute", "params": {"command": "sketch.circle.center", "params": {"center": [60, 10], "radius": 5}}}, {"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[45, -10], [75, -10]]}}}, {"call": "engine.execute", "params": {"command": "sketch.line", "params": {"points": [[45, 30], [75, 30]]}}}, diff --git a/crates/ui-egui/tests/scenarios/rectangle_clicked_on_origin.json b/crates/ui-egui/tests/scenarios/rectangle_clicked_on_origin.json new file mode 100644 index 00000000..424cfbfe --- /dev/null +++ b/crates/ui-egui/tests/scenarios/rectangle_clicked_on_origin.json @@ -0,0 +1,14 @@ +[ + {"note": "A rectangle whose first corner is clicked on the origin is tied to it once (the engine connects it), so two dimensions fully constrain it."}, + {"start": "sketch.create"}, + {"click": {"plane": "XY"}}, + {"start": "sketch.rectangle.two_point"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [30, 20]}}, + {"key": "Escape"}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["dof"], "value": 2}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["constraints", 4, "type"], "value": "coincident"}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["constraints", 5], "value": null}}}, + {"key": "Z", "cmd": true}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 0], "value": null}}} +]