Two-entity constraints move only the second pick (tangent, parallel, perpendicular, equal, concentric, coincident, collinear, symmetric, midpoint): the constraint is applied with the first entity held (solve_holding), the second keeping its size where it can (lines turn and slide, circles translate), then resizing; if the second is fixed, projected or fully constrained the first moves instead, and if neither can the constraint is refused and nothing changes; later solves hold nothing. Context-menu constraints keep the selection order; a curve picked before a point puts the point on the curve

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 06:39:38 +00:00
1 parent 3e7826fe56
commit 7f9a8f2afd
5 files changed
+339 -12

No files matched your search

+157 -4
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use solvecraft_doc::{FeatureKind, PlaneRef};
use solvecraft_geom::{Plane, Vec2, Vec3};
use solvecraft_sketch::{ConstraintKind, CurveKind, Sketch, solve};
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
@@ -833,13 +833,155 @@ fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&Sketch) -> R
let mut before = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let k = f(sk)?;
before = Some(dof_now(sk));
add_c(sk, k)
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 {
apply_in_order(sk, &rep, &[first], second, cmd)?;
}
Ok(id)
})?;
reject_redundant(before, &info, cmd)?;
Ok(json!({"constraint": id, "sketch": info}))
}
/// A sketch entity a constraint is applied to.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Ent {
Point(usize),
Curve(usize),
}
fn ent(sk: &Sketch, r: &str) -> Option<Ent> {
sk.resolve_point(r).map(Ent::Point).or_else(|| sk.curve_index(r).map(Ent::Curve))
}
/// The entities of a two-entity constraint in the order they were picked (`a` then `b`, or
/// `point` then `line`).
fn entity_order(sk: &Sketch, p: &Value) -> Option<(Ent, Ent)> {
let r = |k: &str| p.get(k).and_then(Value::as_str).and_then(|r| ent(sk, r));
Some((r("a").or_else(|| r("point"))?, r("b").or_else(|| r("line"))?))
}
/// Held in place: fixed or projected, or fully determined by the constraints already there.
fn ent_locked(sk: &Sketch, rep: &SolveReport, e: Ent) -> bool {
match e {
Ent::Point(i) => sk.point_locked(i) || rep.point_determined.get(i).copied().unwrap_or(false),
Ent::Curve(c) => sk.curve_locked(c) || rep.curve_determined.get(c).copied().unwrap_or(false),
}
}
/// Hold entities: their points, and with `radius` their radii too.
fn hold_of(sk: &Sketch, ents: &[Ent], radius: bool) -> Hold {
let mut h = Hold::default();
for e in ents {
match *e {
Ent::Point(i) => h.points.push(i),
Ent::Curve(c) => {
h.points.extend(sk.curves.get(c).map(|c| c.kind.point_ids()).unwrap_or_default());
h.radii.push(c);
}
}
}
if !radius {
h.radii.clear();
}
h
}
/// Did a solve shrink a line or an arc to nothing (a degenerate way to satisfy tangency)?
fn collapsed(before: &Sketch, after: &Sketch) -> bool {
let size = |sk: &Sketch, i: usize| match sk.curves.get(i).map(|c| &c.kind) {
Some(CurveKind::Line { .. }) => line_pts(sk, i).map(|(a, b)| a.dist(b)),
Some(CurveKind::Arc { .. } | CurveKind::Circle { .. }) => sk.radius(i),
_ => None,
};
(0..before.curves.len()).any(|i| match (size(before, i), size(after, i)) {
(Some(b), Some(a)) => b > 1e-6 && a < b * 1e-3,
_ => false,
})
}
/// Keep an entity's size while it moves: a line's length (circles keep their radius by holding
/// it).
fn keep_size(sk: &Sketch, e: Ent) -> Vec<ConstraintKind> {
match (
e,
line_pts(
sk,
match e {
Ent::Curve(c) => c,
Ent::Point(_) => usize::MAX,
},
),
) {
(Ent::Curve(l), Some((a, b))) => vec![ConstraintKind::Length { l, value: a.dist(b) }],
_ => Vec::new(),
}
}
/// Satisfy a just-added constraint the way Fusion applies it: the first-picked entities stay
/// put and only the second moves (first keeping its size, so it turns and slides, then also
/// resizing).
/// If the second can't move (fixed, projected or fully determined), the first moves instead;
/// if neither can, the constraint conflicts and nothing changes. Everything else moves as
/// little as it can. Later solves are ordinary ones (nothing is held).
pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent], second: Ent, cmd: &str) -> Result<()> {
let first_locked = first.iter().any(|e| ent_locked(sk, rep, *e));
let second_locked = ent_locked(sk, rep, second);
// (held geometry, temporary size constraints) to try in turn.
let mut tries: Vec<(Hold, Vec<ConstraintKind>)> = Vec::new();
let mut ordered = |stay: &[Ent], mover: Ent| {
let mut h = hold_of(sk, stay, true);
h.radii.extend(hold_of(sk, &[mover], true).radii);
tries.push((h.clone(), keep_size(sk, mover)));
tries.push((hold_of(sk, stay, true), Vec::new()));
// An end point the two share may slide (a line tangent to an arc it ends on, whose
// centre is pinned, has to turn about its other end).
let shared = hold_of(sk, &[mover], false).points;
if h.points.iter().any(|p| shared.contains(p)) {
h.points.retain(|p| !shared.contains(p));
tries.push((h.clone(), keep_size(sk, mover)));
tries.push((h, Vec::new()));
}
};
if !second_locked {
ordered(first, second);
}
if !first_locked {
for f in first {
ordered(&[second], *f);
}
}
const TEMP: &str = "__keep_size";
for (h, extra) in tries {
let mut trial = sk.clone();
trial.constraints.extend(extra.into_iter().map(|kind| solvecraft_sketch::Constraint {
id: TEMP.into(),
kind,
param: None,
driven: false,
text: None,
}));
let ok = solve_holding(&mut trial, &h).ok();
trial.constraints.retain(|c| c.id != TEMP);
if ok && !collapsed(sk, &trial) {
*sk = trial;
return Ok(());
}
}
// Both could move but holding either one leads nowhere: solve as usual.
if !first_locked && !second_locked {
let mut trial = sk.clone();
if solve(&mut trial).ok() {
*sk = trial;
return Ok(());
}
}
Err(bad(cmd, "that conflicts with the sketch: neither entity can move to satisfy it (they are fixed or fully constrained)"))
}
/// Degrees of freedom of a sketch as it is.
pub(super) fn dof_now(sk: &Sketch) -> usize {
solve(&mut sk.clone()).dof
@@ -890,8 +1032,13 @@ fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintCoincident";
constrain(s, p, cmd, |sk| {
let a = point_ref(sk, p.get("a"), cmd, "a")?;
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)) {
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 });
}
@@ -934,6 +1081,8 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let l = curve_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());
let order = ent(sk, ra).zip(ent(sk, rb));
let mut out = Vec::new();
match (sk.resolve_point(ra), sk.resolve_point(rb)) {
(Some(a), Some(b)) => out.push(add_c(sk, ConstraintKind::Symmetric { p: a, q: b, l })?),
@@ -957,6 +1106,10 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
// The first entity and the symmetry line stay; the second mirrors onto it.
if let Some((a, b)) = order {
apply_in_order(sk, &rep, &[a, Ent::Curve(l)], b, cmd)?;
}
Ok(out)
})?;
Ok(json!({"constraints": ids, "sketch": info}))
+148
View File
@@ -252,3 +252,151 @@ fn projected_geometry_can_become_construction() {
let sk = sketch(&s);
assert!(sk.curves.iter().any(|c| c.id == id && c.construction && c.link.is_some()));
}
// ---------------------------------------------------------------------------------------------
// Two-entity constraints: the first pick stays, the second moves
/// Positions of an entity's points (and its radius).
fn snap(s: &Session, id: &str) -> (Vec<solvecraft_geom::Vec2>, Option<f64>) {
let sk = sketch(s);
if let Some(i) = sk.curve_index(id) {
let pts = sk.curves[i].kind.point_ids().into_iter().filter_map(|p| sk.point(p)).collect();
return (pts, sk.radius(i));
}
(vec![pt(s, id)], None)
}
fn same(a: &(Vec<solvecraft_geom::Vec2>, Option<f64>), b: &(Vec<solvecraft_geom::Vec2>, Option<f64>)) -> bool {
a.0.len() == b.0.len() && a.0.iter().zip(&b.0).all(|(p, q)| p.dist(*q) < 1e-9) && a.1.zip(b.1).is_none_or(|(x, y)| (x - y).abs() < 1e-9)
}
/// A free line l1 (5,3)–(45,3) and a circle c1 at (20,18) with radius 5.
fn line_and_circle() -> Session {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[5, 3], [45, 3]]}));
run(&mut s, "CircleCenterRadius", json!({"center": [20, 18], "radius": 5}));
s
}
/// Apply `cmd` with `a` then `b`; the first must not move, the second must.
fn check_order(mut s: Session, cmd: &str, a: &str, b: &str) {
let (a0, b0) = (snap(&s, a), snap(&s, b));
run(&mut s, cmd, json!({"a": a, "b": b}));
assert!(same(&snap(&s, a), &a0), "{cmd}: first `{a}` moved: {a0:?} -> {:?}", snap(&s, a));
assert!(!same(&snap(&s, b), &b0), "{cmd}: second `{b}` did not move");
}
#[test]
fn tangent_moves_only_the_second_pick() {
// Line then circle: only the circle moves (and keeps its size).
let mut s = line_and_circle();
let l0 = snap(&s, "l1");
run(&mut s, "ConstraintTangent", json!({"a": "l1", "b": "c1"}));
assert!(same(&snap(&s, "l1"), &l0), "line moved: {:?}", snap(&s, "l1"));
let c = snap(&s, "c1");
assert!((c.1.unwrap_or(0.0) - 5.0).abs() < 1e-9 && (c.0[0].y - 8.0).abs() < 1e-6, "{c:?}");
// Circle then line: only the line moves.
check_order(line_and_circle(), "ConstraintTangent", "c1", "l1");
}
#[test]
fn a_fixed_second_pick_makes_the_first_move() {
let mut s = line_and_circle();
run(&mut s, "ConstraintFix", json!({"entity": "l1"}));
let l0 = snap(&s, "l1");
let c0 = snap(&s, "c1");
run(&mut s, "ConstraintTangent", json!({"a": "c1", "b": "l1"}));
assert!(same(&snap(&s, "l1"), &l0));
assert!(!same(&snap(&s, "c1"), &c0));
// Fixed first: the second moves as usual.
let mut s = line_and_circle();
run(&mut s, "ConstraintFix", json!({"entity": "c1"}));
let c0 = snap(&s, "c1");
run(&mut s, "ConstraintTangent", json!({"a": "c1", "b": "l1"}));
assert!(same(&snap(&s, "c1"), &c0));
}
#[test]
fn two_fully_constrained_entities_conflict_and_nothing_moves() {
let mut s = line_and_circle();
run(&mut s, "ConstraintFix", json!({"entities": ["l1.start", "l1.end", "c1.center"]}));
run(&mut s, "SketchDimension", json!({"entities": ["c1"], "type": "radius", "value": 5}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!(si["curves"].as_array().is_some_and(|c| c.iter().all(|c| c["fully_constrained"] == true)), "{si}");
let before = sketch(&s);
let e = s.execute("ConstraintTangent", &json!({"a": "l1", "b": "c1"}));
assert!(e.is_err(), "{e:?}");
assert_eq!(sketch(&s), before);
}
#[test]
fn line_pairs_keep_the_first_line() {
let lines = || {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 2]]}));
run(&mut s, "DrawPolyline", json!({"points": [[5, 20], [25, 35]]}));
s
};
let len = |s: &Session, l: &str| {
let p = snap(s, l).0;
p[0].dist(p[1])
};
for cmd in ["ConstraintParallel", "ConstraintPerpendicular", "ConstraintEqual", "ConstraintCollinear"] {
check_order(lines(), cmd, "l1", "l2");
check_order(lines(), cmd, "l2", "l1");
// The second line turns and slides; only Equal changes its length.
let mut s = lines();
let l0 = len(&s, "l2");
run(&mut s, cmd, json!({"a": "l1", "b": "l2"}));
if cmd != "ConstraintEqual" {
assert!((len(&s, "l2") - l0).abs() < 1e-6, "{cmd}: {l0} -> {}", len(&s, "l2"));
} else {
assert!((len(&s, "l2") - len(&s, "l1")).abs() < 1e-6);
}
}
}
#[test]
fn circle_pairs_keep_the_first_circle() {
let circles = || {
let mut s = new_sketch();
run(&mut s, "CircleCenterRadius", json!({"center": [0, 0], "radius": 5}));
run(&mut s, "CircleCenterRadius", json!({"center": [30, 10], "radius": 8}));
s
};
for cmd in ["ConstraintConcentric", "ConstraintEqual", "ConstraintTangent"] {
check_order(circles(), cmd, "c1", "c2");
check_order(circles(), cmd, "c2", "c1");
}
// Equal resizes the second to the first.
let mut s = circles();
run(&mut s, "ConstraintEqual", json!({"a": "c2", "b": "c1"}));
assert!((snap(&s, "c1").1.unwrap_or(0.0) - 8.0).abs() < 1e-9);
}
#[test]
fn point_constraints_keep_the_first_pick() {
let pts = || {
let mut s = new_sketch();
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0]]}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [20, 25]]}));
s
};
check_order(pts(), "ConstraintCoincident", "l2.start", "l1.end");
check_order(pts(), "ConstraintCoincident", "l1.end", "l2.start");
// Curve picked first, point second: the point goes onto the line.
check_order(pts(), "ConstraintCoincident", "l1", "l2.start");
// Midpoint: the point stays, the line moves.
let mut s = pts();
let p0 = snap(&s, "l2.end");
run(&mut s, "ConstraintMidPoint", json!({"point": "l2.end", "line": "l1"}));
assert!(same(&snap(&s, "l2.end"), &p0));
// Symmetry: the first point and the line stay, the second mirrors.
let mut s = pts();
run(&mut s, "DrawPoint", json!({"point": [3, 7]}));
run(&mut s, "DrawPoint", json!({"point": [4, -9]}));
let (a0, l0) = (snap(&s, "p1"), snap(&s, "l1"));
run(&mut s, "ConstraintSymmetry", json!({"a": "p1", "b": "p2", "line": "l1"}));
assert!(same(&snap(&s, "p1"), &a0) && same(&snap(&s, "l1"), &l0));
assert!(pt(&s, "p2").dist(solvecraft_geom::Vec2::new(3.0, -7.0)) < 1e-6, "{:?}", pt(&s, "p2"));
}
+1 -1
View File
@@ -27,7 +27,7 @@ pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError};
pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions};
pub use shape::{Shape, intersections};
pub use solver::{SolveReport, SolveStatus, solve};
pub use solver::{Hold, SolveReport, SolveStatus, solve, solve_holding};
pub use text::{MAX_TEXT_CHARS, text_geometry};
pub use wire::{MAX_WIRE_POINTS, Wire};
+24 -4
View File
@@ -61,8 +61,20 @@ struct Layout {
n: usize,
}
fn layout(sk: &Sketch) -> Layout {
/// Geometry held in place for one solve: point indices and curves whose radius is held.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Hold {
pub points: Vec<usize>,
pub radii: Vec<usize>,
}
fn layout(sk: &Sketch, hold: &Hold) -> Layout {
let mut fixed: Vec<bool> = sk.points.iter().map(|p| p.fixed).collect();
for p in &hold.points {
if let Some(f) = fixed.get_mut(*p) {
*f = true;
}
}
for c in &sk.constraints {
if let ConstraintKind::Fix { p } = c.kind
&& let Some(f) = fixed.get_mut(p)
@@ -92,8 +104,9 @@ fn layout(sk: &Sketch) -> Layout {
let rvar = sk
.curves
.iter()
.map(|c| match c.kind {
CurveKind::Circle { .. } | CurveKind::Ellipse { .. } if c.link.is_none() && !c.fixed => {
.enumerate()
.map(|(i, c)| match c.kind {
CurveKind::Circle { .. } | CurveKind::Ellipse { .. } if c.link.is_none() && !c.fixed && !hold.radii.contains(&i) => {
n += 1;
Some(n - 1)
}
@@ -774,7 +787,14 @@ fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec<usize>)], vars: &[usize],
/// Solve the sketch in place. On failure the geometry is restored and the report lists the
/// violated constraints.
pub fn solve(sk: &mut Sketch) -> SolveReport {
let lay = layout(sk);
solve_holding(sk, &Hold::default())
}
/// Solve with some geometry held as if it were fixed (for this solve only): applying a
/// constraint between two entities holds the first so only the second moves. Everything else
/// moves as little as it can. On failure the geometry is restored.
pub fn solve_holding(sk: &mut Sketch, hold: &Hold) -> SolveReport {
let lay = layout(sk, hold);
let mut x = vec![0.0; lay.n];
for (i, p) in sk.points.iter().enumerate() {
if let Some(v) = lay.pvar.get(i).copied().flatten() {
+9 -3
View File
@@ -421,9 +421,12 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
let mut rounds = Vec::new();
let mut curves = Vec::new();
let mut points = Vec::new();
// Selection order: a two-entity constraint keeps the first and moves the second.
let mut picked: Vec<String> = Vec::new();
for s in &app.session.selection {
match s {
Sel::SketchCurve { id } => {
picked.push(id.clone());
curves.push(id.clone());
match ss.sketch.curve_index(id).and_then(|i| ss.sketch.curves.get(i)).map(|c| &c.kind) {
Some(CurveKind::Line { .. }) => lines.push(id.clone()),
@@ -431,7 +434,10 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
_ => {}
}
}
Sel::SketchPoint { id } => points.push(id.clone()),
Sel::SketchPoint { id } => {
picked.push(id.clone());
points.push(id.clone());
}
_ => {}
}
}
@@ -461,7 +467,7 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
c.push(cmd(app, "ConstraintHorizontalVertical", "Horizontal/Vertical").with(json!({ "points": points })));
}
if p == 1 && curves.len() == 1 && n == 2 {
c.push(cmd(app, "ConstraintCoincident", "Coincident").with(json!({ "a": points[0], "b": curves[0] })));
c.push(cmd(app, "ConstraintCoincident", "Coincident").with(json!({ "a": picked[0], "b": picked[1] })));
if l == 1 {
c.push(cmd(app, "ConstraintMidPoint", "MidPoint").with(json!({ "point": points[0], "line": lines[0] })));
}
@@ -480,7 +486,7 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
c.push(cmd(app, "ConstraintTangent", "Tangent").with(ab));
}
if l == 1 && r == 1 && n == 2 {
c.push(cmd(app, "ConstraintTangent", "Tangent").with(json!({ "a": lines[0], "b": rounds[0] })));
c.push(cmd(app, "ConstraintTangent", "Tangent").with(json!({ "a": picked[0], "b": picked[1] })));
}
if p == 2 && l == 1 && n == 3 {
c.push(cmd(app, "ConstraintSymmetry", "Symmetry").with(json!({ "a": points[0], "b": points[1], "line": lines[0] })));