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

+180 -13
View File
@@ -106,7 +106,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_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; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. Returns measured in unit (mm or deg)",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
@@ -401,6 +401,7 @@ pub(super) fn draw(
let first = sk.points.len();
let (curves, mut cons) = f(sk, doc)?;
cons.extend(connect_points(sk, first, p)?);
drop_implied_axes(sk, &mut cons);
Ok((curves, cons))
})
}
@@ -821,6 +822,74 @@ fn rect_constraints(sk: &mut Sketch, l: &[usize]) -> Result<Vec<String>> {
Ok(cons)
}
/// Whether line `l` being horizontal (`horizontal`) or vertical already follows from another
/// constraint than `own`: its ends coincide with the ends of a line constrained the same way (a
/// side two rectangles share), or with two points constrained level (or plumb).
fn axis_implied(sk: &Sketch, l: usize, horizontal: bool, own: &str) -> bool {
let Some(CurveKind::Line { a, b }) = sk.curves.get(l).map(|c| &c.kind) else { return false };
let (a, b) = (*a, *b);
// Coincident points as one (union-find over the coincident constraints).
let mut parent: Vec<usize> = (0..sk.points.len()).collect();
let root = |parent: &[usize], mut i: usize| {
for _ in 0..parent.len() {
match parent.get(i) {
Some(&up) if up != i => i = up,
_ => break,
}
}
i
};
for c in &sk.constraints {
if let ConstraintKind::Coincident { p, q } = c.kind {
let (rp, rq) = (root(&parent, p), root(&parent, q));
if rp != rq
&& let Some(slot) = parent.get_mut(rp)
{
*slot = rq;
}
}
}
let (ra, rb) = (root(&parent, a), root(&parent, b));
sk.constraints.iter().filter(|c| c.id != own).any(|c| {
let ends = match (&c.kind, horizontal) {
(ConstraintKind::Horizontal { l: m }, true) | (ConstraintKind::Vertical { l: m }, false) if *m != l => {
match sk.curves.get(*m).map(|c| &c.kind) {
Some(CurveKind::Line { a, b }) => Some((*a, *b)),
_ => None,
}
}
(ConstraintKind::HorizontalPoints { p, q }, true) | (ConstraintKind::VerticalPoints { p, q }, false) => Some((*p, *q)),
_ => None,
};
ends.is_some_and(|(p, q)| {
let (rp, rq) = (root(&parent, p), root(&parent, q));
rp != rq && ((rp, rq) == (ra, rb) || (rp, rq) == (rb, ra))
})
})
}
/// Drawing adds horizontal/vertical only where it is not already implied: a rectangle drawn
/// onto another's side shares that side's direction through the two coincident corners, so a
/// second "vertical" there would only be redundant. Drops such constraints among `cons` (the
/// drawing's own) from the sketch and the list.
fn drop_implied_axes(sk: &mut Sketch, cons: &mut Vec<String>) {
let mut dropped = Vec::new();
for id in cons.iter() {
let axis = sk.constraints.iter().find(|c| &c.id == id).and_then(|c| match c.kind {
ConstraintKind::Horizontal { l } => Some((l, true)),
ConstraintKind::Vertical { l } => Some((l, false)),
_ => None,
});
if let Some((l, horizontal)) = axis
&& axis_implied(sk, l, horizontal, id)
{
sk.constraints.retain(|c| &c.id != id);
dropped.push(id.clone());
}
}
cons.retain(|id| !dropped.contains(id));
}
/// Which side and corner of an axis-aligned rectangle is which, from its lines and corner
/// points as `polygon_lines` made them (counter-clockwise from the bottom-left corner), so a
/// caller can dimension it without inspecting the sketch first.
@@ -855,6 +924,7 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
let mut cons = rect_constraints(sk, &l)?;
mark_construction(sk, &l, p);
cons.extend(connect_points(sk, first, p)?);
drop_implied_axes(sk, &mut cons);
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
})?;
Ok(rect_result(out, names, info))
@@ -887,6 +957,7 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
}
mark_construction(sk, &l, p);
cons.extend(connect_points(sk, first, p)?);
drop_implied_axes(sk, &mut cons);
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
})?;
Ok(rect_result(out, names, info))
@@ -1174,19 +1245,24 @@ fn draw_point(s: &mut Session, p: &Value) -> 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 mut residual = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let k = f(sk)?;
let rep = solve(&mut sk.clone());
before = Some(rep.dof);
let order = entity_order(sk, p);
let id = add_c(sk, k)?;
if let Some((first, second)) = order {
residual = residual_of(sk, &id);
// A constraint that already holds needs nothing moved (even when both entities are
// fully determined: it is then redundant, see `redundancy`).
let holds = residual.is_some_and(|r| r <= solidcraft_sketch::CONSISTENT);
if let Some((first, second)) = order.filter(|_| !holds) {
apply_in_order(sk, &rep, &[first], second, cmd)?;
}
Ok(id)
})?;
reject_redundant(before, &info, cmd)?;
Ok(json!({"constraint": id, "sketch": info}))
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
constraint_result(p, cmd, id, info, by, Value::Null)
}
/// A sketch entity a constraint is applied to.
@@ -1330,18 +1406,79 @@ pub(super) fn dof_now(sk: &Sketch) -> usize {
solve(&mut sk.clone()).dof
}
/// A constraint that removes no degree of freedom over-constrains the sketch (Fusion refuses
/// it); the caller's edit is rolled back by returning the error.
pub(super) fn reject_redundant(before: Option<usize>, info: &Value, cmd: &str) -> Result<()> {
/// A constraint just added that removes no degree of freedom (`before` → the edit's `info`) is
/// redundant or a conflict. Redundant when it held where the sketch was before it was added
/// (`residual`, see [`solidcraft_sketch::constraint_residual`]): what it asks for is already
/// implied by other constraints, and the solver satisfies it with them. Returns the
/// constraints that determine it (as "id (Name)"), or `None` when it removes a degree of
/// freedom. A conflict is refused; the caller's edit is rolled back by returning the error.
pub(super) fn redundancy(
s: &Session,
p: &Value,
cmd: &str,
before: Option<usize>,
info: &Value,
residual: Option<f64>,
cid: &str,
) -> Result<Option<Vec<String>>> {
let after = info.get("dof").and_then(Value::as_u64).map(|d| d as usize);
if let (Some(b), Some(a)) = (before, after)
&& a >= b
{
return Err(bad(cmd, "that would over-constrain the sketch (it is already determined by other constraints)"));
let (Some(b), Some(a)) = (before, after) else { return Ok(None) };
if a < b {
return Ok(None);
}
if !residual.is_some_and(|r| r <= solidcraft_sketch::CONSISTENT) {
return Err(bad(cmd, "that would over-constrain the sketch (it conflicts with what the other constraints already determine)"));
}
Ok(Some(determined_by(s, p, cmd, cid)))
}
/// The constraints that already determine constraint `cid` of the target sketch, as
/// "id (Name)".
fn determined_by(s: &Session, p: &Value, cmd: &str, cid: &str) -> Vec<String> {
let Some(sk) = target_sketch(s, p, cmd).ok().and_then(|id| s.doc.sketch(id).ok()) else { return Vec::new() };
let Some(ci) = sk.constraints.iter().position(|c| c.id == cid) else { return Vec::new() };
solidcraft_sketch::determined_by(sk, ci)
.into_iter()
.filter_map(|k| sk.constraints.get(k))
.map(|c| format!("{} ({})", c.id, c.kind.name()))
.collect()
}
/// "k3 (Vertical), k9 (Vertical)", or "other constraints" when they are not known.
pub(super) fn listed(by: &[String]) -> String {
if by.is_empty() { "other constraints".to_string() } else { by.join(", ") }
}
/// A redundant constraint is kept (and shown as redundant) unless the caller asked for
/// `if_redundant: "refuse"` (drawing-time inferences, which only add what is not already there).
pub(super) fn keep_redundant(p: &Value, cmd: &str, by: &[String]) -> Result<()> {
if str_(p, "if_redundant").is_some_and(|m| m.eq_ignore_ascii_case("refuse")) {
return Err(bad(cmd, format!("that would over-constrain the sketch (it is already determined by {})", listed(by))));
}
Ok(())
}
/// The result of a constraint command: the constraint, and whether it is redundant (and what
/// determines it).
pub(super) fn constraint_result(p: &Value, cmd: &str, id: String, info: Value, by: Option<Vec<String>>, extra: Value) -> Result<Value> {
let mut out = json!({"constraint": id, "sketch": info});
if let Some(by) = by {
keep_redundant(p, cmd, &by)?;
out["redundant"] = json!(true);
out["determined_by"] = json!(by);
}
if let (Some(o), Value::Object(e)) = (out.as_object_mut(), extra) {
o.extend(e);
}
Ok(out)
}
/// The residual of the constraint `id` where the sketch is now (see
/// [`solidcraft_sketch::constraint_residual`]).
pub(super) fn residual_of(sk: &Sketch, id: &str) -> Option<f64> {
sk.constraints.iter().position(|c| c.id == id).and_then(|ci| solidcraft_sketch::constraint_residual(sk, ci))
}
fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.horizontal_vertical";
let mode = str_(p, "mode").map(str::to_ascii_lowercase);
@@ -1610,6 +1747,8 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
let text_at = p.get("text_at").and_then(vec2);
let driven = bool_(p, "driven").unwrap_or(false);
let mut before = None;
let (mut residual, mut new_id) = (None, String::new());
let start = s.doc.clone();
let ((param, kind_name, current), info) = edit(s, p, cmd, true, |sk, doc| {
before = Some(dof_now(sk));
let ent = |r: &str| -> Result<(Option<usize>, Option<usize>)> {
@@ -1766,8 +1905,13 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
{
(k, cur) = (ka, ca);
}
// How far the value is from what the geometry measures now: zero for a dimension that
// only restates what the sketch already has.
let want = vals.get(&pname).and_then(|v| v.to_kind(if is_angle { Kind::Angle } else { Kind::Length }).ok());
residual = want.map(|w| (w - cur).abs()).filter(|r| r.is_finite());
let name = k.name();
let id = sk.add_constraint(k, Some(pname.clone()))?;
new_id = id.clone();
place_text(sk, &id, text_at);
Ok((pname, name, measured(is_angle, cur)))
})?;
@@ -1776,8 +1920,28 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
if driven {
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
}
if reject_redundant(before, &info, cmd).is_err() {
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
// A dimension the sketch already determines cannot drive it: it is refused, or with
// `if_redundant: "driven"` placed as a driven (reference) dimension instead.
let by = redundancy(s, p, cmd, before, &info, residual, &new_id)
.map_err(|_| bad(cmd, "that dimension conflicts with the sketch: other constraints already determine it with a different value"))?;
if let Some(by) = by {
if str_(p, "if_redundant").is_some_and(|m| m.eq_ignore_ascii_case("driven")) {
s.doc = start;
let mut q = p.clone();
q["driven"] = json!(true);
if let Some(o) = q.as_object_mut() {
o.remove("if_redundant");
o.remove("value");
}
let mut out = dimension(s, &q)?;
out["redundant"] = json!(true);
out["determined_by"] = json!(by);
return Ok(out);
}
return Err(bad(
cmd,
format!("that dimension is already determined by {}; add it as a driven (reference) dimension with driven: true", listed(&by)),
));
}
let v = s.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info}))
@@ -2156,3 +2320,6 @@ mod drag_live_tests;
#[cfg(test)]
#[path = "sketch_ids_tests.rs"]
mod ids_tests;
#[cfg(test)]
#[path = "sketch_redundant_tests.rs"]
mod redundant_tests;
@@ -232,7 +232,8 @@ fn bad_line_references_are_errors_and_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");
// (A redundant one is kept unless refused: refused here.)
assert!(s.execute("sketch.constraint.parallel", &json!({"a": "b", "b": "x", "if_redundant": "refuse"})).is_err(), "already horizontal");
assert_eq!(s.doc.to_json(), before_redundant);
assert!(axis_lines(&sketch(&s)).is_empty());
}
+19 -10
View File
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
use super::sketch::{add_c, dof_now, edit, reject_redundant};
use super::sketch::{add_c, constraint_result, dof_now, edit, listed, redundancy, residual_of};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
use crate::{EngineError, Result, Session};
@@ -56,15 +56,17 @@ pub static COMMANDS: &[CommandSpec] = &[
fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.curvature";
let mut before = None;
let (mut before, mut residual) = (None, None);
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let a = curve(sk, p.get("a"), cmd)?;
let b = curve(sk, p.get("b"), cmd)?;
before = Some(dof_now(sk));
add_c(sk, ConstraintKind::Smooth { a, b })
let id = add_c(sk, ConstraintKind::Smooth { a, b })?;
residual = residual_of(sk, &id);
Ok(id)
})?;
reject_redundant(before, &info, cmd)?;
Ok(json!({"constraint": id, "sketch": info}))
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
constraint_result(p, cmd, id, info, by, Value::Null)
}
fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
@@ -349,16 +351,18 @@ fn guess(sk: &Sketch, ents: &[String], cmd: &str) -> Result<ConstraintKind> {
fn smart_constrain(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constrainer";
let ents = string_list(p, "entities");
let mut before = None;
let (mut before, mut residual) = (None, None);
let mut name = "";
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let k = guess(sk, &ents, cmd)?;
name = k.name();
before = Some(dof_now(sk));
add_c(sk, k)
let id = add_c(sk, k)?;
residual = residual_of(sk, &id);
Ok(id)
})?;
reject_redundant(before, &info, cmd)?;
Ok(json!({"constraint": id, "type": name, "sketch": info}))
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
constraint_result(p, cmd, id, info, by, json!({"type": name}))
}
fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
@@ -397,7 +401,10 @@ fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
c.driven = true;
}
before = Some(dof_now(&sk));
reject_redundant(before, &info, cmd)?;
// It holds (its parameter takes the value it measures), so it can only be redundant.
if let Some(by) = redundancy(s, p, cmd, before, &info, Some(0.0), &cid)? {
return Err(bad(cmd, format!("that dimension is already determined by {}; it stays driven", listed(&by))));
}
}
s.doc_mut().prune_model_params();
Ok(json!({"constraint": cid, "driven": driven, "sketch": info}))
@@ -468,6 +475,8 @@ fn glyphs(s: &mut Session, p: &Value) -> Result<Value> {
"param": c.param,
"at": anchor(&ss.sketch, &c.kind),
"failing": failing.contains(&c.id),
// Implied by the others (it removes no freedom) but holding: shown dimmed.
"redundant": ss.report.redundant.contains(&c.id),
})
})
.collect();
@@ -25,17 +25,17 @@ fn sketch(s: &Session) -> solidcraft_sketch::Sketch {
}
#[test]
fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
fn redundant_dimensions_are_refused_driven_ones_measure() {
let mut s = new_sketch();
let l = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
// Already horizontal (inferred).
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": l})).unwrap_err().to_string();
assert!(e.contains("over-constrain"), "{e}");
// Already horizontal (inferred): kept as redundant, unless asked to refuse it.
let e = s.execute("sketch.constraint.horizontal_vertical", &json!({"line": l, "if_redundant": "refuse"})).unwrap_err().to_string();
assert!(e.contains("over-constrain") && e.contains("already determined by"), "{e}");
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{l}.start"), "b": "origin"}));
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": 10}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0, "{si}");
// A second length on it over-constrains: refused, unless driven.
// A second length on it is already determined: refused, unless driven.
assert!(s.execute("sketch.dimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
let d = run(&mut s, "sketch.dimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
assert_eq!(d["driven"], true);
+3 -1
View File
@@ -95,7 +95,9 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad(cmd, "unsupported dimension role")),
};
let mut dimension = json!({"entities": entities, "type": ty, "value": value});
// A typed size the sketch already has (a rectangle drawn onto another's side, the same
// height) is placed as a driven (reference) dimension rather than refused.
let mut dimension = json!({"entities": entities, "type": ty, "value": value, "if_redundant": "driven"});
if let Some(sketch) = params.get("sketch") {
dimension["sketch"] = sketch.clone();
}
@@ -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));
}