Sketch constraints accept x / y for the sketch's own axes (#394) (#448)

`sketch.constraint.symmetry` (line), `parallel`, `perpendicular`,
`collinear`, `tangent` and `coincident` (point on curve) now take `x` /
`y` for the sketch's own axes, matching what `solid.revolve` means by
lowercase x|y. A real curve with that id still wins.

An axis becomes a linked construction line on it: a new
`LinkSource::SketchAxis` resolved in sketch coordinates, so it stays on
the sketch's axis on any plane, is shared by every constraint on that
axis, is left out of profiles and is reported in `created`. Equal,
concentric, midpoint and horizontal/vertical keep refusing axes, since
the axis line's finite length or fixed direction would make them
meaningless.


Claude-Session: https://claude.ai/code/session_01YDDPmNdRoBgkHcLeUDKVfb

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Irakli KourtisandClaude Opus 5.5 authored and GitHub committed 2026-10-12 20:31:35 -04:00
1 parent 722bd007c8
commit 0dee9deec1
5 files changed
+332 -17

No files matched your search

+45 -16
View File
@@ -117,12 +117,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "CONSTRAINTS")
.icon("c_coincident")
.enabled(in_sketch)
.params("a, b: points, a point and a curve, or two distinct lines (joins their closest endpoints)"),
.params("a, b: points, a point and a curve or sketch axis (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created), or two distinct lines (joins their closest endpoints)"),
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
.at("SKETCH", "CONSTRAINTS")
.icon("c_tangent")
.enabled(in_sketch)
.params("a, b: curves"),
.params("a, b: curves or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.equal", "Equal", c_equal)
.at("SKETCH", "CONSTRAINTS")
.icon("c_equal")
@@ -132,12 +132,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "CONSTRAINTS")
.icon("c_parallel")
.enabled(in_sketch)
.params("a, b: lines"),
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular)
.at("SKETCH", "CONSTRAINTS")
.icon("c_perpendicular")
.enabled(in_sketch)
.params("a, b: lines"),
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.fix", "Fix/UnFix", c_fix)
.at("SKETCH", "CONSTRAINTS")
.icon("c_fix")
@@ -157,12 +157,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "CONSTRAINTS")
.icon("c_collinear")
.enabled(in_sketch)
.params("a, b: lines"),
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry)
.at("SKETCH", "CONSTRAINTS")
.icon("c_symmetry")
.enabled(in_sketch)
.params("a, b: points, line: symmetry line"),
.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)"),
CommandSpec::new("sketch.construction", "Normal/Construction", construction)
.at("SKETCH", "CREATE")
.icon("construction")
@@ -454,6 +454,22 @@ pub(super) fn curve_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -
sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}`")))
}
/// A line a constraint refers to: a curve id, or `x` / `y` for the sketch's own axes (a curve
/// with that id wins). An axis becomes the sketch's linked construction line on it
/// ([`Sketch::sketch_axis_line`]), added on first use and reported in `created`.
pub(super) fn line_or_axis_ref(sk: &mut Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result<usize> {
let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a curve id, or x / y for a sketch axis")))?;
curve_or_axis(sk, id)?.ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}` (x and y name the sketch's own axes)")))
}
/// Curve `id`, or the line of sketch axis `id` (see [`line_or_axis_ref`]); `None` when neither.
fn curve_or_axis(sk: &mut Sketch, id: &str) -> Result<Option<usize>> {
match sk.curve_index(id) {
Some(c) => Ok(Some(c)),
None => Ok(sk.sketch_axis_line(id)?),
}
}
pub(super) fn point_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result<usize> {
let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a point reference")))?;
sk.resolve_point(id).ok_or_else(|| bad(cmd, format!("unknown sketch point `{id}`")))
@@ -1154,7 +1170,7 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
// ---------------------------------------------------------------------------------------------
// Constraints
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&Sketch) -> Result<ConstraintKind>) -> Result<Value> {
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result<ConstraintKind>) -> Result<Value> {
let mut before = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let k = f(sk)?;
@@ -1179,7 +1195,7 @@ pub(super) enum Ent {
}
fn ent(sk: &Sketch, r: &str) -> Option<Ent> {
sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).map(Ent::Curve))
sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).or_else(|| sk.sketch_axis_curve(r)).map(Ent::Curve))
}
/// The entities of a two-entity constraint in the order they were picked (`a` then `b`, or
@@ -1372,15 +1388,18 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
}
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"))?;
// A curve picked first and a point second: the point goes onto the curve.
if let (None, Some(c), Some(q)) = (sk.resolve_point(aref), sk.curve_index(aref), sk.resolve_point(bref)) {
// A curve (or sketch axis) picked first and a point second: the point goes onto it.
if sk.resolve_point(aref).is_none()
&& let Some(q) = sk.resolve_point(bref)
&& let Some(c) = curve_or_axis(sk, aref)?
{
return Ok(ConstraintKind::PointOnCurve { p: q, c });
}
let a = point_ref(sk, p.get("a"), cmd, "a")?;
if let Some(b) = sk.resolve_point(bref) {
return Ok(ConstraintKind::Coincident { p: a, q: b });
}
let c = sk.curve_index(bref).ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?;
let c = curve_or_axis(sk, bref)?.ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?;
Ok(ConstraintKind::PointOnCurve { p: a, c })
})
}
@@ -1420,20 +1439,27 @@ fn two_curves(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
Ok((curve_ref(sk, p.get("a"), cmd, "a")?, curve_ref(sk, p.get("b"), cmd, "b")?))
}
/// Two curves where either may be a sketch axis (`x` / `y`, see [`line_or_axis_ref`]).
fn two_curves_or_axes(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> {
Ok((line_or_axis_ref(sk, p.get("a"), cmd, "a")?, line_or_axis_ref(sk, p.get("b"), cmd, "b")?))
}
fn c_tangent(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.tangent", |sk| two_curves(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b }))
constrain(s, p, "sketch.constraint.tangent", |sk| {
two_curves_or_axes(sk, p, "sketch.constraint.tangent").map(|(a, b)| ConstraintKind::Tangent { a, b })
})
}
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.equal", |sk| two_curves(sk, p, "sketch.constraint.equal").map(|(a, b)| ConstraintKind::Equal { a, b }))
}
fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.parallel", |sk| {
two_curves(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
two_curves_or_axes(sk, p, "sketch.constraint.parallel").map(|(a, b)| ConstraintKind::Parallel { a, b })
})
}
fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.perpendicular", |sk| {
two_curves(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
two_curves_or_axes(sk, p, "sketch.constraint.perpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })
})
}
fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1443,7 +1469,7 @@ fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
}
fn c_collinear(s: &mut Session, p: &Value) -> Result<Value> {
constrain(s, p, "sketch.constraint.collinear", |sk| {
two_curves(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
two_curves_or_axes(sk, p, "sketch.constraint.collinear").map(|(a, b)| ConstraintKind::Collinear { a, b })
})
}
fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1455,7 +1481,7 @@ fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.symmetry";
let (ids, info) = edit(s, p, cmd, true, |sk, _| {
let l = curve_ref(sk, p.get("line"), cmd, "line")?;
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"))?;
let rep = solve(&mut sk.clone());
@@ -2119,6 +2145,9 @@ mod rect_tests;
#[path = "sketch_ux_tests.rs"]
mod ux_tests;
#[cfg(test)]
#[path = "sketch_axis_ref_tests.rs"]
mod axis_ref_tests;
#[cfg(test)]
#[path = "sketch_drag_live_tests.rs"]
mod drag_live_tests;
@@ -0,0 +1,238 @@
//! Constraints that take a line accept `x` / `y` for the sketch's own axes (issue #394): the
//! axis becomes one linked construction line on it, shared by every constraint on that axis,
//! reported in `created`, and left out of profiles. A real curve with that id wins.
use serde_json::{Value, json};
use solidcraft_geom::{Vec2, Vec3};
use solidcraft_sketch::{CurveKind, LinkSource, 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 on(plane: Value) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({ "plane": plane }));
s
}
fn pt(sk: &Sketch, r: &str) -> Vec2 {
sk.point(sk.resolve_point(r).unwrap()).unwrap()
}
fn strs(v: &Value) -> Vec<String> {
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
/// The sketch's axis lines: (axis name, curve id).
fn axis_lines(sk: &Sketch) -> Vec<(String, String)> {
sk.links
.iter()
.filter_map(|l| match &l.source {
LinkSource::SketchAxis { name } => Some((name.clone(), l.id.clone())),
_ => None,
})
.flat_map(|(name, link)| sk.link_curves(&link).into_iter().map(move |c| (name.clone(), sk.curves[c].id.clone())))
.collect()
}
fn assert_axis_line(sk: &Sketch, id: &str, dir: Vec2) {
let c = sk.curve_index(id).unwrap();
let curve = &sk.curves[c];
assert!(curve.construction, "axis line {id} is construction");
assert!(sk.is_linked_curve(c), "axis line {id} is linked");
let CurveKind::Line { a, b } = curve.kind else { panic!("axis line {id} is a line") };
for p in [sk.point(a).unwrap(), sk.point(b).unwrap()] {
assert!(p.cross(dir).abs() < 1e-9, "{id} end {p:?} lies on the axis {dir:?}");
}
}
#[test]
fn issue_394_symmetry_about_the_sketch_y_axis() {
let mut s = on(json!("XY"));
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
assert!(r["sketch"]["solved"].as_bool().unwrap(), "{r}");
let sk = sketch(&s);
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
assert!((start.x + end.x).abs() < 1e-7 && (start.y - end.y).abs() < 1e-7, "mirrored about y: {start:?} {end:?}");
assert!((start.x - -4.0).abs() < 1e-7, "the first entity stays: {start:?}");
let axes = axis_lines(&sk);
assert_eq!(axes.len(), 1, "{axes:?}");
let (name, line) = &axes[0];
assert_eq!(name, "y");
assert_axis_line(&sk, line, Vec2::Y);
assert_eq!(strs(&r["created"]["curves"]), vec![line.clone()], "the axis line is reported in created: {r}");
assert_eq!(strs(&r["created"]["constraints"]).len(), 1, "{r}");
// A second constraint on the same axis reuses its line.
run(&mut s, "sketch.line", json!({"points": [[-3, 5], [8, 5]], "ids": ["b"]}));
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "b.start", "b": "b.end", "line": "y"}));
assert!(strs(&r["created"]["curves"]).is_empty(), "{r}");
assert_eq!(axis_lines(&sketch(&s)).len(), 1);
}
#[test]
fn a_real_curve_named_like_an_axis_wins() {
let mut s = on(json!("XY"));
run(&mut s, "sketch.line", json!({"points": [[5, -20], [5, 20]], "ids": ["y"]}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "y"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [12, 0]], "ids": ["a"]}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
let sk = sketch(&s);
assert!(axis_lines(&sk).is_empty(), "no axis line when a curve is called y");
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
assert!((start.x + end.x - 10.0).abs() < 1e-7, "mirrored about x = 5: {start:?} {end:?}");
}
#[test]
fn a_rectangle_centred_on_the_origin_keeps_one_profile() {
let mut s = on(json!("XY"));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 1], "p1": [12, 7]}));
let sk = sketch(&s);
let lines = strs(&r["curves"]);
let dir = |id: &String| {
let CurveKind::Line { a, b } = sk.curves[sk.curve_index(id).unwrap()].kind else { panic!() };
sk.point(b).unwrap() - sk.point(a).unwrap()
};
let horizontal = lines.iter().find(|l| dir(l).y.abs() < 1e-9).unwrap().clone();
let vertical = lines.iter().find(|l| dir(l).x.abs() < 1e-9).unwrap().clone();
run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{horizontal}.start"), "b": format!("{horizontal}.end"), "line": "y"}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": format!("{vertical}.start"), "b": format!("{vertical}.end"), "line": "x"}));
let sk = sketch(&s);
let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY);
let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
for l in &lines {
for end in ["start", "end"] {
let p = pt(&sk, &format!("{l}.{end}"));
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
}
}
assert!((lo.x + hi.x).abs() < 1e-7 && (lo.y + hi.y).abs() < 1e-7, "centred on the origin: {lo:?} {hi:?}");
assert_eq!(axis_lines(&sk).len(), 2);
// The axis lines cross the rectangle but are construction: still one profile.
assert_eq!(run(&mut s, "sketch.solve", json!({}))["profiles"], json!(1));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
}
#[test]
fn sketch_axes_on_a_plane_off_the_world_axes() {
// A plane whose own axes are none of the world axes: x along world Y, y along world Z,
// origin away from the world origin.
let origin = Vec3::new(5.0, 7.0, 9.0);
let mut s = on(json!({"origin": [5, 7, 9], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}));
run(&mut s, "sketch.line", json!({"points": [[3, -6], [3, 2]], "ids": ["a"]}));
let r = run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "x"}));
assert!(r["sketch"]["solved"].as_bool().unwrap(), "{r}");
let sk = sketch(&s);
let (start, end) = (pt(&sk, "a.start"), pt(&sk, "a.end"));
assert!((start.y + end.y).abs() < 1e-7 && (start.x - end.x).abs() < 1e-7, "mirrored about the sketch x axis: {start:?} {end:?}");
let axes = axis_lines(&sk);
assert_eq!(axes.len(), 1);
assert_axis_line(&sk, &axes[0].1, Vec2::X);
// After a recompute the axis line is still the sketch's x axis, in world space too.
run(&mut s, "sketch.finish", json!({}));
let st = s.model.state();
let id = s.doc.find_feature("Sketch1").map(|f| f.id).unwrap();
let ss = st.sketch(id).unwrap();
let c = ss.sketch.curve_index(&axes[0].1).unwrap();
let CurveKind::Line { a, b } = ss.sketch.curves[c].kind else { panic!() };
for p in [a, b] {
let w = ss.plane.to_world(ss.sketch.point(p).unwrap()) - origin;
assert!(w.x.abs() < 1e-9 && w.z.abs() < 1e-9, "on the plane's x axis (world Y through its origin): {w:?}");
}
}
#[test]
fn other_line_constraints_take_sketch_axes() {
let mut s = on(json!("XZ"));
run(&mut s, "sketch.line", json!({"points": [[1, 1], [6, 3]], "ids": ["p"]}));
run(&mut s, "sketch.constraint.parallel", json!({"a": "p", "b": "x"}));
let sk = sketch(&s);
assert!((pt(&sk, "p.start").y - pt(&sk, "p.end").y).abs() < 1e-7, "parallel to x");
run(&mut s, "sketch.line", json!({"points": [[-2, 1], [-1, 6]], "ids": ["q"]}));
run(&mut s, "sketch.constraint.perpendicular", json!({"a": "x", "b": "q"}));
let sk = sketch(&s);
assert!((pt(&sk, "q.start").x - pt(&sk, "q.end").x).abs() < 1e-7, "perpendicular to x");
run(&mut s, "sketch.line", json!({"points": [[2, -5], [4, -8]], "ids": ["r"]}));
run(&mut s, "sketch.constraint.collinear", json!({"a": "r", "b": "y"}));
let sk = sketch(&s);
assert!(pt(&sk, "r.start").x.abs() < 1e-7 && pt(&sk, "r.end").x.abs() < 1e-7, "on the y axis");
run(&mut s, "sketch.circle.center", json!({"center": [10, 4], "radius": 2, "id": "c"}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "c", "b": "x"}));
let sk = sketch(&s);
let c = sk.curve_index("c").unwrap();
let CurveKind::Circle { c: centre, r } = sk.curves[c].kind else { panic!() };
assert!((sk.point(centre).unwrap().y.abs() - r).abs() < 1e-7, "tangent to x");
// A point onto an axis, either pick order.
run(&mut s, "sketch.point", json!({"point": [7, 9], "id": "u"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "u", "b": "x"}));
assert!(pt(&sketch(&s), "u").y.abs() < 1e-7);
run(&mut s, "sketch.point", json!({"point": [7, 9], "id": "v"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "y", "b": "v"}));
assert!(pt(&sketch(&s), "v").x.abs() < 1e-7);
// Still one line per axis.
let mut names: Vec<String> = axis_lines(&sketch(&s)).into_iter().map(|(n, _)| n).collect();
names.sort();
assert_eq!(names, ["x", "y"]);
}
#[test]
fn the_axis_survives_save_and_load() {
let mut s = on(json!("YZ"));
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": "a.start", "b": "a.end", "line": "y"}));
run(&mut s, "sketch.finish", json!({}));
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let id = doc.find_feature("Sketch1").map(|f| f.id).unwrap();
let sk = doc.sketch(id).unwrap();
assert_eq!(axis_lines(sk).len(), 1);
assert_eq!(sk.links.iter().map(|l| &l.source).collect::<Vec<_>>(), [&LinkSource::SketchAxis { name: "y".into() }]);
}
#[test]
fn bad_line_references_are_errors_and_change_nothing() {
let mut s = on(json!("XY"));
run(&mut s, "sketch.line", json!({"points": [[-4, 0], [10, 0]], "ids": ["a"]}));
run(&mut s, "sketch.line", json!({"points": [[0, 3], [9, 4]], "ids": ["b"]}));
let before = s.doc.to_json();
let hostile =
[json!("Y"), json!("X"), json!("z"), json!(""), json!("x.start"), json!(" y"), json!(5), json!(null), json!(["y"]), json!({"axis": "y"})];
for line in &hostile {
assert!(s.execute("sketch.constraint.symmetry", &json!({"a": "a.start", "b": "a.end", "line": line})).is_err(), "symmetry line {line}");
for cmd in ["sketch.constraint.parallel", "sketch.constraint.perpendicular", "sketch.constraint.collinear", "sketch.constraint.tangent"] {
assert!(s.execute(cmd, &json!({"a": "b", "b": line})).is_err(), "{cmd} b {line}");
}
assert!(s.execute("sketch.constraint.coincident", &json!({"a": "b.start", "b": line})).is_err(), "coincident b {line}");
}
// Constraints that do not take an axis still refuse x / y, and leave no axis line behind.
for cmd in ["sketch.constraint.equal", "sketch.constraint.concentric"] {
assert!(s.execute(cmd, &json!({"a": "b", "b": "x"})).is_err(), "{cmd}");
}
assert!(s.execute("sketch.constraint.midpoint", &json!({"point": "b.start", "line": "y"})).is_err());
assert!(s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "x"})).is_err());
assert_eq!(s.doc.to_json(), before, "failed commands change nothing");
// A constraint the sketch refuses rolls the axis line back with it.
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "b", "mode": "horizontal"}));
let before_redundant = s.doc.to_json();
assert!(s.execute("sketch.constraint.parallel", &json!({"a": "b", "b": "x"})).is_err(), "already horizontal");
assert_eq!(s.doc.to_json(), before_redundant);
assert!(axis_lines(&sketch(&s)).is_empty());
}