A flat face that no construction certificate names on its own now attaches by its exact history name (a new lineage face certificate): cut walls and floors, chamfer faces, each pattern or mirror copy, side walls a Join merged, and the pieces a later feature split a face into. Construction certificates still come first; a seed keeps its certificate when it is patterned. Two flush extrudes of one outline joined end to end keep their exact history in the prism boolean. Face sketch 52% -> 71%, offset plane 52% -> 71%, press-pull/extrude from face 41% -> 60% in the action matrix; baseline blessed. Fixes #491, fixes #492, fixes #493, fixes #557. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
6.2 KiB
Rust
127 lines
6.2 KiB
Rust
//! 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 attaches too, also where a full-face Join
|
|
// merged it with the first extrude's side (#491): that face is known by its
|
|
// exact history name.
|
|
if !circle {
|
|
let side = if full { [40., 20., 15.] } else { [30., 20., 15.] };
|
|
s.execute("sketch.create", &json!({"plane": {"face": side}, "name": "Side"})).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);
|
|
}
|