fix: faces of an extrude drawn over a face's projected edges keep an exact source (#505)
A profile traced over a face sketch's projected boundary left the extrude's caps and sides without an exact planar source, so a sketch or offset plane on the new top face was refused. The drawn curve is now the edge's source where it covers a projection. Clearer fixed-plane message; regression tests in the engine, UI harness and design corpus, plus a corpus-wide flat-face attachment check (known gaps under #491-#495). Fixes #489 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
4aaa5457b3
commit
45bc22fc30
11 files changed
+1113
-15
No files matched your search
@@ -1532,3 +1532,7 @@ mod offset_edit_guard_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "offset_plane_face_tests.rs"]
|
||||
mod offset_plane_face_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "stacked_face_tests.rs"]
|
||||
mod stacked_face_tests;
|
||||
@@ -183,6 +183,19 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
}
|
||||
None
|
||||
}
|
||||
Err(solidcraft_doc::DocError::Invalid(message))
|
||||
if matches!(
|
||||
message.as_str(),
|
||||
"this face has no exact planar source; choose a fixed plane instead"
|
||||
| "this face has no supported construction certificate; choose a fixed plane instead"
|
||||
) =>
|
||||
{
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"this face can't keep an attachment: it was merged from more than one feature's faces or reshaped by an edit. \
|
||||
Pick another face, or use a fixed plane here (fixed:true), which won't follow later changes to the face",
|
||||
));
|
||||
}
|
||||
Err(error) => return Err(bad(cmd, format!("{error}; choose a supported exact face or explicitly use fixed:true"))),
|
||||
};
|
||||
Ok(PlaneRef::Face { plane, at, name, anchor })
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Issue #489: a sketch or an offset plane on the top face of an extrude that was itself
|
||||
//! sketched on a face. Sketching a profile over the whole face draws curves on top of the face's
|
||||
//! projected boundary; the extrude's faces must still carry an exact, certified planar source.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::FaceAttachmentStatus;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
|
||||
}
|
||||
|
||||
fn profile(s: &mut Session, circle: bool, full: bool) {
|
||||
if circle {
|
||||
run(s, "sketch.circle.center", json!({"center": [20, 20], "diameter": if full { 40 } else { 16 }}));
|
||||
} else if full {
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
|
||||
} else {
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [10, 10], "p1": [30, 30]}));
|
||||
}
|
||||
}
|
||||
|
||||
/// A 40 mm base (square or round) 10 mm tall, then a second extrude 10 mm up from a sketch on
|
||||
/// its top face (or on a plane at that height), joined or as a new body.
|
||||
fn two_extrudes(circle: bool, full: bool, on_face: bool, operation: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
profile(&mut s, circle, true);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "First"}));
|
||||
let plane = if on_face { json!({"face": [20, 20, 10]}) } else { json!("XY") };
|
||||
let mut params = json!({"plane": plane, "name": "Upper"});
|
||||
if !on_face {
|
||||
params["offset"] = json!(10);
|
||||
}
|
||||
run(&mut s, "sketch.create", params);
|
||||
profile(&mut s, circle, full);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": operation, "name": "Second"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn healthy(s: &Session, sketch: &str) -> solidcraft_geom::Plane {
|
||||
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap();
|
||||
match s.placed_document().sketch_face_attachment(id, &s.model) {
|
||||
FaceAttachmentStatus::Healthy { plane, .. } => plane,
|
||||
other => panic!("{sketch}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn no_errors(s: &Session) {
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_and_offset_plane_on_the_top_face_of_a_face_sketched_extrude() {
|
||||
for operation in ["join", "new"] {
|
||||
for circle in [false, true] {
|
||||
for full in [true, false] {
|
||||
for on_face in [true, false] {
|
||||
let case = format!("{operation} circle={circle} full={full} on_face={on_face}");
|
||||
let mut s = two_extrudes(circle, full, on_face, operation);
|
||||
let top = [20., 20., 20.];
|
||||
s.execute("sketch.create", &json!({"plane": {"face": top}, "name": "Top"})).unwrap_or_else(|e| panic!("{case}: {e}"));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let plane = healthy(&s, "Top");
|
||||
assert!((plane.origin.z - 20.).abs() < 1e-9 && (plane.normal().z - 1.).abs() < 1e-9, "{case}: {plane:?}");
|
||||
s.execute("construct.plane.offset", &json!({"base": {"face": top}, "offset": 5, "name": "Above"}))
|
||||
.unwrap_or_else(|e| panic!("{case}: {e}"));
|
||||
// The second extrude's flat side is an exact source too, unless a full-face
|
||||
// Join merged it with the first extrude's side: that face belongs to neither,
|
||||
// so it offers the explicit fixed plane with a readable reason.
|
||||
if !circle {
|
||||
let side = if full { [40., 20., 15.] } else { [30., 20., 15.] };
|
||||
let params = json!({"plane": {"face": side}, "name": "Side"});
|
||||
if full && operation == "join" {
|
||||
let error = s.execute("sketch.create", ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains("merged") && error.contains("fixed:true"), "{case}: {error}");
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": side, "fixed": true}, "name": "Side"}));
|
||||
} else {
|
||||
s.execute("sketch.create", ¶ms).unwrap_or_else(|e| panic!("{case} side: {e}"));
|
||||
assert!((healthy(&s, "Side").normal().x - 1.).abs() < 1e-9, "{case}");
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
}
|
||||
s.refresh();
|
||||
no_errors(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Three extrudes, each sketched over the whole top face of the one before; editing the first
|
||||
/// one's height moves every later sketch with its face.
|
||||
#[test]
|
||||
fn three_stacked_extrudes_follow_an_edit_of_the_first() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "E1"}));
|
||||
for (k, top) in [(2, 10.), (3, 20.)] {
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 20, top]}, "name": format!("S{k}")}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "operation": "join", "name": format!("E{k}")}));
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 20, 30]}, "name": "S4"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 20], "diameter": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
s.refresh();
|
||||
no_errors(&s);
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
for (name, z) in [("S2", 10.), ("S3", 20.), ("S4", 30.)] {
|
||||
assert!((healthy(&s, name).origin.z - z).abs() < 1e-9, "{name}");
|
||||
}
|
||||
|
||||
run(&mut s, "timeline.edit", json!({"feature": "E1", "set": {"extent": {"distance": "25"}}}));
|
||||
s.refresh();
|
||||
no_errors(&s);
|
||||
for (name, z) in [("S2", 25.), ("S3", 35.), ("S4", 45.)] {
|
||||
assert!((healthy(&s, name).origin.z - z).abs() < 1e-9, "{name} after edit");
|
||||
}
|
||||
let volume: f64 = s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum();
|
||||
assert!((volume - 40. * 40. * 45.).abs() < 1e-6, "{volume}");
|
||||
|
||||
// Saved and reopened, every attachment still resolves.
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
reopened.refresh();
|
||||
no_errors(&reopened);
|
||||
assert!((healthy(&reopened, "S4").origin.z - 45.).abs() < 1e-9);
|
||||
}
|
||||
Reference in new issue
Block a user