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 <PhilippBischoff0@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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<_>>()
);
}
+6 -1
View File
@@ -9,7 +9,12 @@ geometry and dimensions.
Supported planar sources are exact Box faces, untapered New Body and Join Extrude caps and
straight sketch sides, and planar faces of immutable STEP imports. Extrude caps also identify their
outer boundary and holes by source curve IDs. Reordering profiles cannot substitute another
profile's cap. A profile drawn over a face sketch's projected boundary (a rectangle
profile's cap. A thin extrude's caps are bounded by offset curves the sketch doesn't have, so a
wall built as one region is identified by the curves it follows instead (as its outer boundary,
with no holes). That is the identity the solid caps had when the wall follows the whole profile,
so a sketch on a cap keeps its face when the extrude is made thin, or solid again. A wall along
other curves, or one of several walls apart, is another face: it is attached by its history
name below, and a sketch made on the solid cap reports a lost attachment. A profile drawn over a face sketch's projected boundary (a rectangle
traced over the whole face, or a strip whose sides lie on the face's edges) is identified by
the drawn curves: where a drawn curve and a projected one cover the same edge, the drawn curve
is the source. Two drawn curves, or two projections, over one edge remain ambiguous. Curved surfaces cannot host a planar sketch through this workflow.