Sketch: symmetry about the origin point or an origin axis (#1318)

Fixes #1075. sketch.constraint.symmetry takes a centre point (the origin or any sketch point) as well as a line; the Symmetry tool's third pick may be the origin point or the sketch's own X/Y origin axis. Checked on main + this branch: cargo check --workspace, the engine cmd::sketch tests (257) and the UI symmetry pick tests.
This commit is contained in:
DuBearte authored and GitHub committed 2026-10-15 03:47:37 -04:00
1 parent 6f115ad740
commit 3e13eb6392
4 files changed
+245 -1

No files matched your search

+79 -1
View File
@@ -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<Value> {
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<Option<usize>> {
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<String> {
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<Value> {
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<Value> {
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<Vec<String>> {
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<Vec<String>> {
pairs.iter().map(|(a, b)| symmetric_about_point(sk, *a, *b, c, cmd)).collect()
}
fn round_center(sk: &Sketch, c: usize) -> Option<usize> {
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;
@@ -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);
}
+46
View File
@@ -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:?}");
}
+33
View File
@@ -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 {