diff --git a/crates/engine/src/cmd/sketch_constraints_tests.rs b/crates/engine/src/cmd/sketch_constraints_tests.rs index b311c72a..8ebf5714 100644 --- a/crates/engine/src/cmd/sketch_constraints_tests.rs +++ b/crates/engine/src/cmd/sketch_constraints_tests.rs @@ -170,3 +170,20 @@ fn smart_constrain_and_driven_toggle() { let r = run(&mut s, "sketch.toggle_driven", json!({"constraint": did})); assert_eq!(r["driven"], false); } + +#[test] +fn drawing_from_a_line_midpoint_keeps_it_there() { + let mut s = new_sketch(); + let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [20, 0]]}))["curves"])[0].clone(); + let r = run(&mut s, "DrawPolyline", json!({"points": [format!("mid:{l}"), [10, 15]]})); + let si = run(&mut s, "sketch.inspect", json!({})); + assert!(si["constraints"].as_array().unwrap().iter().any(|c| c["name"] == "MidPoint"), "{si}"); + // Lengthen the first line: the second one's start follows the middle. + run(&mut s, "sketch.move_point", json!({"point": format!("{l}.end"), "to": [40, 0]})); + let sk = sketch(&s); + let n = ids(&r["curves"])[0].clone(); + let st = sk.resolve_point(&format!("{n}.start")).and_then(|i| sk.point(i)).unwrap(); + let a = sk.resolve_point(&format!("{l}.start")).and_then(|i| sk.point(i)).unwrap(); + let b = sk.resolve_point(&format!("{l}.end")).and_then(|i| sk.point(i)).unwrap(); + assert!(st.dist((a + b) * 0.5) < 1e-7, "{st:?}"); +} diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index 30d84758..b1a66513 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -328,15 +328,16 @@ fn auto_project(s: &mut Session, p: &Value) -> Result { /// Point arguments that snap to model geometry while drawing, rewritten to sketch point ids: /// `{"vertex": [x,y,z]}` projects the vertex (a linked point); `{"on_edge": [x,y,z]}` projects -/// the edge and makes a free point on it (coincident with the projection). Everything else is -/// left as it is. The links are added to the active sketch in the session's document. +/// the edge and makes a free point on it (coincident with the projection); `"mid:"` +/// makes a point held at the line's midpoint. Everything else is left as it is. The links are added to the active sketch in the session's document. pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result { let Some(id) = s.active_sketch else { return Ok(p.clone()) }; let mut out = p.clone(); let mut doc: Option<(Document, Sketch)> = None; let fix = |v: &mut Value, s: &Session, doc: &mut Option<(Document, Sketch)>| -> Result<()> { let (vertex, edge) = (v.get("vertex").and_then(vec3), v.get("on_edge").and_then(vec3)); - if vertex.is_none() && edge.is_none() { + let mid = v.as_str().and_then(|x| x.strip_prefix("mid:")).map(str::to_string); + if vertex.is_none() && edge.is_none() && mid.is_none() { return Ok(()); } if doc.is_none() { @@ -347,7 +348,14 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result": a new point at the line's midpoint, kept there. + let l = sk.curve_index(&line).ok_or_else(|| bad(cmd, format!("unknown curve `{line}`")))?; + let Some(solvecraft_sketch::Shape::Line { a, b }) = sk.shape(l) else { return Err(bad(cmd, format!("`{line}` is not a line"))) }; + let pi = sk.add_point((a + b) * 0.5, None)?; + sk.add_constraint(ConstraintKind::Midpoint { p: pi, l }, None)?; + sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default() + } else if let Some(at) = vertex { let l = add_link(s, d, id, sk, LinkKind::Project, LinkSource::Vertex { body, at }).map_err(|e| bad(cmd, format!("vertex: {e}")))?; let pi = sk.link_points(&l).first().copied().ok_or_else(|| bad(cmd, "vertex projects to nothing"))?; sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default() @@ -373,7 +381,7 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result fix(v, s, &mut doc)?, + Value::Object(_) | Value::String(_) => fix(v, s, &mut doc)?, _ => {} } } diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 82f26185..2bb53a10 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -35,12 +35,13 @@ pub fn refine_snap( sk: &solvecraft_engine::sketch::Sketch, plane: &solvecraft_engine::geom::Plane, lp: Vec2, -) -> Option { +) -> Option<(Vec2, Option)> { // Millimetres per pixel here. let (a, b) = (proj.to_screen(plane.to_world(lp))?, proj.to_screen(plane.to_world(lp + Vec2::X))?); let px = 1.0 / (a.distance(b) as f64).max(1e-9); let tol = 7.0 * px; let mut best: Option<(f64, &'static str, Vec2)> = None; + let mut mid_of: Option = None; for (i, c) in sk.curves.iter().enumerate() { if let solvecraft_engine::sketch::CurveKind::Line { .. } = c.kind && let Some(solvecraft_engine::sketch::Shape::Line { a, b }) = sk.shape(i) @@ -49,6 +50,7 @@ pub fn refine_snap( let d = m.dist(lp); if d < tol && best.is_none_or(|x| d < x.0) { best = Some((d, "Mid", m)); + mid_of = Some(c.id.clone()); } } } @@ -76,7 +78,8 @@ pub fn refine_snap( } } SNAP.with(|s| *s.borrow_mut() = best.map(|(_, l, p)| (l, p))); - best.map(|(_, _, p)| p) + // A midpoint snap is a point argument the commands understand ("mid:"). + best.map(|(_, l, p)| (p, if l == "Mid" { mid_of.map(|m| format!("mid:{m}")) } else { None })) } /// A click on a constraint glyph selects it (true: the click was used). diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index e4a17308..42905c7b 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -799,7 +799,7 @@ pub fn sketch_point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec2, let (minor, _) = grid_step(app.cam.half_height()); let step = minor / 10.0; if let Some(p) = crate::sketch_tools::refine_snap(app, proj, &ss.sketch, &ss.plane, lp) { - return Some((p, None)); + return Some(p); } Some((Vec2::new((lp.x / step).round() * step, (lp.y / step).round() * step), None)) }