A sketch on an extrude's cap keeps its face when the extrude is made thin (#1532)

Thin extrude caps are certified by the curves the wall follows, so a sketch attached to a cap survives switching between solid and thin (#1519).

Co-Authored-By: Philipp Bischoff <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-15 18:55:17 -04:00
1 parent b400958c3e
commit 7160ff7beb
4 files changed
+149 -2

No files matched your search

+29
View File
@@ -148,6 +148,35 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solidcraft_k
Some(bindings)
}
/// Cap signatures for a thin extrude (#1519). A thin wall's caps are bounded by offset curves
/// the sketch doesn't have, so [`extrude_cap_bindings`] can never certify them. A wall built
/// from one region is signed by the curves it follows instead: the cap of this extrude whose
/// wall runs along them. That is the signature its solid caps had when the wall follows the
/// whole profile (a rectangle's four lines), so a sketch attached to a solid cap keeps its face
/// when the extrude is made thin, and the other way round. Several regions (walls apart, or one
/// meeting itself) are left uncertified: they keep their history names.
pub(crate) fn thin_extrude_cap_bindings(curves: &[String], regions: usize, bodies: &[solidcraft_kernel::Body]) -> CapBindings {
use solidcraft_kernel::FaceSource;
let mut outer = curves.to_vec();
outer.sort();
outer.dedup();
let signed = regions == 1 && bodies.len() == 1 && valid_signature(&outer, &[]);
bodies
.iter()
.map(|body| {
let sources: Vec<_> = body.face_sources().into_iter().flatten().collect();
let ends =
|high: bool| sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeCap { high: h, .. }) if *h == high)).count();
// One cap at each end, or none of them is certified.
let unique = ends(true) <= 1 && ends(false) <= 1;
sources
.iter()
.map(|source| (signed && unique && matches!(source, Some(FaceSource::ExtrudeCap { .. }))).then(|| (outer.clone(), Vec::new())))
.collect()
})
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum FaceCertificate {
+4 -1
View File
@@ -2938,7 +2938,10 @@ fn feature_tools_with_constructor(
// certified as a new body's are (see `Body::with_preserved_union_planar_sources`).
if matches!(operation, Operation::NewBody | Operation::Join) && extent.taper.is_none() {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &bodies, 10_000_000);
let cap_bindings = match thin {
Some(thin) => Some(crate::config::thin_extrude_cap_bindings(&thin.curves, regions.len(), &bodies)),
None => crate::config::extrude_cap_bindings(&ss, &bodies, 10_000_000),
};
let source_curves = cap_bindings.as_ref().map(|_| crate::config::ExactSketchCurves::new(&ss));
tools = tools
.into_iter()
+110
View File
@@ -172,3 +172,113 @@ fn hostile_thin_params_never_panic() {
}
}
}
// #1519: sketches on a thin extrude's caps. A thin wall's caps are bounded by offset curves the
// sketch doesn't have, so they are certified by the curves the wall follows: the signature the
// solid caps had when the wall follows the whole profile.
fn attachment(s: &Session, sketch: &str) -> solidcraft_doc::FaceAttachmentStatus {
let id = s.doc.find_feature(sketch).map(|f| f.id).unwrap_or_default();
s.placed_document().sketch_face_attachment(id, &s.model)
}
fn attached(s: &Session, sketch: &str) -> (solidcraft_geom::Plane, Option<solidcraft_doc::config::FaceCertificate>) {
match attachment(s, sketch) {
solidcraft_doc::FaceAttachmentStatus::Healthy { plane, target, .. } => (plane, target.certificate),
other => panic!("{sketch}: {other:?}"),
}
}
fn same_plane(a: &solidcraft_geom::Plane, b: &solidcraft_geom::Plane) {
let near = |p: solidcraft_geom::Vec3, q: solidcraft_geom::Vec3| (p - q).len() < 1e-9;
assert!(near(a.origin, b.origin) && near(a.x, b.x) && near(a.y, b.y), "{a:?} != {b:?}");
}
fn sketch_on(s: &mut Session, at: [f64; 3], name: &str) {
run(s, "sketch.create", json!({"plane": {"face": at}, "name": name}));
run(s, "sketch.finish", json!({}));
}
fn is_extrude_cap(certificate: &Option<solidcraft_doc::config::FaceCertificate>) -> bool {
matches!(certificate, Some(solidcraft_doc::config::FaceCertificate::ExtrudeCap { .. }))
}
#[test]
fn sketches_on_a_solid_extrudes_caps_keep_their_faces_when_it_is_made_thin_and_solid_again() {
for side in ["center", "inside", "outside"] {
let mut s = Session::default();
let sides = rectangle(&mut s);
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "distance": 10, "name": "Wall"}));
// On the rim of the wall whichever side it lies on (the rectangle starts at (5, 5)).
sketch_on(&mut s, [5.5, 5.5, 10.0], "Top");
sketch_on(&mut s, [5.5, 5.5, 0.0], "Bottom");
let (top, _) = attached(&s, "Top");
let (bottom, _) = attached(&s, "Bottom");
run(&mut s, "timeline.edit", json!({"feature": "Wall", "set": {"thin": {"curves": sides, "thickness": "1", "side": side}}}));
no_errors(&s, side);
let (thin_top, certificate) = attached(&s, "Top");
assert!(is_extrude_cap(&certificate), "{side}: {certificate:?}");
same_plane(&thin_top, &top);
same_plane(&attached(&s, "Bottom").0, &bottom);
// The sketch follows the thin wall's height, and keeps its face through a save.
run(&mut s, "timeline.edit", json!({"feature": "Wall", "set": {"extent": {"distance": "25"}}}));
assert!((attached(&s, "Top").0.origin.z - 25.0).abs() < 1e-9, "{side}");
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
reopened.refresh();
no_errors(&reopened, side);
assert!((attached(&reopened, "Top").0.origin.z - 25.0).abs() < 1e-9, "{side}");
// And solid again.
run(&mut s, "timeline.edit", json!({"feature": "Wall", "set": {"thin": null, "extent": {"distance": "10"}}}));
no_errors(&s, side);
same_plane(&attached(&s, "Top").0, &top);
same_plane(&attached(&s, "Bottom").0, &bottom);
}
}
#[test]
fn a_sketch_on_a_thin_cap_is_certified_and_keeps_its_face_when_the_extrude_is_made_solid() {
let mut s = Session::default();
rectangle(&mut s);
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "side": "inside", "distance": 10, "name": "Wall"}));
sketch_on(&mut s, [6.0, 6.0, 10.0], "Top");
let (top, certificate) = attached(&s, "Top");
assert!(is_extrude_cap(&certificate), "{certificate:?}");
run(&mut s, "timeline.edit", json!({"feature": "Wall", "set": {"thin": null}}));
no_errors(&s, "solid");
same_plane(&attached(&s, "Top").0, &top);
}
#[test]
fn a_thin_wall_along_part_of_the_profile_does_not_take_over_its_cap() {
// A wall along two sides is another wall than the rectangle's: the sketch reports its lost
// face instead of moving onto the new one.
let mut s = Session::default();
let sides = rectangle(&mut s);
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "distance": 10, "name": "Wall"}));
sketch_on(&mut s, [6.0, 6.0, 10.0], "Top");
let _ = s.execute("timeline.edit", &json!({"feature": "Wall", "set": {"thin": {"curves": [&sides[0], &sides[1]], "thickness": "2"}}}));
assert!(matches!(attachment(&s, "Top"), solidcraft_doc::FaceAttachmentStatus::Lost { .. }), "{:?}", attachment(&s, "Top"));
}
#[test]
fn the_caps_of_walls_apart_attach_by_their_history_names() {
// Two rectangles make two walls: their caps aren't certified (one signature would name
// both), so a sketch on one attaches by its exact history name, as before.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20, 0], "p1": [30, 10]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 1, "side": "inside", "distance": 5}));
sketch_on(&mut s, [0.5, 0.5, 5.0], "Top");
let (_, certificate) = attached(&s, "Top");
assert!(matches!(certificate, Some(solidcraft_doc::config::FaceCertificate::Lineage)), "{certificate:?}");
}
fn no_errors(s: &Session, context: &str) {
assert!(
s.model.results.iter().all(|r| r.error.is_none()),
"{context}: {:?}",
s.model.results.iter().filter_map(|r| r.error.as_ref()).collect::<Vec<_>>()
);
}