Files
OneCAD/crates/engine/src/cmd/stacked_face_tests.rs
T
bherbruckandClaude Opus 5.5 6d5df4b9b3 Sketch on cut walls, chamfers, pattern copies, join-merged sides and split faces (#625)
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>
2026-10-13 05:26:29 -04:00

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, &params).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);
}