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 <[email protected]>
This commit is contained in:
NOWN-DEVandClaude Opus 5.5 authored and GitHub committed 2026-10-12 21:27:28 -04:00
1 parent 1ae6e9beb4
commit 9ab25cfd4a
3 files changed
+183 -23

No files matched your search

@@ -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);
}
}
+35 -23
View File
@@ -118,15 +118,36 @@ fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
Err(bad(cmd, format!("cannot project {v}"))) Err(bad(cmd, format!("cannot project {v}")))
} }
/// The plane of a sketch feature. /// The plane of a sketch feature, resolved against `st`: the model before the sketch as its
fn sketch_plane(doc: &Document, id: u64) -> Result<Plane> { /// 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<Plane> {
let (vals, _) = doc.param_values(); let (vals, _) = doc.param_values();
match doc.feature(id).map(|f| &f.kind) { 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"))), _ => 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<std::borrow::Cow<'a, solidcraft_doc::ModelState>, 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. /// Keep candidate feature edits while overlaying uncommitted occurrence placements.
fn placed_candidate<'a>(s: &Session, doc: &'a Document) -> std::borrow::Cow<'a, Document> { fn placed_candidate<'a>(s: &Session, doc: &'a Document) -> std::borrow::Cow<'a, Document> {
if s.pending_moves.is_empty() { 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(); return Vec::new();
} }
let doc = placed_candidate(s, doc); let doc = placed_candidate(s, doc);
let Ok(plane) = sketch_plane(&doc, id) else { return Vec::new() };
let (vals, _) = doc.param_values(); let (vals, _) = doc.param_values();
let st = s.model.state_before(id); 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 { let attachment = doc.feature(id).and_then(|feature| match &feature.kind {
solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane), solidcraft_doc::FeatureKind::Sketch { plane, .. } => super::sketch::face::anchor(plane),
_ => None, _ => None,
}); });
let st = match attachment { let Ok(st) = state_for_sketch(s, &doc, id, &st) else {
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 {
return vec!["lost face attachment; reattach explicitly".into()]; return vec!["lost face attachment; reattach explicitly".into()];
}; };
let Ok(plane) = sketch_plane(&doc, id, &st) else { return Vec::new() };
match attachment { match attachment {
Some(anchor) => solidcraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor), 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), 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. /// 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<String> { pub(super) fn add_link(s: &Session, doc: &Document, id: u64, sk: &mut Sketch, kind: LinkKind, src: LinkSource) -> Result<String> {
let doc = placed_candidate(s, doc); let doc = placed_candidate(s, doc);
let plane = sketch_plane(&doc, id)?;
let (vals, _) = doc.param_values(); let (vals, _) = doc.param_values();
let st = s.model.state_before(id); let st = s.model.state_before(id);
// The model as the sketch's component sees it. // The model as the sketch's component sees it.
let comp = doc.feature(id).map(|f| f.component).unwrap_or(0); let st = state_for_sketch(s, &doc, id, &st)?;
let attachment = doc.feature(id).and_then(|feature| match &feature.kind { let plane = sketch_plane(&doc, id, &st)?;
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 r = resolve(&doc, &vals, &st, &plane, kind, &src)?; let r = resolve(&doc, &vals, &st, &plane, kind, &src)?;
let name = if let LinkSource::Face { body, at } = &r.source { let name = if let LinkSource::Face { body, at } = &r.source {
st.body(body).and_then(|body| { st.body(body).and_then(|body| {
@@ -454,7 +461,8 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default() sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default()
} else if let Some(at) = edge { } else if let Some(at) = edge {
let l = add_link(s, d, id, sk, LinkKind::Project, LinkSource::Edge { body, at }).map_err(|e| bad(cmd, format!("edge: {e}")))?; let l = add_link(s, d, id, sk, LinkKind::Project, LinkSource::Edge { body, at }).map_err(|e| bad(cmd, format!("edge: {e}")))?;
let plane = sketch_plane(d, id)?; let before = s.model.state_before(id);
let plane = sketch_plane(d, id, &*state_for_sketch(s, d, id, &before)?)?;
let q = plane.to_local(at); let q = plane.to_local(at);
let ci = sk.link_curves(&l).first().copied().ok_or_else(|| bad(cmd, "the edge projects to a point"))?; let ci = sk.link_curves(&l).first().copied().ok_or_else(|| bad(cmd, "the edge projects to a point"))?;
let pi = sk.add_point(q, None)?; let pi = sk.add_point(q, None)?;
@@ -490,10 +498,10 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
/// edits, but cannot provide the construction certificate required by public attachments. /// edits, but cannot provide the construction certificate required by public attachments.
pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> { pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> {
let mut doc = (*s.doc).clone(); let mut doc = (*s.doc).clone();
let plane = sketch_plane(&doc, id)?;
let raw = s.model.state_before(id); let raw = s.model.state_before(id);
let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0); 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 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 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 face_name = state.body(body).and_then(|body| solidcraft_doc::naming::face_names(body).get(index).cloned());
let mut sk = doc.sketch(id)?.clone(); 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<O
#[cfg(test)] #[cfg(test)]
#[path = "sketch_project_tests.rs"] #[path = "sketch_project_tests.rs"]
mod tests; mod tests;
#[cfg(test)]
#[path = "sketch_model_plane_tests.rs"]
mod model_plane_tests;
+4
View File
@@ -39,6 +39,10 @@ is projected in one undo step. A rejected projection keeps the selection for cor
an empty or mixed selection starts the ordinary picking tool. External sketch selections an empty or mixed selection starts the ordinary picking tool. External sketch selections
retain their source sketch, even when the active sketch uses the same point or curve names. retain their source sketch, even when the active sketch uses the same point or curve names.
A sketch on a construction plane built from the model (through a sketch line, through three
points, midway between two faces…) projects, intersects, includes and holds text like a sketch
on any other plane, and its links follow when a parameter moves that plane.
Run from a script or the control channel, every command that adds sketch geometry returns the Run from a script or the control channel, every command that adds sketch geometry returns the
ids of what it added (`created: {points, curves, constraints, dimensions}`). Ids are stable and ids of what it added (`created: {points, curves, constraints, dimensions}`). Ids are stable and
never reused within a sketch, even after a delete, so a script can dimension or constrain the new never reused within a sketch, even after a delete, so a script can dimension or constrain the new