diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 33ede42e..181d69d3 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -167,7 +167,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "CONSTRAINTS") .icon("c_symmetry") .enabled(in_sketch) - .params("a, b: two points or two curves (lines mirror their ends, circles/arcs their centres); line: symmetry line id, or x / y for the sketch's own axes (a curve with that id wins; an axis becomes a linked construction line on it, reported in created)"), + .params("a, b: two points or two curves (lines mirror their ends, circles/arcs their centres); line: symmetry line id, or x / y for the sketch's own axes (a curve with that id wins; an axis becomes a linked construction line on it, reported in created); or point (or a point id as line, \"origin\" too): symmetric about that point, each pair joined by a construction line it is the midpoint of"), CommandSpec::new("sketch.construction", "Normal/Construction", construction) .at("SKETCH", "CREATE") .icon("construction") @@ -1676,9 +1676,38 @@ fn c_midpoint(s: &mut Session, p: &Value) -> Result { Ok(ConstraintKind::Midpoint { p: point_ref(sk, p.get("point"), cmd, "point")?, l: curve_ref(sk, p.get("line"), cmd, "line")? }) }) } +/// The centre of a symmetry about a point (#1075): `point`, or `line` naming a point rather than +/// a curve or a sketch axis (the origin picked as the third entity). +fn symmetry_center(sk: &Sketch, p: &Value, cmd: &str) -> Result> { + if let Some(r) = p.get("point") { + return point_ref(sk, Some(r), cmd, "point").map(Some); + } + let Some(id) = p.get("line").and_then(Value::as_str) else { return Ok(None) }; + if sk.curve_index(id).is_some() || matches!(id, "x" | "y") { + return Ok(None); + } + Ok(sk.resolve_point(id)) +} + +/// Points `a` and `b` symmetric about point `c`: a construction line from `a` to `b` with `c` +/// at its midpoint. +fn symmetric_about_point(sk: &mut Sketch, a: usize, b: usize, c: usize, cmd: &str) -> Result { + if a == b || a == c || b == c { + return Err(bad(cmd, "symmetry about a point needs two other points")); + } + let l = sk.add_line_pts(a, b, None)?; + if let Some(cu) = sk.curves.get_mut(l) { + cu.construction = true; + } + add_c(sk, ConstraintKind::Midpoint { p: c, l }) +} + fn c_symmetry(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.constraint.symmetry"; let (ids, info) = edit(s, p, cmd, true, |sk, _| { + if let Some(c) = symmetry_center(sk, p, cmd)? { + return symmetry_about_point(sk, p, c, cmd); + } let l = line_or_axis_ref(sk, p.get("line"), cmd, "line")?; let ra = p.get("a").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`a` must be a point or curve"))?; let rb = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?; @@ -1716,6 +1745,52 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result { Ok(json!({"constraints": ids, "sketch": info})) } +/// Symmetry about point `c`: two points, two circles/arcs (their centres, and equal radii) or two +/// lines (each end with the end of the other line nearest its mirror image). The constraints +/// (the construction lines show in the response's `created`). +fn symmetry_about_point(sk: &mut Sketch, p: &Value, c: usize, cmd: &str) -> Result> { + let ra = p.get("a").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`a` must be a point or curve"))?; + let rb = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?; + let rep = solve(&mut sk.clone()); + let order = ent(sk, ra).zip(ent(sk, rb)); + let pairs: Vec<(usize, usize)> = match (sk.resolve_point(ra), sk.resolve_point(rb)) { + (Some(a), Some(b)) => vec![(a, b)], + _ => { + let ca = sk.curve_index(ra).ok_or_else(|| bad(cmd, format!("unknown entity `{ra}`")))?; + let cb = sk.curve_index(rb).ok_or_else(|| bad(cmd, format!("unknown entity `{rb}`")))?; + match (sk.curves.get(ca).map(|x| x.kind.clone()), sk.curves.get(cb).map(|x| x.kind.clone())) { + (Some(CurveKind::Line { a: a0, b: a1 }), Some(CurveKind::Line { a: b0, b: b1 })) => { + let pc = sk.point(c).unwrap_or_default(); + let mirror = |i: usize| pc * 2.0 - sk.point(i).unwrap_or_default(); + let near = |i: usize, j: usize| mirror(i).dist(sk.point(j).unwrap_or_default()); + if near(a0, b0) + near(a1, b1) <= near(a0, b1) + near(a1, b0) { vec![(a0, b0), (a1, b1)] } else { vec![(a0, b1), (a1, b0)] } + } + (Some(_), Some(_)) => { + let pa = round_center(sk, ca).ok_or_else(|| bad(cmd, "symmetry needs two lines or two circles/arcs"))?; + let pb = round_center(sk, cb).ok_or_else(|| bad(cmd, "symmetry needs two lines or two circles/arcs"))?; + let mut out = symmetry_pairs(sk, &[(pa, pb)], c, cmd)?; + out.push(add_c(sk, ConstraintKind::Equal { a: ca, b: cb })?); + if let Some((a, b)) = order { + apply_in_order(sk, &rep, &[a, Ent::Point(c)], b, cmd)?; + } + return Ok(out); + } + _ => return Err(bad(cmd, "symmetry needs two points or two curves")), + } + } + }; + let out = symmetry_pairs(sk, &pairs, c, cmd)?; + // The first entity and the centre stay; the second mirrors onto it. + if let Some((a, b)) = order { + apply_in_order(sk, &rep, &[a, Ent::Point(c)], b, cmd)?; + } + Ok(out) +} + +fn symmetry_pairs(sk: &mut Sketch, pairs: &[(usize, usize)], c: usize, cmd: &str) -> Result> { + pairs.iter().map(|(a, b)| symmetric_about_point(sk, *a, *b, c, cmd)).collect() +} + fn round_center(sk: &Sketch, c: usize) -> Option { match sk.curves.get(c)?.kind { CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } => Some(c), @@ -2411,3 +2486,6 @@ mod ids_tests; #[cfg(test)] #[path = "sketch_redundant_tests.rs"] mod redundant_tests; +#[cfg(test)] +#[path = "sketch_symmetry_point_tests.rs"] +mod symmetry_point_tests; diff --git a/crates/engine/src/cmd/sketch_symmetry_point_tests.rs b/crates/engine/src/cmd/sketch_symmetry_point_tests.rs new file mode 100644 index 00000000..05077e11 --- /dev/null +++ b/crates/engine/src/cmd/sketch_symmetry_point_tests.rs @@ -0,0 +1,87 @@ +//! Symmetry about a point (#1075): the origin (or any sketch point) picked as the third entity, +//! or given as `point`. Each pair is joined by a construction line the point is the midpoint of. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec2; +use solidcraft_sketch::{ConstraintKind, Sketch}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn sketch(s: &Session) -> Sketch { + s.doc.sketch(s.active_sketch.unwrap()).unwrap().clone() +} + +fn pt(sk: &Sketch, r: &str) -> Vec2 { + sk.point(sk.resolve_point(r).unwrap()).unwrap() +} + +fn new_sketch() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + s +} + +#[test] +fn two_points_symmetric_about_the_origin() { + for params in [json!({"a": "p", "b": "q", "line": "origin"}), json!({"a": "p", "b": "q", "point": "origin"})] { + let mut s = new_sketch(); + run(&mut s, "sketch.point", json!({"point": [10, 5], "id": "p"})); + run(&mut s, "sketch.point", json!({"point": [-12, -3], "id": "q"})); + let r = run(&mut s, "sketch.constraint.symmetry", params.clone()); + let sk = sketch(&s); + // The first point stays; the second mirrors onto it through the origin. + assert!(pt(&sk, "p").dist(Vec2::new(10.0, 5.0)) < 1e-6, "{params}"); + assert!(pt(&sk, "q").dist(Vec2::new(-10.0, -5.0)) < 1e-6, "{params}: {:?}", pt(&sk, "q")); + // One construction line, from p to q, with the origin at its midpoint. + let created = r["created"]["curves"].as_array().unwrap(); + assert_eq!(created.len(), 1, "{r}"); + let l = sk.curve_index(created[0].as_str().unwrap()).unwrap(); + assert!(sk.curves[l].construction); + assert!(sk.constraints.iter().any(|k| k.kind == ConstraintKind::Midpoint { p: 0, l })); + // It holds: moving p moves q opposite. + run(&mut s, "sketch.drag", json!({"entity": "p", "from": [10, 5], "to": [20, 8]})); + let sk = sketch(&s); + assert!((pt(&sk, "p") + pt(&sk, "q")).len() < 1e-6, "{:?} {:?}", pt(&sk, "p"), pt(&sk, "q")); + } +} + +#[test] +fn circles_and_lines_symmetric_about_a_point() { + let mut s = new_sketch(); + run(&mut s, "sketch.point", json!({"point": [5, 5], "id": "c"})); + run(&mut s, "sketch.circle.center", json!({"center": [15, 5], "radius": 2, "id": "k1"})); + run(&mut s, "sketch.circle.center", json!({"center": [-4, 6], "radius": 3, "id": "k2"})); + run(&mut s, "sketch.constraint.symmetry", json!({"a": "k1", "b": "k2", "point": "c"})); + let sk = sketch(&s); + let (c1, c2) = (sk.center(sk.curve_index("k1").unwrap()).unwrap(), sk.center(sk.curve_index("k2").unwrap()).unwrap()); + assert!(((c1 + c2) * 0.5).dist(Vec2::new(5.0, 5.0)) < 1e-6, "{c1:?} {c2:?}"); + assert!((sk.radius(sk.curve_index("k1").unwrap()).unwrap() - sk.radius(sk.curve_index("k2").unwrap()).unwrap()).abs() < 1e-6); + + // Two lines: each end pairs with the other line's end nearest its mirror image. + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points": [[2, 1], [8, 4]], "ids": ["a"]})); + run(&mut s, "sketch.line", json!({"points": [[-7, -5], [-1, -1]], "ids": ["b"]})); + let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a", "b": "b", "line": "origin"})); + assert_eq!(r["created"]["curves"].as_array().unwrap().len(), 2, "{r}"); + let sk = sketch(&s); + assert!((pt(&sk, "a.start") + pt(&sk, "b.end")).len() < 1e-6); + assert!((pt(&sk, "a.end") + pt(&sk, "b.start")).len() < 1e-6); +} + +#[test] +fn a_point_cannot_be_its_own_mirror() { + let mut s = new_sketch(); + run(&mut s, "sketch.point", json!({"point": [10, 5], "id": "p"})); + let before = s.doc.to_json(); + assert!(s.execute("sketch.constraint.symmetry", &json!({"a": "p", "b": "origin", "point": "origin"})).is_err()); + assert!(s.execute("sketch.constraint.symmetry", &json!({"a": "p", "b": "p", "line": "origin"})).is_err()); + assert!(s.execute("sketch.constraint.symmetry", &json!({"a": "p", "b": "origin", "point": "nope"})).is_err()); + assert_eq!(s.doc.to_json(), before); +} diff --git a/crates/ui-egui/src/symmetry_pick_tests.rs b/crates/ui-egui/src/symmetry_pick_tests.rs new file mode 100644 index 00000000..d2ecae50 --- /dev/null +++ b/crates/ui-egui/src/symmetry_pick_tests.rs @@ -0,0 +1,46 @@ +//! The Symmetry tool's third pick may be the origin point or an origin axis (#1075). + +use serde_json::json; +use solidcraft_engine::geom::Vec2; + +use crate::scenario::Harness; + +/// An XY sketch with points p (10, 5) and q (-12, -3), the Symmetry tool started and both picked. +fn two_points_picked() -> Harness { + let mut h = Harness::new(); + h.app.ui.show_origin = true; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.point", json!({"point":[10,5],"id":"p"})).unwrap(); + h.app.run("sketch.point", json!({"point":[-12,-3],"id":"q"})).unwrap(); + h.app.cam.distance = 120.0; + h.settle(); + h.app.start("sketch.constraint.symmetry"); + for xy in [[10, 5], [-12, -3]] { + let at = h.call("ui.at", json!({ "sketch": xy }))["result"].clone(); + h.step(&json!({ "click": at })).unwrap(); + } + h +} + +fn point(h: &Harness, id: &str) -> Vec2 { + let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + sk.point(sk.resolve_point(id).unwrap()).unwrap() +} + +#[test] +fn symmetry_about_the_origin_point() { + let mut h = two_points_picked(); + let at = h.call("ui.at", json!({"sketch":[0,0]}))["result"].clone(); + h.step(&json!({ "click": at })).unwrap(); + assert!((point(&h, "p") + point(&h, "q")).len() < 1e-6, "{:?} {:?}", point(&h, "p"), point(&h, "q")); +} + +#[test] +fn symmetry_about_an_origin_axis() { + let mut h = two_points_picked(); + // On the Y axis, away from the origin point and from p and q. + let at = h.call("ui.at", json!({"sketch":[0,6]}))["result"].clone(); + h.step(&json!({ "click": at })).unwrap(); + let (p, q) = (point(&h, "p"), point(&h, "q")); + assert!((p.x + q.x).abs() < 1e-6 && (p.y - q.y).abs() < 1e-6, "mirrored in the Y axis: {p:?} {q:?}"); +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index eb62739a..1f643373 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -16,6 +16,10 @@ mod display_preview_tests; #[path = "sketch_neutral_click_tests.rs"] mod neutral_click_tests; +#[cfg(test)] +#[path = "symmetry_pick_tests.rs"] +mod symmetry_pick_tests; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { /// Click points; `n` points complete the shape (0 = chain until finished). @@ -189,8 +193,12 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { } Kind::Pick(n) => { let hits = pick(app, proj, pos); + // Symmetry's third pick may be an origin axis lying along the sketch's own x or y + // (#1075); the command names those `x` / `y`. + let axis_ok = tool.cmd == "sketch.constraint.symmetry" && tool.picks.len() == 2; if let Some(id) = hits.iter().find_map(|h| match h { Hit::SketchPoint { id, .. } | Hit::SketchCurve { id, .. } => Some(id.clone()), + Hit::Axis { name } if axis_ok => sketch_axis_of(app, name).map(str::to_string), _ => None, }) { tool.picks.push(id); @@ -455,6 +463,31 @@ pub(crate) fn projection_preselection(selection: &[solidcraft_engine::Sel]) -> O .collect() } +/// `x` or `y` when the origin axis `name` is the active sketch's own x or y axis: along it, and +/// through the sketch's origin. +fn sketch_axis_of(app: &SolveApp, name: &str) -> Option<&'static str> { + use solidcraft_engine::geom::Vec3; + let dir = match name { + "X" => Vec3::X, + "Y" => Vec3::Y, + "Z" => Vec3::Z, + _ => return None, + }; + let st = app.session.world_state(); + let plane = st.sketch(app.session.active_sketch?)?.plane; + let o = plane.origin; + if (o - dir * o.dot(dir)).len() > 1e-6 { + return None; + } + if plane.x.dot(dir).abs() > 1.0 - 1e-9 { + Some("x") + } else if plane.y.dot(dir).abs() > 1.0 - 1e-9 { + Some("y") + } else { + None + } +} + fn run_pick(app: &mut SolveApp, t: &Tool) { let k = &t.picks; let params = match t.cmd {