From 9ab25cfd4a18aa03d1e23ba3d2318fedce80bd66 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:27:28 +0200 Subject: [PATCH] fix: sketches on planes built from the model can project, intersect, include and hold text (#459) Project, Intersect, Include 3D Geometry and Text resolved the active sketch's plane without the model, so on a construction plane that is built from geometry (perpendicular to a plane through a sketch line, through three points, midway between two faces, tangent, along a path) they stopped with "this plane is built from the model: evaluate first". Evaluation resolves the same plane against the model before the sketch, in its component's frame; the sketch commands now do the same, in one place, for adding a link, refreshing the links on an edit, snapping a point onto an edge and the internal face profiles. Tests: a sketch on a plane through a sloped sketch line projects a point and a line of that sketch, follows a parameter that tilts the plane (also after reopen and after another edit), holds text, intersects a body and includes an edge; the same on a three-point plane and a midplane. Co-authored-by: Claude Opus 5.5 --- .../src/cmd/sketch_model_plane_tests.rs | 144 ++++++++++++++++++ crates/engine/src/cmd/sketch_project.rs | 58 ++++--- docs/book/src/guide/sketching.md | 4 + 3 files changed, 183 insertions(+), 23 deletions(-) create mode 100644 crates/engine/src/cmd/sketch_model_plane_tests.rs diff --git a/crates/engine/src/cmd/sketch_model_plane_tests.rs b/crates/engine/src/cmd/sketch_model_plane_tests.rs new file mode 100644 index 00000000..d7023584 --- /dev/null +++ b/crates/engine/src/cmd/sketch_model_plane_tests.rs @@ -0,0 +1,144 @@ +//! Sketches on construction planes built from the model (perpendicular to a plane through a +//! sketch line, through three points, a midplane…): Project, Intersect, Include 3D Geometry and +//! Text resolve the sketch's plane against the model before the sketch, as evaluation does, and +//! the links follow when a parameter moves the plane. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec3; + +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}"), + } +} + +/// Sketch `A` on YZ with the line `s` from the origin to (run 100, rise `rise`), and the plane +/// `P` through that line, square to YZ. The plane's origin is the line's start and its x axis +/// runs along the line. +fn sloped_plane(s: &mut Session) { + run(s, "parameters.add", json!({"name": "rise", "expression": "60 mm"})); + run(s, "sketch.create", json!({"plane": "YZ", "name": "A"})); + run(s, "sketch.line", json!({"points": [[0, 0], [100, 60]], "ids": ["s"]})); + run(s, "sketch.dimension", json!({"entities": ["s.start", "s.end"], "type": "horizontal", "value": "100 mm"})); + run(s, "sketch.dimension", json!({"entities": ["s.start", "s.end"], "type": "vertical", "value": "rise"})); + run(s, "sketch.finish", json!({})); + run(s, "construct.plane.perpendicular", json!({"plane": "YZ", "line": {"sketch": "A", "curve": "s"}, "name": "P"})); +} + +/// World position of point `point` of sketch `sketch`. +fn world_point(s: &Session, sketch: &str, point: &str) -> Vec3 { + let id = s.doc.find_feature(sketch).unwrap().id; + let st = s.model.state(); + let ss = st.sketch(id).unwrap(); + let i = ss.sketch.resolve_point(point).unwrap_or_else(|| panic!("no point {point} in {sketch}")); + ss.plane.to_world(ss.sketch.point(i).unwrap()) +} + +fn no_trouble(s: &Session) { + for r in &s.model.results { + assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning); + } +} + +#[test] +fn project_onto_a_plane_built_from_a_sketch_line_follows_the_parameter() { + let mut s = Session::default(); + sloped_plane(&mut s); + run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"})); + let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "A", "point": "s.end"}, {"sketch": "A", "curve": "s"}]})); + let point = r["links"][0]["points"][0].as_str().unwrap().to_string(); + let curve = r["links"][1]["curves"][0].as_str().unwrap().to_string(); + // Geometry drawn against the projection keeps working: a circle on the projected point. + run(&mut s, "sketch.circle.center", json!({"center": point, "radius": 4})); + run(&mut s, "sketch.finish", json!({})); + no_trouble(&s); + let len = (100.0f64 * 100.0 + 60.0 * 60.0).sqrt(); + let end = world_point(&s, "A", "s.end"); + assert!(world_point(&s, "B", &point).dist(end) < 1e-9, "{:?} {end:?}", world_point(&s, "B", &point)); + let ends = [world_point(&s, "B", &format!("{curve}.start")), world_point(&s, "B", &format!("{curve}.end"))]; + assert!(ends.iter().any(|q| q.dist(Vec3::ZERO) < 1e-9) && ends.iter().any(|q| q.dist(end) < 1e-9), "{ends:?}"); + { + let st = s.model.state(); + let b = st.sketch(s.doc.find_feature("B").unwrap().id).unwrap(); + let q = b.sketch.point(b.sketch.resolve_point(&point).unwrap()).unwrap(); + assert!((q.x - len).abs() < 1e-9 && q.y.abs() < 1e-9, "the line's end lies on the plane's x axis: {q:?}"); + } + // The parameter tilts the plane; the projection and the circle on it follow, in the file too. + run(&mut s, "parameters.change", json!({"name": "rise", "expression": "25 mm"})); + let check = |s: &Session| { + no_trouble(s); + let end = world_point(s, "A", "s.end"); + assert!((end.z - 25.0).abs() < 1e-9 && (end.y - 100.0).abs() < 1e-9, "{end:?}"); + assert!(world_point(s, "B", &point).dist(end) < 1e-9); + assert!(world_point(s, "B", "c1.center").dist(end) < 1e-9); + }; + check(&s); + check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap())); + // Editing the sketch again after the change refreshes its links on the moved plane. + run(&mut s, "sketch.edit", json!({"sketch": "B"})); + run(&mut s, "sketch.point", json!({"point": [10, 10]})); + run(&mut s, "sketch.finish", json!({})); + check(&s); +} + +#[test] +fn text_on_a_plane_built_from_a_sketch_line() { + let mut s = Session::default(); + sloped_plane(&mut s); + run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"})); + let r = run(&mut s, "sketch.text", json!({"text": "AB", "at": [10, 10], "height": 5})); + assert!(!r["curves"].as_array().unwrap().is_empty(), "{r}"); + run(&mut s, "sketch.finish", json!({})); + no_trouble(&s); + let st = s.model.state(); + let b = st.sketch(s.doc.find_feature("B").unwrap().id).unwrap(); + assert!(!b.profiles.is_empty()); +} + +#[test] +fn intersect_and_include_on_planes_built_from_the_model() { + let mut s = Session::default(); + sloped_plane(&mut s); + run(&mut s, "solid.box", json!({"corner": [-10, 20, -5], "length": 20, "width": 30, "height": 80, "body_name": "Post"})); + // The plane through three sketch points and the midplane of two faces are built from the model too. + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "T"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 30]], "ids": ["u", "v"]})); + run(&mut s, "sketch.finish", json!({})); + run( + &mut s, + "construct.plane.three_points", + json!({"a": {"sketch": "T", "point": "u.start"}, "b": {"sketch": "T", "point": "u.end"}, "c": {"sketch": "T", "point": "v.end"}, "name": "Three"}), + ); + run(&mut s, "construct.plane.midplane", json!({"a": {"face": [0, 20, 30]}, "b": {"face": [0, 50, 30]}, "name": "Mid"})); + + run(&mut s, "sketch.create", json!({"plane": "P", "name": "B"})); + // The slope cuts the post: a closed section of four lines. + let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Post"}]})); + assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}"); + // An edge of the post, brought onto the slope. + let r = run(&mut s, "sketch.include_3d", json!({"refs": [{"edge": [-10, 20, 30], "body": "Post"}]})); + assert!(r["links"][0]["link"].is_string(), "{r}"); + run(&mut s, "sketch.finish", json!({})); + + run(&mut s, "sketch.create", json!({"plane": "Three", "name": "C"})); + let r = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "T", "point": "v.end"}]})); + let p = r["links"][0]["points"][0].as_str().unwrap().to_string(); + run(&mut s, "sketch.finish", json!({})); + assert!(world_point(&s, "C", &p).dist(Vec3::new(40.0, 30.0, 0.0)) < 1e-9); + + run(&mut s, "sketch.create", json!({"plane": "Mid", "name": "D"})); + let r = run(&mut s, "sketch.intersect", json!({"refs": [{"body": "Post"}]})); + assert_eq!(r["links"][0]["curves"].as_array().unwrap().len(), 4, "{r}"); + run(&mut s, "sketch.finish", json!({})); + no_trouble(&s); + // The midplane sits half way between the post's two faces (y = 35). + let st = s.model.state(); + let d = st.sketch(s.doc.find_feature("D").unwrap().id).unwrap(); + assert!(d.sketch.points.iter().any(|q| q.link.is_some())); + for q in d.sketch.points.iter().filter(|q| q.link.is_some()) { + assert!((d.plane.to_world(q.pos).y - 35.0).abs() < 1e-9); + } +} diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index 49f8baf1..723998b7 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -118,15 +118,36 @@ fn source(s: &Session, v: &Value, cmd: &str) -> Result { Err(bad(cmd, format!("cannot project {v}"))) } -/// The plane of a sketch feature. -fn sketch_plane(doc: &Document, id: u64) -> Result { +/// The plane of a sketch feature, resolved against `st`: the model before the sketch as its +/// component sees it, which is what evaluation resolves it against. A construction plane built +/// from the model (through a sketch line, three points, between two faces…) exists only there. +fn sketch_plane(doc: &Document, id: u64, st: &solidcraft_doc::ModelState) -> Result { let (vals, _) = doc.param_values(); match doc.feature(id).map(|f| &f.kind) { - Some(FeatureKind::Sketch { plane, .. }) => Ok(doc.resolve_plane(&vals, plane, 0)?), + Some(FeatureKind::Sketch { plane, .. }) => Ok(doc.resolve_plane_in(&vals, plane, 0, Some(st))?), _ => Err(EngineError::Other(format!("feature {id} is not a sketch"))), } } +/// The model before sketch `id` in the frame its links and its plane are resolved in: the +/// attached face's frame for a sketch on a face, else its component's. +fn state_for_sketch<'a>( + s: &Session, + doc: &Document, + id: u64, + st: &'a solidcraft_doc::ModelState, +) -> std::result::Result, solidcraft_doc::DocError> { + let comp = doc.feature(id).map(|f| f.component).unwrap_or(0); + let attachment = doc.feature(id).and_then(|feature| match &feature.kind { + solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane), + _ => None, + }); + match attachment { + Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(doc, st, anchor, comp), + None => Ok(solidcraft_doc::state_in_frame_at(doc, st, comp, owner_frame(s, doc, comp))), + } +} + /// Keep candidate feature edits while overlaying uncommitted occurrence placements. fn placed_candidate<'a>(s: &Session, doc: &'a Document) -> std::borrow::Cow<'a, Document> { if s.pending_moves.is_empty() { @@ -152,21 +173,16 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> return Vec::new(); } let doc = placed_candidate(s, doc); - let Ok(plane) = sketch_plane(&doc, id) else { return Vec::new() }; let (vals, _) = doc.param_values(); let st = s.model.state_before(id); - let comp = doc.feature(id).map(|f| f.component).unwrap_or(0); let attachment = doc.feature(id).and_then(|feature| match &feature.kind { solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane), _ => None, }); - let st = match attachment { - Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp), - None => Ok(solidcraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))), - }; - let Ok(st) = st else { + let Ok(st) = state_for_sketch(s, &doc, id, &st) else { return vec!["lost face attachment; reattach explicitly".into()]; }; + let Ok(plane) = sketch_plane(&doc, id, &st) else { return Vec::new() }; 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), @@ -176,20 +192,11 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) -> /// Add a link for `src` to sketch `id` in `doc` (not stored in the session). Returns the link id. pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result { let doc = placed_candidate(s, doc); - let plane = sketch_plane(&doc, id)?; let (vals, _) = doc.param_values(); let st = s.model.state_before(id); // The model as the sketch's component sees it. - let comp = doc.feature(id).map(|f| f.component).unwrap_or(0); - let attachment = doc.feature(id).and_then(|feature| match &feature.kind { - solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane), - _ => None, - }); - let st = match attachment { - Some(anchor) => solidcraft_doc::state_in_face_anchor_frame(&doc, &st, anchor, comp), - None => Ok(solidcraft_doc::state_in_frame_at(&doc, &st, comp, owner_frame(s, &doc, comp))), - }; - let st = st?; + let st = state_for_sketch(s, &doc, id, &st)?; + let plane = sketch_plane(&doc, id, &st)?; let r = resolve(&doc, &vals, &st, &plane, kind, &src)?; let name = if let LinkSource::Face { body, at } = &r.source { st.body(body).and_then(|body| { @@ -454,7 +461,8 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result Result Result { let mut doc = (*s.doc).clone(); - let plane = sketch_plane(&doc, id)?; let raw = s.model.state_before(id); let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0); let state = solidcraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component)); + let plane = sketch_plane(&doc, id, &state)?; let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?; let face_name = state.body(body).and_then(|body| solidcraft_doc::naming::face_names(body).get(index).cloned()); let mut sk = doc.sketch(id)?.clone(); @@ -538,3 +546,7 @@ pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result