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 <noreply@anthropic.com>
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}")))
}
/// The plane of a sketch feature.
fn sketch_plane(doc: &Document, id: u64) -> Result<Plane> {
/// 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<Plane> {
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<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.
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<String> {
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<Value
sk.points.get(pi).map(|q| q.id.clone()).unwrap_or_default()
} 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 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 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)?;
@@ -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.
pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> {
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<O
#[cfg(test)]
#[path = "sketch_project_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "sketch_model_plane_tests.rs"]
mod model_plane_tests;