From 7f9a8f2afd4f75871c6619df8ed0f7edf4147ffe Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 05:40:42 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/sketch.rs | 161 ++++++++++++++++++++++- crates/engine/src/cmd/sketch_ux_tests.rs | 148 +++++++++++++++++++++ crates/sketch/src/lib.rs | 2 +- crates/sketch/src/solver.rs | 28 +++- crates/ui-egui/src/context_menu.rs | 12 +- 5 files changed, 339 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index dac9b673..f1bf1b91 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -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 { + 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 { + 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)> = 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 { fn c_coincident(s: &mut Session, p: &Value) -> Result { 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 { 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 { } } } + // 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})) diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index e4b72ade..a052c44b 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -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, Option) { + 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, Option), b: &(Vec, Option)) -> 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")); +} diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index eaa43b5e..a2a3d8d0 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -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}; diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index e4d4a126..11b5f578 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -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, + pub radii: Vec, +} + +fn layout(sk: &Sketch, hold: &Hold) -> Layout { let mut fixed: Vec = 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)], 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() { diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 4d5a1fe6..33ea06d3 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -421,9 +421,12 @@ fn sketch_entity_items(app: &SolveApp) -> Vec { 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 = 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 { _ => {} } } - 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 { 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 { 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] })));