Sketch: accept redundant but consistent constraints (#687) (#699)

A clover of same-size rectangles drawn around a shared corner was refused as over-constrained.

Constraints that remove no degree of freedom but still hold (zero residual) are now kept and shown dimmed as redundant. Conflicting ones are still refused. A redundant driving dimension is refused with "already determined by ...". Typed sizes become driven (reference) dimensions. Drawing no longer adds Horizontal/Vertical to a side that is already implied through two coincident corners. The solver reports redundant constraints using a Jacobian row-dependence test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-13 10:12:55 -04:00
1 parent add4a18961
commit 9ba72e118b
14 files changed
+804 -77

No files matched your search

@@ -0,0 +1,252 @@
//! Redundant but consistent constraints (#687): rectangles of one size drawn around a shared
//! corner (a "clover"), or onto each other's sides, imply some of their constraints twice. Those
//! are kept (and reported as redundant) or not added at all; only constraints that conflict
//! with the sketch are refused.
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
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 new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
s
}
fn sketch(s: &Session) -> solidcraft_sketch::Sketch {
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone()
}
fn report(s: &Session) -> solidcraft_sketch::SolveReport {
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().report.clone()
}
fn glyphs(s: &mut Session) -> Value {
run(s, "sketch.glyphs", json!({}))
}
/// The four quadrants around `c`, each `w` square: bottom-left, bottom-right, top-right,
/// top-left (each next one shares a side with the one before; the last shares two).
fn quadrants(c: [f64; 2], w: f64) -> [([f64; 2], [f64; 2]); 4] {
let [x, y] = c;
[([x - w, y - w], [x, y]), ([x, y - w], [x + w, y]), ([x, y], [x + w, y + w]), ([x - w, y], [x, y + w])]
}
fn horizontal_vertical(sk: &solidcraft_sketch::Sketch) -> usize {
sk.constraints
.iter()
.filter(|c| matches!(c.kind, solidcraft_sketch::ConstraintKind::Horizontal { .. } | solidcraft_sketch::ConstraintKind::Vertical { .. }))
.count()
}
/// The owner's steps: rectangles of one size (typed 10 × 10) drawn snapped onto a shared
/// corner, 2, 3 and 4 of them. Every one is accepted; a size the sketch already has becomes a
/// driven (reference) dimension, and the sketch ends fully constrained.
#[test]
fn a_clover_of_same_size_rectangles_drawn_by_snapping_is_accepted() {
for n in 2..=4 {
let mut s = new_sketch();
for (i, (a, b)) in quadrants([0.0, 0.0], 10.0).iter().take(n).enumerate() {
let r = s.execute(
"sketch.draw.dimensioned",
&json!({"command": "sketch.rectangle.two_point", "params": {"p0": a, "p1": b}, "dimensions": {"width": 10, "height": 10}}),
);
let r = r.unwrap_or_else(|e| panic!("rectangle {i} of {n}: {e}"));
assert_eq!(r["sketch"]["solved"], true, "{r}");
}
let g = glyphs(&mut s);
assert_eq!(g["fully_constrained"], true, "{n} rectangles: {g}");
assert_eq!(g["conflicts"], json!([]), "{g}");
let sk = sketch(&s);
assert_eq!(sk.curves.len(), 4 * n);
// Shared sides carry one horizontal/vertical, not two: 4 for the first rectangle, 3 for
// each with one shared side, 2 for the last of four (two shared sides).
let expected = [0, 4, 7, 10, 12][n];
assert_eq!(horizontal_vertical(&sk), expected, "{n} rectangles");
// Each later rectangle's size along a shared side is a reference dimension.
let driven = sk.constraints.iter().filter(|c| c.driven).count();
assert_eq!(driven, [0, 0, 1, 2, 4][n], "{n} rectangles");
for (a, b) in quadrants([0.0, 0.0], 10.0).iter().take(n) {
for corner in [Vec2::new(a[0], a[1]), Vec2::new(b[0], b[1])] {
assert!(sk.points.iter().any(|p| p.pos.dist(corner) < 1e-9), "{n} rectangles: corner {corner:?} moved");
}
}
}
}
/// Undimensioned, the same clover is drawn without a single redundant constraint: shared
/// sides are not constrained twice, and the freedom left is the two column widths and the two
/// row heights.
#[test]
fn rectangles_sharing_sides_do_not_constrain_a_side_twice() {
let mut s = new_sketch();
for (a, b) in quadrants([50.0, 50.0], 10.0) {
run(&mut s, "sketch.rectangle.two_point", json!({"p0": a, "p1": b}));
}
let rep = report(&s);
assert!(rep.ok(), "{rep:?}");
assert_eq!(rep.redundant, Vec::<String>::new());
// Free: the shared corner (2), two column widths, two row heights.
assert_eq!(rep.dof, 6);
assert_eq!(horizontal_vertical(&sketch(&s)), 12);
}
/// Two rectangles on one side: the second only adds its width.
#[test]
fn shared_edge_rectangles() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [25, 10]}));
// Three of its sides' directions and its two corners on the first one.
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(5), "{r}");
let rep = report(&s);
assert_eq!(rep.dof, 3, "the first one's width and height, the second one's width");
assert!(rep.redundant.is_empty());
// Sized and placed, all of it is fixed.
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 10}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
run(&mut s, "sketch.dimension", json!({"entities": ["l5"], "value": 15}));
assert_eq!(glyphs(&mut s)["fully_constrained"], true);
}
/// Two separately drawn 10 × 10 squares snapped corner to corner: the second snap is implied by
/// the first and the equal sizes. It is kept, reported as redundant along with what implies
/// it, and the degrees of freedom and the fully constrained state are unchanged.
#[test]
fn snapping_a_second_corner_of_same_size_rectangles_is_redundant_not_refused() {
let mut s = new_sketch();
for (a, b) in [([0.0, 0.0], [10.0, 10.0]), ([20.0, 0.0], [30.0, 10.0])] {
run(
&mut s,
"sketch.draw.dimensioned",
json!({"command": "sketch.rectangle.two_point", "params": {"p0": a, "p1": b}, "dimensions": {"width": 10, "height": 10}}),
);
}
// The second square's bottom-left corner onto the first one's bottom-right.
run(&mut s, "sketch.constraint.coincident", json!({"a": "l5.start", "b": "l1.end"}));
let dof = report(&s).dof;
assert_eq!(dof, 0, "the first square is on the origin, the second one placed by its corner");
// Its top-left corner is already on the first one's top-right.
let r = run(&mut s, "sketch.constraint.coincident", json!({"a": "l7.end", "b": "l2.end"}));
assert_eq!(r["redundant"], true, "{r}");
assert!(!r["determined_by"].as_array().is_none_or(Vec::is_empty), "{r}");
let rep = report(&s);
assert_eq!((rep.ok(), rep.dof), (true, 0));
assert_eq!(rep.redundant, vec![r["constraint"].as_str().unwrap().to_string()]);
let g = glyphs(&mut s);
assert_eq!(g["fully_constrained"], true);
let flagged: Vec<&Value> = g["glyphs"].as_array().unwrap().iter().filter(|x| x["redundant"] == true).collect();
assert_eq!(flagged.len(), 1, "{g}");
// Drawing-time inferences ask for redundant ones to be left out.
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "l8", "if_redundant": "refuse"})).unwrap_err().to_string();
assert!(e.contains("already determined by"), "{e}");
}
/// A size the sketch already has, dimensioned again: refused as already determined (naming
/// what determines it), or placed as a driven dimension; an equal constraint the sketch already
/// implies is kept as redundant.
#[test]
fn redundant_equal_dimensions() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [20, 10]}));
let height = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
let k = sketch(&s).constraints.iter().find(|c| c.param.as_deref() == height["param"].as_str()).map(|c| c.id.clone()).unwrap();
// The second one's right side is as tall as the shared side.
let e = s.execute("sketch.dimension", &json!({"entities": ["l6"], "value": 10})).unwrap_err().to_string();
assert!(e.contains("already determined by") && e.contains(&k), "{e}");
let d = run(&mut s, "sketch.dimension", json!({"entities": ["l6"], "value": 10, "if_redundant": "driven"}));
assert_eq!((d["driven"].clone(), d["redundant"].clone()), (json!(true), json!(true)), "{d}");
// Equal widths, twice: the second is implied by the first.
run(&mut s, "sketch.constraint.equal", json!({"a": "l1", "b": "l5"}));
let before = report(&s).dof;
let r = run(&mut s, "sketch.constraint.equal", json!({"a": "l3", "b": "l7"}));
assert_eq!(r["redundant"], true, "{r}");
assert_eq!(report(&s).dof, before);
// Its driving value changes still solve with the redundant constraints present.
let pname = height["param"].as_str().unwrap().to_string();
run(&mut s, "parameters.change", json!({"name": pname, "expression": "14 mm"}));
let sk = sketch(&s);
assert!(report(&s).ok());
let len = |id: &str| {
let c = sk.curve_index(id).unwrap();
let solidcraft_sketch::CurveKind::Line { a, b } = sk.curves[c].kind else { panic!() };
sk.points[a].pos.dist(sk.points[b].pos)
};
assert!((len("l6") - 14.0).abs() < 1e-7 && (len("l4") - 14.0).abs() < 1e-7);
}
/// A constraint that does not hold where the others put the geometry still conflicts.
#[test]
fn a_conflicting_constraint_is_still_refused() {
let mut s = new_sketch();
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [20, 10]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 10}));
let before = sketch(&s).constraints.len();
// The second one's right side cannot be 12 while the shared side is 10.
let e = s.execute("sketch.dimension", &json!({"entities": ["l6"], "value": 12})).unwrap_err().to_string();
assert!(e.contains("over-constrain") || e.contains("conflicts"), "{e}");
let e = s.execute("sketch.dimension", &json!({"entities": ["l6"], "value": 12, "if_redundant": "driven"})).unwrap_err().to_string();
assert!(e.contains("over-constrain") || e.contains("conflicts"), "{e}");
// A vertical side cannot be horizontal.
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": "l2", "mode": "horizontal"})).unwrap_err().to_string();
assert!(e.contains("over-constrain"), "{e}");
assert_eq!(sketch(&s).constraints.len(), before);
assert!(report(&s).redundant.is_empty());
}
/// Dragging the shared corner of a clover moves all four rectangles with it, redundant
/// constraints and all.
#[test]
fn dragging_the_shared_corner_moves_every_rectangle() {
let mut s = new_sketch();
for (a, b) in quadrants([50.0, 50.0], 10.0) {
run(&mut s, "sketch.rectangle.two_point", json!({"p0": a, "p1": b}));
}
// A redundant equal and a redundant vertical, so the drag has some to carry.
run(&mut s, "sketch.constraint.equal", json!({"a": "l1", "b": "l13"}));
let r = run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l4"}));
assert_eq!(r["redundant"], true, "{r}");
assert!(!report(&s).redundant.is_empty());
let centre = Vec2::new(50.0, 50.0);
let id = sketch(&s).points.iter().find(|p| p.pos.dist(centre) < 1e-9).map(|p| p.id.clone()).unwrap();
run(&mut s, "sketch.drag", json!({"entity": id, "from": [50, 50], "to": [56, 47]}));
let sk = sketch(&s);
let rep = report(&s);
assert!(rep.ok(), "{rep:?}");
let to = Vec2::new(56.0, 47.0);
// Each rectangle still has a corner on the moved one and is still a rectangle.
let near: usize = sk.points.iter().filter(|p| p.pos.dist(to) < 1e-6).count();
assert!(near >= 4, "{near} corners at the dragged spot");
for l in &sk.curves {
let solidcraft_sketch::CurveKind::Line { a, b } = l.kind else { continue };
let d = sk.points[b].pos - sk.points[a].pos;
assert!(d.x.abs() < 1e-7 || d.y.abs() < 1e-7, "{} is no longer axis-aligned: {d:?}", l.id);
}
assert!(sk.points.iter().all(|p| p.pos.dist(centre) > 1e-6), "nothing stayed behind at the old corner");
}
/// A horizontal on a line that already is (it was drawn so, inferred) is redundant, not an
/// error; the degrees of freedom do not change, and the sketch still turns fully constrained.
#[test]
fn an_explicit_redundant_constraint_keeps_the_dof_and_full_constraint() {
let mut s = new_sketch();
let l = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"][0].as_str().unwrap().to_string();
let r = run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": l}));
assert_eq!(r["redundant"], true, "{r}");
// Its start is on the origin (snapped): only its length is free.
assert_eq!(report(&s).dof, 1);
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 10}));
let g = glyphs(&mut s);
assert_eq!((g["dof"].clone(), g["fully_constrained"].clone()), (json!(0), json!(true)), "{g}");
assert!(report(&s).point_determined.iter().all(|d| *d));
}