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:
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user