diff --git a/crates/doc/src/config_certificates.rs b/crates/doc/src/config_certificates.rs index 25e9b94d..a5902e13 100644 --- a/crates/doc/src/config_certificates.rs +++ b/crates/doc/src/config_certificates.rs @@ -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 { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 263c8233..e468e913 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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 = 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() diff --git a/crates/engine/src/cmd/thin_extrude_tests.rs b/crates/engine/src/cmd/thin_extrude_tests.rs index f2086497..94f27e98 100644 --- a/crates/engine/src/cmd/thin_extrude_tests.rs +++ b/crates/engine/src/cmd/thin_extrude_tests.rs @@ -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) { + 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) -> 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::>() + ); +} diff --git a/docs/face-attachments.md b/docs/face-attachments.md index 1a6a7d8a..50f39112 100644 --- a/docs/face-attachments.md +++ b/docs/face-attachments.md @@ -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.