diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index db170f99..0e962e81 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -2361,18 +2361,25 @@ impl Document { /// the sketch over is refused. Offsets whose sources moved are refreshed and solved again: /// from the stored state again after a refused or failed first solve (the geometry is still /// the stored one), from the solved one otherwise. + /// + /// `moved_from`: the stored sketch, when `sk` is that sketch with its links refreshed. A + /// link's points are where their source is now; the sides and turns the solve keeps are + /// those of the stored state ([`Sketch::positions_in`]), not the ones read beside a line + /// that has moved past a point. pub fn solve_sketch( &self, vals: &std::collections::BTreeMap, sk: &mut Sketch, stored: &solidcraft_sketch::DrivingValues, + moved_from: Option<&Sketch>, ) -> Result { self.apply_dimension_values(vals, sk)?; - let mut report = solidcraft_sketch::solve_after_value_edit(sk, stored); + let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from)); + let mut report = solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref()); if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() { // Source dimensions may have moved curves during the solve. sk.refresh_offsets()?; - report = if report.ok() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_value_edit(sk, stored) }; + report = if report.ok() { solidcraft_sketch::solve(sk) } else { solidcraft_sketch::solve_after_edit(sk, stored, was(sk).as_deref()) }; } Ok(report) } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 7b522bec..9d0bf366 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3347,9 +3347,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: if let Some(w) = face_warning { warns.insert(0, w); } - // The stored sketch is solved for its stored values. + // The stored sketch is solved for its stored values, with its links where they + // were stored: a link that moved must not change the side a dimension from it is on. let stored = sk.driving_values(); - let report = doc.solve_sketch(vals, &mut sk, &stored)?; + let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?; if !report.keeps_orientation() { warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})"))); } diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index f575beb1..45fc0e1b 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -294,7 +294,7 @@ pub(super) fn edit_solved( let unsolved = sk.clone(); #[cfg(test)] solve_counts::record(Analysis::Full); - let rep = doc.solve_sketch(&vals, &mut sk, &stored)?; + let rep = doc.solve_sketch(&vals, &mut sk, &stored, None)?; Some(settle(strict, rep, &mut sk, unsolved)?) } }; @@ -336,7 +336,7 @@ pub(super) fn counted_solve( pub(super) fn solve_current(sk: &mut Sketch, doc: &solidcraft_doc::Document) -> Result { let stored = sk.driving_values(); let (values, _) = doc.param_values(); - Ok(doc.solve_sketch(&values, sk, &stored)?) + Ok(doc.solve_sketch(&values, sk, &stored, None)?) } /// Solves run on this thread by [`counted_solve`] and the edit's own solve: (all of them, those diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index 723998b7..8e17849e 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -166,8 +166,14 @@ fn owner_frame(s: &Session, doc: &Document, comp: u64) -> solidcraft_doc::Mat { if comp == s.active_component { s.active_component_frame() } else { doc.component_transform(comp) } } -/// Re-resolve the links of a sketch being edited against the model before it, so edits see -/// current reference geometry. Returns warnings. +/// Re-resolve the links of a sketch being edited (`sk`, a copy of the stored sketch `id`) +/// against the model before it, so edits see current reference geometry. Returns warnings. +/// +/// When a link moved, the rest of the sketch follows it as the evaluation solves it, keeping +/// the stored sides and turns, so the edit goes on from the sketch the model shows. Left where +/// it was stored beside a line that has moved past it, a dimensioned point would give the +/// edit's solve the other side to keep. A sketch that does not solve that way keeps its stored +/// geometry, with the links moved. pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> Vec { if sk.links.is_empty() { return Vec::new(); @@ -183,10 +189,21 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> return vec!["lost face attachment; reattach explicitly".into()]; }; let Ok(plane) = sketch_plane(&doc, id, &st) else { return Vec::new() }; - match attachment { + let warnings = match attachment { Some(anchor) => solidcraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor), None => solidcraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk), + }; + if let Ok(stored) = doc.sketch(id) + && sk.positions_in(stored).is_some() + { + let mut solved = sk.clone(); + let follows = solved.refresh_offsets().is_ok() + && doc.solve_sketch(&vals, &mut solved, &stored.driving_values(), Some(stored)).is_ok_and(|report| report.keeps_orientation()); + if follows { + *sk = solved; + } } + warnings } /// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id. @@ -547,6 +564,10 @@ pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +/// Sketch `A`: the horizontal line `rail`, 100 long, at the height the parameter `height` +/// (50) gives it. +fn rail_sketch(s: &mut Session) { + run(s, "parameters.add", json!({"name": "height", "expression": "50 mm"})); + run(s, "sketch.create", json!({"plane": "XY", "name": "A"})); + run(s, "sketch.line", json!({"points": [[0, 50], [100, 50]], "ids": ["rail"]})); + run(s, "sketch.constraint.horizontal_vertical", json!({"line": "rail", "mode": "horizontal"})); + run(s, "sketch.constraint.horizontal_vertical", json!({"points": ["origin", "rail.start"], "mode": "vertical"})); + run(s, "sketch.dimension", json!({"entities": ["origin", "rail.start"], "value": "height"})); + run(s, "sketch.dimension", json!({"entities": ["rail"], "value": "100 mm"})); + run(s, "sketch.finish", json!({})); +} + +/// Sketch `S` (left open): `rail` brought in by `command`, and the point `p` at (50, `y`), +/// 50 from the origin horizontally. Returns the linked line. +fn point_by_the_rail(s: &mut Session, command: &str, y: f64) -> String { + rail_sketch(s); + run(s, "sketch.create", json!({"plane": "XY", "name": "S"})); + let r = run(s, command, json!({"refs": [{"sketch": "A", "curve": "rail"}]})); + let rail = r["links"][0]["curves"][0].as_str().unwrap().to_string(); + run(s, "sketch.point", json!({"point": [50, y], "id": "p"})); + run(s, "sketch.dimension", json!({"entities": ["origin", "p"], "type": "horizontal", "value": "50 mm"})); + rail +} + +/// `p` held 5 from `line`: the dimension's parameter. +fn five_from(s: &mut Session, line: &str) -> String { + let r = run(s, "sketch.dimension", json!({"entities": ["p", line], "type": "distance", "value": "5 mm"})); + r["param"].as_str().unwrap().to_string() +} + +fn set_height(s: &mut Session, height: f64) { + run(s, "parameters.change", json!({"name": "height", "expression": format!("{height} mm")})); +} + +/// Where the model has `p` of sketch `S`. +fn p(s: &Session) -> Vec2 { + let st = s.model.state(); + let sk = &st.sketch(s.doc.find_feature("S").unwrap().id).unwrap().sketch; + sk.point(sk.resolve_point("p").unwrap()).unwrap() +} + +/// `p` is at (50, `y`), and the sketch has nothing to report. +#[track_caller] +fn assert_p(s: &Session, y: f64, when: &str) { + assert!(p(s).dist(Vec2::new(50.0, y)) < 1e-9, "{when}: {:?}", p(s)); + let feature = s.model.result(s.doc.find_feature("S").unwrap().id).unwrap(); + assert_eq!((&feature.error, &feature.warning), (&None, &None), "{when}"); +} + +/// A line of sketch `A` whose height a parameter drives, projected into sketch `S`, whose point +/// is dimensioned 5 from the projection. +#[test] +fn a_distance_from_a_projected_line_keeps_its_side_when_the_source_moves_past() { + let mut s = Session::default(); + let rail = point_by_the_rail(&mut s, "sketch.project", 45.0); + five_from(&mut s, &rail); + run(&mut s, "sketch.finish", json!({})); + assert_p(&s, 45.0, "drawn"); + // The source drops below the point's stored place: the point stays below the projection. + for height in [20.0, 50.0, 6.0, 400.0] { + set_height(&mut s, height); + assert_p(&s, height - 5.0, &format!("height {height}")); + } + // Editing the sketch afterwards refreshes the projection first; the side is the one from before. + set_height(&mut s, 20.0); + run(&mut s, "sketch.edit", json!({"sketch": "S"})); + run(&mut s, "sketch.point", json!({"point": [80, 80]})); + run(&mut s, "sketch.finish", json!({})); + assert_p(&s, 15.0, "after an edit"); +} + +/// The same from the other side: the point above the line, the source rising past it and +/// coming back; and with the line brought in by Include 3D Geometry. +#[test] +fn a_point_above_the_line_stays_above_when_the_source_rises_past_and_returns() { + for command in ["sketch.project", "sketch.include_3d"] { + let mut s = Session::default(); + let rail = point_by_the_rail(&mut s, command, 55.0); + five_from(&mut s, &rail); + run(&mut s, "sketch.finish", json!({})); + for height in [80.0, 400.0, 50.0, 6.0, 50.0] { + set_height(&mut s, height); + assert_p(&s, height + 5.0, &format!("{command}, height {height}")); + } + } +} + +/// Model geometry in a sketch on XZ (whose y runs down the model's Z): the top of a box whose +/// height a parameter drives, as a projected edge, as an edge of a projected face and as a +/// section of the body. The point is 5 below the top, inside the box. +#[test] +fn a_distance_from_model_geometry_keeps_its_side_when_the_model_moves_past() { + // A projected edge is found again by the point picked on it, so it follows small steps + // only; a face (by its name) and a body follow any. + let small: &[f64] = &[42.0, 50.0, 58.0, 50.0, 44.0]; + let large: &[f64] = &[20.0, 50.0, 6.0, 400.0]; + let cases = [ + ("sketch.project", json!({"edge": [50, 0, 50]}), 0.0, small), + ("sketch.project", json!({"face": [50, 0, 25]}), 0.0, large), + ("sketch.intersect", json!({"body": "Body1"}), -20.0, large), + ]; + for (command, link, front, heights) in cases { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "height", "expression": "50 mm"})); + run(&mut s, "solid.box", json!({"corner": [0, front, 0], "length": 100, "width": 40, "height": "height"})); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "S"})); + run(&mut s, command, json!({"refs": [link]})); + let curves = run(&mut s, "sketch.inspect", json!({}))["curves"].clone(); + let high = |c: &Value, end: &str| (c[end][1].as_f64().unwrap() + 50.0).abs() < 1e-6; + let top = curves.as_array().unwrap().iter().find(|c| high(c, "start_at") && high(c, "end_at")).unwrap()["id"].as_str().unwrap().to_string(); + run(&mut s, "sketch.point", json!({"point": [50, -45], "id": "p"})); + run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p"], "type": "horizontal", "value": "50 mm"})); + five_from(&mut s, &top); + run(&mut s, "sketch.finish", json!({})); + // (An edge found again by its picked point says so in a warning.) + for height in heights { + set_height(&mut s, *height); + assert!(p(&s).dist(Vec2::new(50.0, 5.0 - height)) < 1e-9, "{command} {link}, height {height}: {:?}", p(&s)); + } + } +} + +/// Another projection added after the source moved stores the sketch without solving it: the +/// sketch it stores is the one the model shows. +#[test] +fn a_projection_added_after_the_source_moved_leaves_the_side() { + let mut s = Session::default(); + let rail = point_by_the_rail(&mut s, "sketch.project", 45.0); + five_from(&mut s, &rail); + run(&mut s, "sketch.finish", json!({})); + set_height(&mut s, 20.0); + run(&mut s, "sketch.edit", json!({"sketch": "S"})); + run(&mut s, "sketch.project", json!({"refs": ["origin"]})); + run(&mut s, "sketch.finish", json!({})); + assert_p(&s, 15.0, "after the projection"); + set_height(&mut s, 50.0); + assert_p(&s, 45.0, "back at 50"); +} + +/// The side is still the user's to choose, by where the point is when the dimension is added: +/// also when the source has moved past the point since it was drawn. That side is kept from +/// then on, and through an edit of the dimension's value. +#[test] +fn a_dimension_added_after_the_source_moved_takes_the_side_its_point_is_on() { + let mut s = Session::default(); + let rail = point_by_the_rail(&mut s, "sketch.project", 45.0); + run(&mut s, "sketch.finish", json!({})); + // Drawn below the line; the line drops below it. + set_height(&mut s, 20.0); + assert_p(&s, 45.0, "no distance yet"); + run(&mut s, "sketch.edit", json!({"sketch": "S"})); + let distance = five_from(&mut s, &rail); + run(&mut s, "sketch.finish", json!({})); + assert_p(&s, 25.0, "dimensioned above the line"); + for height in [50.0, 6.0, 400.0, 20.0] { + set_height(&mut s, height); + assert_p(&s, height + 5.0, &format!("height {height}")); + } + run(&mut s, "parameters.change", json!({"name": distance, "expression": "8 mm"})); + assert_p(&s, 28.0, "8 from the line"); + set_height(&mut s, 6.0); + assert_p(&s, 14.0, "8 from the line, height 6"); +} diff --git a/crates/sketch/src/construct.rs b/crates/sketch/src/construct.rs index 996abef9..53be92b6 100644 --- a/crates/sketch/src/construct.rs +++ b/crates/sketch/src/construct.rs @@ -94,11 +94,13 @@ impl Placement { /// Place the points the constraints pin down; `given[i]` marks the points that are constants /// for this solve. Branches are chosen to match the orientation of `reference` (positions -/// before an interactive move) where it covers a point, of the sketch's current positions -/// otherwise; a reference longer than the point list is ignored. `on_curve` holds the sketch's -/// point-on-curve pairs ([`crate::solver::points_on_curves`]); the decomposition works within -/// what is left of `budget`, and charges it. -pub(crate) fn seed(sk: &Sketch, given: &[bool], reference: Option<&[Vec2]>, on_curve: &[(usize, usize)], budget: &mut Budget) -> Seed { +/// before an interactive move, or the stored ones after linked geometry moved) where it covers +/// a point, of the sketch's current positions otherwise; a reference longer than the point list +/// is ignored. `edits`: the solve applies edits; a drag step solves against its reference and +/// moves only held points. `on_curve` holds the sketch's point-on-curve pairs +/// ([`crate::solver::points_on_curves`]); the decomposition works within what is left of +/// `budget`, and charges it. +pub(crate) fn seed(sk: &Sketch, given: &[bool], reference: Option<&[Vec2]>, edits: bool, on_curve: &[(usize, usize)], budget: &mut Budget) -> Seed { let n = sk.points.len(); let reference = reference.filter(|r| r.len() <= n); let before = |i: usize| match reference.and_then(|r| r.get(i)) { @@ -139,9 +141,6 @@ pub(crate) fn seed(sk: &Sketch, given: &[bool], reference: Option<&[Vec2]>, on_c if !switch::decompose_on() { return out; } - // A drag step solves against its reference and moves only held points; any other solve - // applies edits. - let edits = reference.is_none(); let mut clusters = Decomposition::new(&reading, &prop.anchors, edits, budget.left()); if !clusters.built() { return out; diff --git a/crates/sketch/src/construct_rounds_tests.rs b/crates/sketch/src/construct_rounds_tests.rs index 8be3c0bb..ec7442d9 100644 --- a/crates/sketch/src/construct_rounds_tests.rs +++ b/crates/sketch/src/construct_rounds_tests.rs @@ -693,7 +693,7 @@ fn hostile_lines_and_rounds_neither_panic_nor_change_the_report() { let mut c = base(); case(&mut c.sk); let sk = edited(&c); - let s = seed(&sk, &given(&sk), Some(&[v(f64::NAN, 0.0)]), &points_on_curves(&sk), &mut Budget::full()); + let s = seed(&sk, &given(&sk), Some(&[v(f64::NAN, 0.0)]), false, &points_on_curves(&sk), &mut Budget::full()); assert!(s.pos.iter().flatten().all(|p| p.is_finite()), "case {i}: {s:?}"); assert!(s.radius.iter().flatten().all(|r| r.is_finite() && *r > 0.0), "case {i}: {s:?}"); let mut t = c.sk.clone(); @@ -751,7 +751,7 @@ fn a_dimensioned_radius_is_seeded_with_nothing_given() { let mut sk = Sketch::new(); let circle = sk.add_circle(v(10.0, 5.0), 10.0, None, None).unwrap(); push(&mut sk, ConstraintKind::Diameter { c: circle, value: 30.0 }); - let s = seed(&sk, &vec![false; sk.points.len()], None, &[], &mut Budget::full()); + let s = seed(&sk, &vec![false; sk.points.len()], None, true, &[], &mut Budget::full()); assert_eq!(s.radius[circle], Some(15.0)); } @@ -773,10 +773,10 @@ fn the_touch_point_comes_from_the_point_on_curve_index() { push(&mut sk, ConstraintKind::Tangent { a: l, b: circle }); push(&mut sk, ConstraintKind::PointOnCurve { p: b, c: circle }); let on_curve = crate::solver::points_on_curves(&sk); - let with = seed(&sk, &given(&sk), None, &on_curve, &mut Budget::full()); + let with = seed(&sk, &given(&sk), None, true, &on_curve, &mut Budget::full()); assert!(with.placed_by[c].is_some_and(Placement::anchors), "{with:?}"); assert!(with.pos[c].unwrap().dist(v(40.0, 12.0) + d.perp() * 8.0) < 1e-9, "{with:?}"); - let without = seed(&sk, &given(&sk), None, &[], &mut Budget::full()); + let without = seed(&sk, &given(&sk), None, true, &[], &mut Budget::full()); assert!(!without.placed_by[c].is_some_and(Placement::anchors), "{without:?}"); } diff --git a/crates/sketch/src/construct_tests.rs b/crates/sketch/src/construct_tests.rs index 50c1998c..a69d9833 100644 --- a/crates/sketch/src/construct_tests.rs +++ b/crates/sketch/src/construct_tests.rs @@ -528,7 +528,7 @@ fn hostile_input_neither_panics_nor_changes_the_report() { case(&mut sk, a, b, l); let mut mask = given(&sk); mask.push(true); - let s = seed(&sk, &mask, Some(&[v(f64::NAN, 0.0)]), &points_on_curves(&sk), &mut Budget::full()); + let s = seed(&sk, &mask, Some(&[v(f64::NAN, 0.0)]), false, &points_on_curves(&sk), &mut Budget::full()); assert!(s.pos.iter().flatten().all(|p| p.is_finite()), "case {i}: {s:?}"); let n = sk.points.len(); for reference in [None, Some(vec![Vec2::ZERO; 1]), Some(vec![v(f64::NAN, f64::INFINITY); n]), Some(vec![Vec2::ZERO; n + 3])] { @@ -902,7 +902,7 @@ fn given_points_are_never_placed() { push(&mut sk, ConstraintKind::Distance { p: a, q: d, value: 36.0 }); let mask = given(&sk); assert!(mask[g]); - let out = seed(&sk, &mask, None, &points_on_curves(&sk), &mut Budget::full()); + let out = seed(&sk, &mask, None, true, &points_on_curves(&sk), &mut Budget::full()); assert_eq!((out.pos[g], out.placed_by[g]), (None, None), "{out:?}"); assert_eq!(out.placed_by[d], None, "nothing is built on it: {out:?}"); } diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index a445f45f..c2313655 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -55,7 +55,8 @@ pub use profiles::{ }; pub use shape::{Shape, intersections}; pub use solver::{ - Analysis, DrivingValues, Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_after_value_edit, solve_holding, solve_with, + Analysis, DrivingValues, Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_after_edit, solve_after_value_edit, solve_holding, + solve_with, }; pub use text::{MAX_TEXT_CHARS, text_geometry, text_geometry_in}; pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline}; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 65826610..de352149 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -852,6 +852,21 @@ impl Sketch { Ok(true) } + /// Where this sketch's points were in `stored`, the same sketch before its links were + /// moved to where their sources are now ([`Sketch::update_link`]): point by point, as + /// [`crate::solve_after_edit`] takes them. Points are matched by id (a rebuilt link has + /// new ones); a point `stored` does not have is where it is now. `None` when none moved. + pub fn positions_in(&self, stored: &Sketch) -> Option> { + let aligned = self.points.len() == stored.points.len() && self.points.iter().zip(&stored.points).all(|(p, q)| p.id == q.id); + let was: Vec = if aligned { + stored.points.iter().map(|p| p.pos).collect() + } else { + let by_id: std::collections::BTreeMap<&str, Vec2> = stored.points.iter().map(|p| (p.id.as_str(), p.pos)).collect(); + self.points.iter().map(|p| by_id.get(p.id.as_str()).copied().unwrap_or(p.pos)).collect() + }; + self.points.iter().zip(&was).any(|(p, w)| p.pos != *w).then_some(was) + } + fn drop_link_entities(&mut self, id: &str) { let curves = self.link_curves(id); let keep = self.links.clone(); @@ -921,3 +936,7 @@ impl Sketch { #[cfg(test)] #[path = "link_identity_tests.rs"] mod identity_tests; + +#[cfg(test)] +#[path = "link_side_tests.rs"] +mod side_tests; diff --git a/crates/sketch/src/link_side_tests.rs b/crates/sketch/src/link_side_tests.rs new file mode 100644 index 00000000..e5b07738 --- /dev/null +++ b/crates/sketch/src/link_side_tests.rs @@ -0,0 +1,122 @@ +//! A dimension from a link keeps its side when the link moves: the solve after the links are +//! refreshed reads sides and turns where the points were stored ([`Sketch::positions_in`]), +//! not beside a line that has already moved past them. + +use super::*; +use crate::{ConstraintKind, solve, solve_after_edit}; + +fn source() -> LinkSource { + LinkSource::SketchCurve { sketch: 1, curve: "rail".into() } +} + +fn rail(height: f64) -> Vec { + vec![LinkGeom::Line(Vec2::new(0.0, height), Vec2::new(80.0, height))] +} + +/// What the evaluation does with a stored sketch whose link's source moved: the link goes +/// where the source is, then the sketch is solved from its stored state. `kept`: the solve is +/// given the stored positions. +fn moved(stored: &Sketch, link: &str, geom: &[LinkGeom], kept: bool) -> Sketch { + let mut sk = stored.clone(); + assert!(!sk.update_link(link, geom).unwrap(), "the link keeps its points"); + let before = if kept { sk.positions_in(stored) } else { None }; + assert!(solve_after_edit(&mut sk, &stored.driving_values(), before.as_deref()).keeps_orientation()); + sk +} + +fn near(at: Vec2, x: f64, y: f64) -> bool { + at.dist(Vec2::new(x, y)) < 1e-9 +} + +#[test] +fn a_point_dimensioned_from_a_linked_line_keeps_its_side_when_the_line_moves_past() { + let mut sk = Sketch::new(); + let link = sk.add_link(LinkKind::Project, source(), &rail(50.0)).unwrap(); + let l = sk.link_curves(&link)[0]; + let p = sk.add_point(Vec2::new(40.0, 45.0), None).unwrap(); + sk.add_constraint(ConstraintKind::DistanceX { p: 0, q: p, value: 40.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::PointLineDistance { p, l, value: 5.0 }, None).unwrap(); + assert!(solve(&mut sk).ok()); + // Past the point (20, 6, 44), away from it (400) and not as far as it (48). + for height in [20.0, 6.0, 44.0, 400.0, 48.0] { + let at = moved(&sk, &link, &rail(height), true).points[p].pos; + assert!(near(at, 40.0, height - 5.0), "{height}: {at:?}"); + } + // Read where the line is now, the stored point is above it: the solution on that side. + let at = moved(&sk, &link, &rail(20.0), false).points[p].pos; + assert!(near(at, 40.0, 25.0), "{at:?}"); +} + +#[test] +fn a_dimension_along_an_axis_keeps_its_side_of_a_linked_point_that_moves_past() { + for upright in [true, false] { + let end = |h: f64| vec![LinkGeom::Point(if upright { Vec2::new(80.0, h) } else { Vec2::new(h, 80.0) })]; + let mut sk = Sketch::new(); + let link = sk.add_link(LinkKind::Project, LinkSource::SketchPoint { sketch: 1, point: "end".into() }, &end(50.0)).unwrap(); + let q = sk.link_points(&link)[0]; + let p = sk.add_point(if upright { Vec2::new(40.0, 45.0) } else { Vec2::new(45.0, 40.0) }, None).unwrap(); + if upright { + sk.add_constraint(ConstraintKind::DistanceX { p: 0, q: p, value: 40.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::DistanceY { p: q, q: p, value: 5.0 }, None).unwrap(); + } else { + sk.add_constraint(ConstraintKind::DistanceY { p: 0, q: p, value: 40.0 }, None).unwrap(); + sk.add_constraint(ConstraintKind::DistanceX { p: q, q: p, value: 5.0 }, None).unwrap(); + } + assert!(solve(&mut sk).ok()); + for height in [20.0, 6.0, 400.0] { + let at = moved(&sk, &link, &end(height), true).points[p].pos; + let (along, across) = if upright { (at.y, at.x) } else { (at.x, at.y) }; + assert!((along - (height - 5.0)).abs() < 1e-9 && (across - 40.0).abs() < 1e-9, "upright {upright}, {height}: {at:?}"); + } + let at = moved(&sk, &link, &end(20.0), false).points[p].pos; + assert!(((if upright { at.y } else { at.x }) - 25.0).abs() < 1e-9, "upright {upright}: {at:?}"); + } +} + +/// A corner 50 from both ends of a linked line: below the line, or its mirror image above. +#[test] +fn a_corner_dimensioned_from_two_linked_points_keeps_its_turn_when_they_move_past() { + let mut sk = Sketch::new(); + let link = sk.add_link(LinkKind::Project, source(), &rail(50.0)).unwrap(); + let ends = sk.link_points(&link); + let p = sk.add_point(Vec2::new(40.0, 20.0), None).unwrap(); + for end in ends { + sk.add_constraint(ConstraintKind::Distance { p: end, q: p, value: 50.0 }, None).unwrap(); + } + assert!(solve(&mut sk).ok()); + assert!(near(sk.points[p].pos, 40.0, 20.0)); + for height in [10.0, -200.0, 300.0] { + let at = moved(&sk, &link, &rail(height), true).points[p].pos; + assert!(near(at, 40.0, height - 30.0), "{height}: {at:?}"); + } + let at = moved(&sk, &link, &rail(10.0), false).points[p].pos; + assert!(near(at, 40.0, 40.0), "{at:?}"); +} + +#[test] +fn stored_positions_are_matched_by_point_id() { + let mut stored = Sketch::new(); + let link = stored.add_link(LinkKind::Project, source(), &rail(50.0)).unwrap(); + let p = stored.add_point(Vec2::new(40.0, 45.0), Some("p")).unwrap(); + assert_eq!(stored.positions_in(&stored), None, "nothing moved"); + + let mut sk = stored.clone(); + sk.update_link(&link, &rail(20.0)).unwrap(); + let was: Vec = stored.points.iter().map(|q| q.pos).collect(); + assert_eq!(sk.positions_in(&stored), Some(was)); + + // A link of another shape is built anew: its points are new, and the points after it + // change places in the list. + let mut sk = stored.clone(); + let bent = [LinkGeom::Line(Vec2::new(0.0, 20.0), Vec2::new(40.0, 30.0)), LinkGeom::Line(Vec2::new(40.0, 30.0), Vec2::new(80.0, 20.0))]; + assert!(sk.update_link(&link, &bent).unwrap()); + let now = sk.point_index("p").unwrap(); + assert_ne!(now, p); + assert_eq!(sk.positions_in(&stored), None, "the new points have no stored place and `p` has not moved"); + sk.points[now].pos = Vec2::new(60.0, 45.0); + let was = sk.positions_in(&stored).unwrap(); + assert_eq!(was[now], Vec2::new(40.0, 45.0)); + for i in sk.link_points(&link) { + assert_eq!(was[i], sk.points[i].pos); + } +} diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index 1c43c21f..8df6972f 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -1275,8 +1275,19 @@ pub type DrivingValues = std::collections::BTreeMap; /// again by stepping its changed values from old to new (see `continue_values`). Where no /// value changed a solve behaves as [`solve`]. pub fn solve_after_value_edit(sk: &mut Sketch, old: &DrivingValues) -> SolveReport { + solve_after_edit(sk, old, None) +} + +/// [`solve_after_value_edit`] of a sketch whose linked geometry may have moved as well: once +/// its links are refreshed ([`Sketch::update_link`]), the linked points are where their sources +/// are now and everything else is still the stored solved state. `stored` has every point's +/// position in that state ([`Sketch::positions_in`]; `None` when no link moved), and the sides +/// and turns the solve keeps are read there, as [`solve_with`] reads them from the positions +/// before an interactive move. Read where the links are now, a point dimensioned from a +/// projected line is on the far side of it as soon as the source moves the line past the point. +pub fn solve_after_edit(sk: &mut Sketch, old: &DrivingValues, stored: Option<&[Vec2]>) -> SolveReport { let old_values: Vec> = sk.constraints.iter().map(|c| old.get(&c.id).copied()).collect(); - solve_from(sk, &Hold::default(), None, Analysis::Full, Some(&old_values), &mut Budget::full()) + solve_from(sk, &Hold::default(), stored, Analysis::Full, Some(&old_values), &mut Budget::full()) } impl Sketch { @@ -1736,7 +1747,11 @@ fn solve_inner( let mut constructed = vec![false; lay.n]; if crate::construct::switch::seed_on() { let given: Vec = lay.pvar.iter().map(Option::is_none).collect(); - let seeded = crate::construct::seed(sk, &given, reference, &on_curve, budget); + // A drag step solves against its reference and moves only held points. Any other solve + // applies edits: also the one after linked geometry moved, whose reference is the + // stored state. + let edits = reference.is_none() || old_values.is_some(); + let seeded = crate::construct::seed(sk, &given, reference, edits, &on_curve, budget); for (i, (at, how)) in seeded.pos.iter().zip(&seeded.placed_by).enumerate() { if let (Some(at), Some(how), Some(v)) = (at, how, lay.pvar.get(i).copied().flatten()) && how.anchors() diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index 2a5c0a6c..564b2214 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -39,7 +39,7 @@ pub(crate) fn given(sk: &Sketch) -> Vec { /// The constructive seed of a plain solve. pub(crate) fn seed_of(sk: &Sketch) -> crate::construct::Seed { - crate::construct::seed(sk, &given(sk), None, &crate::solver::points_on_curves(sk), &mut crate::decompose::Budget::full()) + crate::construct::seed(sk, &given(sk), None, true, &crate::solver::points_on_curves(sk), &mut crate::decompose::Budget::full()) } /// Rough rectangle from four lines sharing corner points.