From af0440aaab706bf2dabed26e1799f69b95a61f13 Mon Sep 17 00:00:00 2001
From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com>
Date: Tue, 13 Oct 2026 03:36:58 +0200
Subject: [PATCH] feat: sketches on a sheet metal face attach to its panel
(#474)
A sketch on a flat face of a sheet metal body was refused ("this face has no exact planar
source"), so the only way was a plane frozen in space. When the sheet changes (a flange's
angle, the thickness), the frozen sketch stays behind: a cut from it fails on the slanted
flange or leaves a blind pocket in the thicker sheet.
`sketch.create` with a face of a sheet metal body now attaches the sketch to the panel the
face belongs to. It keeps the face's name, `sheet:
::` (the panel is
the base or a flange's feature tag), and takes the panel's exact plane from its flat region
and fold each time the model is evaluated, so the sketch follows the sheet through parameter
changes, unfold and refold. Its coordinates are the flat pattern's (the bottom face's y
mirrored). A face that is gone is reported as a lost attachment, never replaced by the
nearest face. The attachment follows a renamed or copied body, shows as `sheet_face` in the
command result and as its own status in Redefine Sketch Plane. `fixed: true` still freezes.
Co-authored-by: Claude Opus 5.5
---
ROADMAP.md | 2 +
crates/doc/src/clipboard.rs | 12 +
crates/doc/src/eval.rs | 36 +++
crates/doc/src/face_anchor.rs | 11 +-
crates/doc/src/sheet.rs | 191 ++++++++++++++++
crates/engine/src/cmd/browser.rs | 11 +-
crates/engine/src/cmd/sheet_tests.rs | 206 ++++++++++++++++++
crates/engine/src/cmd/sketch.rs | 4 +-
crates/engine/src/cmd/sketch_face.rs | 23 ++
crates/ui-egui/src/sketch_attachment_ui.rs | 4 +
.../tests/scenarios/s22_sheet_metal_fold.json | 11 +-
docs/book/src/guide/sheet-metal.md | 12 +
docs/face-attachments.md | 5 +
13 files changed, 515 insertions(+), 13 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 0ecfa01a..7e3e0ec2 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -321,6 +321,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
exact holes and retained islands, including the union of multiple selected profiles, and
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
tapered or bend-crossing machining refuses atomically instead of restoring material later.
+ Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
+ flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs
index 63d4e1a9..2e0f7d0f 100644
--- a/crates/doc/src/clipboard.rs
+++ b/crates/doc/src/clipboard.rs
@@ -84,6 +84,10 @@ pub fn remap_topology(name: &str, ids: &BTreeMap, bodies: &BTreeMap::`, the panel a flange's feature token.
+ if let Some((body, face)) = crate::sheet::split_sheet_face_name(name) {
+ return crate::sheet::sheet_face_name(bodies.get(body).map_or(body, String::as_str), &remap_topology(face, ids, bodies));
+ }
// Imported names belong to the complete canonical body prefix, even when that prefix
// happens to contain text resembling a feature token.
if let Some((body, suffix)) = name.rsplit_once(":face:")
@@ -464,6 +468,14 @@ mod clone_tests {
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
}
#[test]
+ fn a_sheet_face_follows_its_body_and_its_flange() {
+ let ids = BTreeMap::from([(1, 2), (10, 20)]);
+ let bodies = BTreeMap::from([("Body1".into(), "Body1 (copy)".into())]);
+ assert_eq!(remap_topology("sheet:Body1:F1.0:top", &ids, &bodies), "sheet:Body1 (copy):F2.0:top");
+ assert_eq!(remap_topology("sheet:Body1:base:bottom", &ids, &bodies), "sheet:Body1 (copy):base:bottom");
+ assert_eq!(remap_topology("sheet:Other:F10.1:top", &ids, &bodies), "sheet:Other:F20.1:top");
+ }
+ #[test]
fn parameter_remap_is_simultaneous_and_token_scoped() {
let map = BTreeMap::from([("d1".into(), "d2".into()), ("d2".into(), "d3".into())]);
assert_eq!(clone_expression("d1 + d2 + d10 + 2 mm", &map), "d2 + d3 + d10 + 2 mm");
diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs
index 88362813..ed870374 100644
--- a/crates/doc/src/eval.rs
+++ b/crates/doc/src/eval.rs
@@ -3074,6 +3074,38 @@ fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec {
out
}
+/// `plane` with the sheet face it names (see `sheet::plane_sheet_face`) replaced by that face's current
+/// plane in the frame of component `comp`, or an error when the face is gone: a lost
+/// attachment is reported, never moved to the nearest face.
+pub(crate) fn sheet_face_plane_in_frame(doc: &Document, st: &ModelState, comp: u64, plane: &PlaneRef) -> Result {
+ Ok(match plane {
+ PlaneRef::Face { name: Some(name), anchor: None, .. } if crate::sheet::plane_sheet_face(plane).is_some() => {
+ let lost = || DocError::Invalid(format!("lost face attachment: the sheet face `{name}` no longer exists; reattach the sketch to a face"));
+ let (body, face) = crate::sheet::sheet_face_plane(st, name).ok_or_else(lost)?;
+ if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
+ return Ok(PlaneRef::Custom { plane: face });
+ }
+ // The body's own coordinates into the component's frame, as `state_in_frame` places its bodies.
+ let source = st.body(&body).ok_or_else(lost)?;
+ let inverse = crate::mat_inverse(&doc.component_transform(comp)).ok_or_else(lost)?;
+ let m = crate::mat_mul(&inverse, &doc.component_transform(doc.model_body_component(source)));
+ let m = doc.model_body_offset(source).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m);
+ let placed = Plane::new(crate::apply_point(&m, face.origin), crate::apply_vector(&m, face.x), crate::apply_vector(&m, face.y));
+ PlaneRef::Custom { plane: placed.ok_or_else(lost)? }
+ }
+ PlaneRef::Offset { base, distance } => {
+ PlaneRef::Offset { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), distance: distance.clone() }
+ }
+ PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => PlaneRef::AtAngle {
+ base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?),
+ axis_origin: *axis_origin,
+ axis_dir: *axis_dir,
+ angle: angle.clone(),
+ },
+ other => other.clone(),
+ })
+}
+
/// The plane of the body face a sketch sits on, found again: among planar faces with the
/// picked plane's normal, the one nearest `at`. The picked frame moves along its normal onto it.
fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) -> (Plane, Option) {
@@ -3205,6 +3237,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st:
};
let (plane, face_warning) = match (anchor, plane) {
(Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None),
+ // A flat face of a sheet: the panel's exact plane, from its flat region and fold.
+ (None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
+ (doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
+ }
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref()),
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
diff --git a/crates/doc/src/face_anchor.rs b/crates/doc/src/face_anchor.rs
index 39961fd6..c60d8ed5 100644
--- a/crates/doc/src/face_anchor.rs
+++ b/crates/doc/src/face_anchor.rs
@@ -117,11 +117,13 @@ impl Document {
}
}
-/// All status planes are world-space. A lost plane is a repair guide only.
+/// All status planes are world-space. A lost plane is a repair guide only. `Sheet` is a sketch on
+/// a flat face of a sheet metal body, attached by the name of its panel (`sheet::sheet_face_name`).
#[derive(Clone, Debug)]
pub enum FaceAttachmentStatus {
Fixed { plane: Option },
Legacy { plane: Option },
+ Sheet { plane: Option },
Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec },
Lost { message: String, repair_plane: Option },
}
@@ -210,6 +212,13 @@ impl Document {
self.resolve_plane(&vals, plane, 0).ok()
});
let current = world.and_then(|world| current.and_then(|plane| map_plane(&world, &plane).ok()));
+ if crate::sheet::plane_sheet_face(plane).is_some() {
+ let state = model.state_before(feature.id);
+ return match crate::eval::sheet_face_plane_in_frame(self, &state, feature.component, plane) {
+ Ok(_) => FaceAttachmentStatus::Sheet { plane: current },
+ Err(error) => FaceAttachmentStatus::Lost { message: error.to_string(), repair_plane: current },
+ };
+ }
return if has_face_reference(plane) {
FaceAttachmentStatus::Legacy { plane: current }
} else {
diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs
index cb64abfb..f554975c 100644
--- a/crates/doc/src/sheet.rs
+++ b/crates/doc/src/sheet.rs
@@ -2025,6 +2025,72 @@ impl SheetBody {
best.map(|(_, q)| q)
}
+ /// The persistent name of a panel: `base`, a flange's tag, or `#` for an untagged
+ /// flange; `flat` for the whole sheet while it is unfolded.
+ fn panel_name(&self, pi: Option) -> String {
+ match pi {
+ _ if self.flat => "flat".into(),
+ None => "base".into(),
+ Some(i) => match self.flanges.get(i) {
+ Some(f) if !f.tag.is_empty() => f.tag.clone(),
+ _ => format!("#{i}"),
+ },
+ }
+ }
+
+ /// The plane of a flat face of a panel (`top`: the face at the thickness, else the bottom
+ /// one), facing out of the sheet. Its coordinates are the flat pattern's (the bottom
+ /// face's mirrored in y), so a sketch on it measures as the flat pattern does.
+ fn panel_face_plane(&self, world: &Mat, top: bool) -> Option {
+ let (x, y) = (apply_vector(world, Vec3::X), apply_vector(world, Vec3::Y));
+ Plane::new(apply_point(world, Vec3::new(0.0, 0.0, if top { self.t } else { 0.0 })), x, if top { y } else { y * -1.0 })
+ }
+
+ /// The flat face of the sheet that a point lies on (sheet coordinates, within `tol`, which
+ /// stays below a quarter of the thickness): its persistent name (`:top` or
+ /// `:bottom`) and its plane (see [`panel_face`](Self::panel_face)).
+ pub fn panel_face_at(&self, p: Vec3, tol: f64) -> Option<(String, Plane)> {
+ let tol = tol.min(self.t * 0.25);
+ let flat = if self.flat { self.flat_regions().ok()? } else { Vec::new() };
+ let panels = if self.flat { vec![(None, self.base.clone())] } else { self.panels() };
+ for (pi, region) in panels {
+ let world = self.world(pi);
+ let Some(inverse) = mat_inverse(&world) else { continue };
+ let q = apply_point(&inverse, p);
+ let top = (q.z - self.t).abs() < q.z.abs();
+ if (if top { (q.z - self.t).abs() } else { q.z.abs() }) > tol {
+ continue;
+ }
+ let q2 = Vec2::new(q.x, q.y);
+ if if self.flat { flat.iter().any(|r| r.contains(q2)) } else { region.contains(q2) } {
+ return Some((format!("{}:{}", self.panel_name(pi), if top { "top" } else { "bottom" }), self.panel_face_plane(&world, top)?));
+ }
+ }
+ None
+ }
+
+ /// The plane of the flat face with this persistent name (see [`panel_face_at`](Self::panel_face_at)).
+ /// A face named while the sheet was folded follows its panel; one named unfolded (`flat`)
+ /// is the flat sheet's own plane.
+ pub fn panel_face(&self, name: &str) -> Option {
+ let (panel, side) = name.rsplit_once(':')?;
+ let top = match side {
+ "top" => true,
+ "bottom" => false,
+ _ => return None,
+ };
+ let pi = match panel {
+ "flat" | "base" => None,
+ tag => Some(
+ self.flanges
+ .iter()
+ .position(|f| f.tag == tag)
+ .or_else(|| tag.strip_prefix('#')?.parse::().ok().filter(|i| *i < self.flanges.len()))?,
+ ),
+ };
+ self.panel_face_plane(&self.world(pi), top)
+ }
+
/// Map a region in a world plane to flat coordinates through a panel's placement.
pub fn region_to_flat(&self, panel: Option, plane: &Plane, r: &Region2) -> Result {
region_complexity(std::slice::from_ref(r))?;
@@ -2323,6 +2389,52 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
Ok(sheet)
}
+/// The name a sketch keeps for a flat face of a sheet: `sheet:::`.
+pub fn sheet_face_name(body: &str, face: &str) -> String {
+ format!("sheet:{body}:{face}")
+}
+
+/// Point the sheet face a sketch plane is attached to (also beneath an offset or a rotation)
+/// at body `new`, when it is on body `old`.
+pub fn rename_plane_sheet_body(plane: &mut crate::PlaneRef, old: &str, new: &str) {
+ match plane {
+ crate::PlaneRef::Face { name: Some(name), anchor: None, .. } => {
+ let renamed = split_sheet_face_name(name).filter(|(body, _)| *body == old).map(|(_, face)| sheet_face_name(new, face));
+ if let Some(renamed) = renamed {
+ *name = renamed;
+ }
+ }
+ crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } => rename_plane_sheet_body(base, old, new),
+ _ => {}
+ }
+}
+
+/// The sheet face a sketch plane is attached to (also beneath an offset or a rotation): its
+/// [`sheet_face_name`].
+pub fn plane_sheet_face(plane: &crate::PlaneRef) -> Option<&str> {
+ match plane {
+ crate::PlaneRef::Face { name: Some(name), anchor: None, .. } if split_sheet_face_name(name).is_some() => Some(name),
+ crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } => plane_sheet_face(base),
+ _ => None,
+ }
+}
+
+/// The body and the `:` of a [`sheet_face_name`].
+pub fn split_sheet_face_name(name: &str) -> Option<(&str, &str)> {
+ let rest = name.strip_prefix("sheet:")?;
+ let (head, side) = rest.rsplit_once(':')?;
+ let (body, panel) = head.rsplit_once(':')?;
+ (!body.is_empty() && !panel.is_empty() && !side.is_empty()).then_some(())?;
+ Some((body, rest.get(body.len() + 1..)?))
+}
+
+/// The plane of the sheet face with a [`sheet_face_name`], in the body's own coordinates.
+pub fn sheet_face_plane(st: &crate::ModelState, name: &str) -> Option<(String, Plane)> {
+ let (body, face) = split_sheet_face_name(name)?;
+ let plane = st.sheets.iter().find(|s| s.body == body)?.panel_face(face)?;
+ Some((body.to_string(), plane))
+}
+
/// Persistent names of the sheet edges nearest each world point (empty when none).
pub fn sheet_edge_names(st: &crate::ModelState, pts: &[Vec3]) -> Vec {
pts.iter()
@@ -2393,6 +2505,85 @@ mod tests {
assert!((s.flat_size().unwrap().1 - 73.685298).abs() < 1e-5);
}
+ #[test]
+ fn flat_faces_of_a_sheet_are_found_and_named_by_panel() {
+ use std::f64::consts::FRAC_PI_2;
+ // Steel, 2.5 thick, R = T: a 90° flange on the y = 0 edge stands between the planes
+ // y = 0 (its outer face) and y = 2.5 (its inner face) from z = 5 up.
+ let mut s = plate(100.0, 60.0);
+ s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
+ s.flanges[0].tag = "F7.0".into();
+ let face = |s: &SheetBody, p: Vec3| s.panel_face_at(p, 1e-6);
+ let (name, plane) = face(&s, Vec3::new(50.0, 30.0, 2.5)).unwrap();
+ assert_eq!(name, "base:top");
+ assert!(plane.normal().dist(Vec3::Z) < 1e-12 && (plane.origin.z - 2.5).abs() < 1e-12);
+ // The top face's coordinates are the flat pattern's; the bottom face's mirror y.
+ assert!(plane.to_local(Vec3::new(30.0, 20.0, 2.5)).dist(Vec2::new(30.0, 20.0)) < 1e-12);
+ let (name, plane) = face(&s, Vec3::new(50.0, 30.0, 0.0)).unwrap();
+ assert_eq!(name, "base:bottom");
+ assert!(plane.normal().dist(Vec3::Z * -1.0) < 1e-12 && plane.origin.z.abs() < 1e-12);
+ assert!(plane.to_local(Vec3::new(30.0, 20.0, 0.0)).dist(Vec2::new(30.0, -20.0)) < 1e-12);
+ // The flange's inner face is the sheet's top side there; 7 mm up it is flat y = 5 − BA − 7.
+ let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
+ let (inner, plane) = face(&s, Vec3::new(30.0, 2.5, 12.0)).unwrap();
+ assert_eq!(inner, "F7.0:top");
+ assert!(plane.normal().dist(Vec3::Y) < 1e-12, "{:?}", plane.normal());
+ assert!(plane.to_local(Vec3::new(30.0, 2.5, 12.0)).dist(Vec2::new(30.0, 5.0 - ba - 7.0)) < 1e-9);
+ let (outer, plane) = face(&s, Vec3::new(30.0, 0.0, 12.0)).unwrap();
+ assert_eq!(outer, "F7.0:bottom");
+ assert!(plane.normal().dist(Vec3::Y * -1.0) < 1e-12, "{:?}", plane.normal());
+ // A name gives the same plane again.
+ for p in [Vec3::new(50.0, 30.0, 2.5), Vec3::new(50.0, 30.0, 0.0), Vec3::new(30.0, 2.5, 12.0), Vec3::new(30.0, 0.0, 12.0)] {
+ let (name, plane) = face(&s, p).unwrap();
+ assert_eq!(s.panel_face(&name), Some(plane), "{name}");
+ }
+ // Inside the sheet, on the bend, beside the sheet, above the flange: no flat face.
+ for p in [Vec3::new(50.0, 30.0, 1.0), Vec3::new(50.0, 1.0, 1.0), Vec3::new(150.0, 30.0, 2.5), Vec3::new(30.0, 2.5, 40.0)] {
+ assert!(face(&s, p).is_none(), "{p:?}");
+ }
+ // Names that are no face, or no flange, give no plane.
+ for name in ["base", "base:side", "nobody:top", "#7:top", "F8.0:top", ""] {
+ assert!(s.panel_face(name).is_none(), "{name}");
+ }
+ // A flange without a tag is named by its index.
+ s.flanges[0].tag.clear();
+ assert_eq!(face(&s, Vec3::new(30.0, 2.5, 12.0)).map(|f| f.0).as_deref(), Some("#0:top"));
+ assert!(s.panel_face("#0:bottom").is_some());
+ // Unfolded, the whole sheet is one panel.
+ s.flat = true;
+ assert_eq!(face(&s, Vec3::new(50.0, -10.0, 2.5)).map(|f| f.0).as_deref(), Some("flat:top"));
+ assert!(s.panel_face("flat:bottom").is_some());
+ }
+
+ #[test]
+ fn a_pick_beside_a_thin_sheet_takes_the_nearer_face() {
+ // The tolerance of a pick on a large part is far above a thin sheet's thickness, and
+ // never reaches a quarter of it here: the nearer side wins, the middle is no face.
+ let rv = RuleValues { thickness: 0.5, ..steel() };
+ let s = base_flange(
+ "Thin",
+ "Sheet",
+ &rv,
+ Plane::XY,
+ rect(Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(100.0, 60.0), Vec2::new(0.0, 60.0)),
+ );
+ let at = |z: f64| s.panel_face_at(Vec3::new(50.0, 30.0, z), 5.0).map(|f| f.0);
+ assert_eq!(at(0.4).as_deref(), Some("base:top"));
+ assert_eq!(at(0.1).as_deref(), Some("base:bottom"));
+ assert_eq!(at(0.25), None);
+ assert_eq!(at(3.0), None);
+ }
+
+ #[test]
+ fn sheet_face_names_split_from_the_right() {
+ assert_eq!(split_sheet_face_name(&sheet_face_name("Body1", "F7.0:top")), Some(("Body1", "F7.0:top")));
+ // A body name may hold colons; the panel and the side never do.
+ assert_eq!(split_sheet_face_name("sheet:Part:2:base:bottom"), Some(("Part:2", "base:bottom")));
+ for name in ["", "sheet", "sheet:Body1", "sheet:Body1:top", "sheet::base:top", "sheet:Body1::top", "sheet:Body1:base:", "Body1:base:top"] {
+ assert_eq!(split_sheet_face_name(name), None, "{name}");
+ }
+ }
+
#[test]
fn contour_flange_matches_fusion() {
// Oracle 72: XZ chain (0,0)→(40,0)→(40,−30)→(70,−30), 50 wide.
diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs
index 016f4301..ff667feb 100644
--- a/crates/engine/src/cmd/browser.rs
+++ b/crates/engine/src/cmd/browser.rs
@@ -464,6 +464,9 @@ fn rename_body(s: &mut Session, p: &Value) -> Result {
// Later features refer to bodies by name.
let mut failed = None;
for f in d.features.iter_mut().filter(|f| f.id != fid) {
+ if let FeatureKind::Sketch { plane, .. } = &mut f.kind {
+ solidcraft_doc::sheet::rename_plane_sheet_body(plane, &old, name);
+ }
if let FeatureKind::Sketch { plane, sketch } = &mut f.kind
&& let Some(anchor) = super::sketch::face::anchor_mut(plane)
&& anchor.target.body == old
@@ -614,7 +617,13 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result {
s.pending_moves = trial.pending_moves;
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
- FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() { "exact_face" } else { "fixed" }),
+ FeatureKind::Sketch { plane, .. } => Some(if super::sketch::face::anchor(plane).is_some() {
+ "exact_face"
+ } else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
+ "sheet_face"
+ } else {
+ "fixed"
+ }),
_ => None,
});
Ok(json!({ "sketch": id,"attachment":attachment }))
diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs
index 26297aa8..01b3bccd 100644
--- a/crates/engine/src/cmd/sheet_tests.rs
+++ b/crates/engine/src/cmd/sheet_tests.rs
@@ -709,6 +709,212 @@ fn fold_keeps_the_larger_side_either_way() {
}
}
+/// A 100 x 60 plate with a 90° flange up from its y = 0 edge (steel: 2.5 thick, R = T, K 0.44).
+/// The flange's inner face is the plane y = 2.5 and its outer face y = 0; it stands from z = 5.
+fn plate_with_flange(s: &mut Session) {
+ base(s, 100.0, 60.0);
+ run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
+}
+
+/// A circle sketch on `plane`, finished.
+fn circle_sketch(s: &mut Session, name: &str, plane: Value, center: [f64; 2], diameter: f64) -> Value {
+ let made = run(s, "sketch.create", json!({"plane": plane, "name": name}));
+ run(s, "sketch.circle.center", json!({"center": center, "diameter": diameter}));
+ run(s, "sketch.finish", json!({}));
+ made
+}
+
+fn cut_through(s: &mut Session, sketch: &str) {
+ run(s, "solid.extrude", json!({"sketch": sketch, "through_all": true, "direction": "negative", "operation": "cut"}));
+}
+
+fn contains(s: &Session, p: [f64; 3]) -> bool {
+ s.model.state().bodies.first().is_some_and(|b| b.body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2])))
+}
+
+#[test]
+fn a_sketch_on_a_flange_face_attaches_to_its_panel() {
+ // Sketch coordinates on a sheet face are the flat pattern's: the flange panel starts at
+ // y = 5 − BA and runs on toward −y, so a hole 7 mm up the face is at y = 5 − BA − 7.
+ let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
+ let hole = PI * 1.8 * 1.8 * 2.5;
+ let mut s = Session::default();
+ plate_with_flange(&mut s);
+ let v0 = volume(&mut s);
+ let made = circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, 5.0 - ba - 7.0], 3.6);
+ assert_eq!(made["attachment"], "sheet_face");
+ // The sketch keeps the face's name: the body, the panel (its flange) and the side of the sheet.
+ let sketch = s.doc.find_feature("Holes").cloned().unwrap();
+ let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &sketch.kind else { panic!("{:?}", sketch.kind) };
+ let name = solidcraft_doc::sheet::plane_sheet_face(plane).unwrap_or_default();
+ assert!(name.starts_with("sheet:Body1:F") && name.ends_with(":top"), "{name}");
+ // A cut through the flange from that sketch is a round hole 7 mm up the face, at x = 30.
+ run(&mut s, "solid.extrude", json!({"sketch": "Holes", "distance": 2.5, "direction": "negative", "operation": "cut"}));
+ assert!((v0 - volume(&mut s) - hole).abs() < 0.1, "{}", v0 - volume(&mut s));
+ assert!(!contains(&s, [30.0, 1.25, 12.0]) && contains(&s, [30.0, 1.25, 14.5]) && contains(&s, [33.0, 1.25, 12.0]));
+ let f = flat(&mut s);
+ let cut = f["cutouts"][0].as_array().cloned().unwrap_or_default();
+ assert!(!cut.is_empty(), "{f}");
+ for p in &cut {
+ let (x, y) = (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN));
+ assert!(((x - 30.0).hypot(y - (5.0 - ba - 7.0)) - 1.8).abs() < 1e-3, "({x}, {y})");
+ }
+ // `fixed: true` still freezes the picked plane instead of attaching to the panel.
+ let frozen = circle_sketch(&mut s, "Frozen", json!({"face": [50, 2.5, 12], "fixed": true}), [0.0, 0.0], 1.0);
+ assert_eq!(frozen["attachment"], "fixed");
+}
+
+/// The thickness of a sheet comes from a parameter: the flange's face moves with it, and a
+/// sketch frozen on the old plane cuts a blind pocket in the thicker sheet. The attached
+/// sketch cuts the same hole as one drawn on the thicker sheet in the first place.
+#[test]
+fn a_sketch_on_a_sheet_face_follows_the_sheet_when_its_thickness_changes() {
+ let build = |t: f64, then: Option| {
+ let mut s = Session::default();
+ run(&mut s, "parameters.add", json!({"name": "T", "expression": format!("{t} mm")}));
+ run(&mut s, "sheet.manage_rules", json!({"name": "AL", "thickness": "T", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
+ base(&mut s, 100.0, 60.0);
+ run(&mut s, "sheet.flange", json!({"edges": [[50, 0, t]], "height": 20}));
+ // The hole is drawn 10 mm up the 2 mm flange, in the flat pattern: its bend ends at
+ // y = T + R = 3 and the bend allowance is (R + T/2)·π/2 = π. The coordinates stay as they are.
+ let flat_y = 3.0 - PI - 10.0;
+ circle_sketch(&mut s, "Holes", json!({"face": [50, t, 12]}), [30.0, flat_y], 6.0);
+ run(&mut s, "sketch.create", json!({"plane": {"face": [10, 30, t]}, "offset": "5 mm", "name": "Above"}));
+ run(&mut s, "sketch.finish", json!({}));
+ let v0 = volume(&mut s);
+ cut_through(&mut s, "Holes");
+ if let Some(t2) = then {
+ let r = run(&mut s, "parameters.change", json!({"name": "T", "expression": format!("{t2} mm")}));
+ assert_eq!(r["errors"], json!([]), "{r}");
+ }
+ (s, v0)
+ };
+ let (mut grown, _) = build(2.0, Some(3.0));
+ let (mut direct, v_direct) = build(3.0, None);
+ let (vg, vd) = (volume(&mut grown), volume(&mut direct));
+ // The hole is the cylinder Ø6 × 3 through the thicker sheet, as if drawn there at first.
+ assert!((v_direct - vd - PI * 9.0 * 3.0).abs() < 0.1, "{}", v_direct - vd);
+ assert!(rel(vg, vd) < 1e-9, "{vg} {vd}");
+ let (fg, fd) = (flat(&mut grown), flat(&mut direct));
+ assert_eq!(fg["pockets"], json!([]), "a blind pocket instead of the hole: {fg}");
+ assert_eq!(fg["cutouts"], fd["cutouts"]);
+ // The thicker flange's panel starts at flat y = 4 − 3.927 and z = T + R = 4, so the hole is
+ // 10.215 mm up it: through the whole 3 mm.
+ assert!(!contains(&grown, [30.0, 1.5, 14.2]) && !contains(&grown, [30.0, 0.2, 14.2]) && contains(&grown, [30.0, 1.5, 19.0]));
+ // The sketch offset from the base's top face moved up with it: 3 + 5.
+ let plane = run(&mut grown, "sketch.inspect", json!({"sketch": "Above"}))["plane"]["origin"][2].as_f64().unwrap_or(f64::NAN);
+ assert!((plane - 8.0).abs() < 1e-9, "{plane}");
+}
+
+#[test]
+fn sketches_on_the_top_and_bottom_faces_of_a_sheet_survive_unfold_refold_and_reopen() {
+ let mut s = Session::default();
+ base(&mut s, 100.0, 60.0);
+ // The top face's coordinates are the base sketch's; the bottom face's mirror y, as seen from below.
+ circle_sketch(&mut s, "Top", json!({"face": [30, 20, 2.5]}), [30.0, 20.0], 4.0);
+ circle_sketch(&mut s, "Bottom", json!({"face": [60, 20, 0]}), [60.0, -20.0], 4.0);
+ cut_through(&mut s, "Top");
+ cut_through(&mut s, "Bottom");
+ let want = 15000.0 - 2.0 * PI * 4.0 * 2.5;
+ assert!((volume(&mut s) - want).abs() < 0.1, "{}", volume(&mut s));
+ let holes = |f: &Value| {
+ let mut centres: Vec<(f64, f64)> = f["cutouts"]
+ .as_array()
+ .cloned()
+ .unwrap_or_default()
+ .iter()
+ .map(|c| {
+ let pts: Vec<(f64, f64)> = c
+ .as_array()
+ .cloned()
+ .unwrap_or_default()
+ .iter()
+ .map(|p| (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN)))
+ .collect();
+ let n = pts.len().max(1) as f64;
+ (pts.iter().map(|p| p.0).sum::() / n, pts.iter().map(|p| p.1).sum::() / n)
+ })
+ .collect();
+ centres.sort_by(|a, b| a.0.total_cmp(&b.0));
+ centres
+ };
+ for command in [None, Some("sheet.unfold"), Some("sheet.refold")] {
+ if let Some(command) = command {
+ run(&mut s, command, json!({}));
+ }
+ let centres = holes(&flat(&mut s));
+ assert_eq!(centres.len(), 2, "{centres:?}");
+ assert!((centres[0].0 - 30.0).abs() < 0.01 && (centres[0].1 - 20.0).abs() < 0.01, "{centres:?}");
+ assert!((centres[1].0 - 60.0).abs() < 0.01 && (centres[1].1 - 20.0).abs() < 0.01, "{centres:?}");
+ assert!((volume(&mut s) - want).abs() < 0.1);
+ }
+ let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
+ assert!((volume(&mut reopened) - want).abs() < 0.1);
+ assert_eq!(holes(&flat(&mut reopened)), holes(&flat(&mut s)));
+}
+
+#[test]
+fn a_sketch_made_on_the_unfolded_sheet_lands_on_the_flange_after_refolding() {
+ let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
+ let mut s = Session::default();
+ plate_with_flange(&mut s);
+ let v0 = volume(&mut s);
+ run(&mut s, "sheet.unfold", json!({}));
+ // Unfolded, the flange lies in the sheet's plane: the one flat face covers it too.
+ let y = 5.0 - ba - 7.0;
+ circle_sketch(&mut s, "Holes", json!({"face": [30, y, 2.5]}), [30.0, y], 3.6);
+ cut_through(&mut s, "Holes");
+ run(&mut s, "sheet.refold", json!({}));
+ assert!((v0 - volume(&mut s) - PI * 1.8 * 1.8 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut s));
+ assert!(!contains(&s, [30.0, 1.25, 12.0]) && contains(&s, [30.0, 1.25, 14.5]));
+}
+
+#[test]
+fn a_sketch_on_a_sheet_face_reports_a_lost_attachment_instead_of_moving_to_another_face() {
+ let mut s = Session::default();
+ plate_with_flange(&mut s);
+ circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, -8.0], 3.6);
+ let id = s.doc.find_feature("Holes").map(|f| f.id).unwrap_or(0);
+ let status = |s: &Session| s.placed_document().sketch_face_attachment(id, &s.model);
+ assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(_) }));
+ // With the flange suppressed there is no such face; the base's top face is not a substitute.
+ run(&mut s, "timeline.suppress", json!({"feature": "EdgeFlange1", "suppressed": true}));
+ let error = run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].as_str().unwrap_or_default().to_string();
+ assert!(error.contains("lost face attachment") && error.contains("reattach"), "{error}");
+ let solidcraft_doc::FaceAttachmentStatus::Lost { message, repair_plane } = status(&s) else { panic!("{:?}", status(&s)) };
+ assert!(message.contains("sheet face") && repair_plane.is_some(), "{message}");
+ // Redefine Sketch Plane repairs it on another face, here the base's top (its sketch keeps its geometry).
+ let repaired = run(&mut s, "sketch.redefine", json!({"sketch": "Holes", "plane": {"face": [10, 30, 2.5]}}));
+ assert_eq!(repaired["attachment"], "sheet_face");
+ assert!(run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].is_null());
+ assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { .. }));
+ // The flange is back, and a sketch attached to it again is healthy.
+ run(&mut s, "timeline.suppress", json!({"feature": "EdgeFlange1", "suppressed": false}));
+ let again = run(&mut s, "sketch.redefine", json!({"sketch": "Holes", "plane": {"face": [50, 2.5, 12]}}));
+ assert_eq!(again["attachment"], "sheet_face");
+ assert!(run(&mut s, "timeline.inspect", json!({"feature": "Holes"}))["error"].is_null());
+ assert!(matches!(status(&s), solidcraft_doc::FaceAttachmentStatus::Sheet { .. }));
+}
+
+#[test]
+fn renaming_a_sheet_body_keeps_its_sketches_on_their_faces() {
+ let mut s = Session::default();
+ plate_with_flange(&mut s);
+ let v0 = volume(&mut s);
+ circle_sketch(&mut s, "Holes", json!({"face": [50, 2.5, 12]}), [30.0, -8.0], 3.6);
+ run(&mut s, "sketch.create", json!({"plane": {"face": [10, 30, 2.5]}, "offset": "5 mm", "name": "Above"}));
+ run(&mut s, "sketch.finish", json!({}));
+ run(&mut s, "body.rename", json!({"body": "Body1", "name": "Bracket"}));
+ for (sketch, face) in [("Holes", "sheet:Bracket:F"), ("Above", "sheet:Bracket:base:top")] {
+ let feature = s.doc.find_feature(sketch).cloned().unwrap();
+ let solidcraft_doc::FeatureKind::Sketch { plane, .. } = &feature.kind else { panic!("{:?}", feature.kind) };
+ assert!(solidcraft_doc::sheet::plane_sheet_face(plane).is_some_and(|name| name.starts_with(face)), "{sketch}: {:?}", feature.kind);
+ assert!(run(&mut s, "timeline.inspect", json!({"feature": sketch}))["error"].is_null(), "{sketch}");
+ }
+ cut_through(&mut s, "Holes");
+ assert!((v0 - volume(&mut s) - PI * 1.8 * 1.8 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut s));
+}
+
/// A tray shrunk by a parameter change unfolds like the same tray built at that size. The
/// sketch solved again leaves its corners ~1e-13 mm off, and the flat pattern's planar
/// arrangement used to drop a bend zone through a gap that small: "planar region arrangement
diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs
index b2d4c732..f575beb1 100644
--- a/crates/engine/src/cmd/sketch.rs
+++ b/crates/engine/src/cmd/sketch.rs
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
.at("SOLID", "CREATE")
.icon("sketch")
- .params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source, fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
+ .params("plane: XY|XZ|YZ | construction plane name or id | {origin, x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, project_edges?: bool, name?"),
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"),
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.line", "Line", draw_line)
@@ -662,6 +662,8 @@ fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, interna
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() {
"exact_face"
+ } else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
+ "sheet_face"
} else if internal_body.is_some() {
"internal_legacy_face"
} else {
diff --git a/crates/engine/src/cmd/sketch_face.rs b/crates/engine/src/cmd/sketch_face.rs
index 94f0f0f9..42395abf 100644
--- a/crates/engine/src/cmd/sketch_face.rs
+++ b/crates/engine/src/cmd/sketch_face.rs
@@ -79,6 +79,7 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
by_component.entry(doc.model_body_component(body)).or_default().push(body);
}
let mut chosen = None;
+ let mut sheet_face: Option<(String, Plane)> = None;
let mut work = state.bodies.len().saturating_add(doc.occurrences.len());
for (component, path, placement) in placements {
let Some(bodies) = by_component.get(&component) else {
@@ -104,6 +105,22 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?;
let local_point = apply_point(&inverse, point);
let tolerance = (body.body.size() * 1e-4).max(1e-5);
+ // A flat face of a sheet metal body attaches to its panel: the exact plane comes from
+ // the panel's flat region and fold, and the sketch keeps the face's name to follow it.
+ if !fixed
+ && !internal
+ && let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body.name)
+ && let Some((face, plane)) = sheet.panel_face_at(local_point, tolerance * 10.0)
+ {
+ if chosen.is_some() || sheet_face.is_some() {
+ return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
+ }
+ let relative = mat_mul(&author_inverse, &frame);
+ let plane = Plane::new(apply_point(&relative, plane.origin), apply_vector(&relative, plane.x), apply_vector(&relative, plane.y))
+ .ok_or_else(|| bad(cmd, "invalid sheet face plane"))?;
+ sheet_face = Some((solidcraft_doc::sheet::sheet_face_name(&body.name, &face), plane));
+ continue;
+ }
let faces = body.body.faces(tolerance)?;
let mesh = body.body.tessellate(tolerance)?;
work = work.saturating_add(mesh.triangles.len().saturating_mul(if requested_index.is_some() { 1 } else { faces.len() }));
@@ -138,6 +155,12 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
}
}
}
+ if let Some((name, plane)) = sheet_face {
+ if chosen.is_some() {
+ return Err(bad(cmd, "face point is ambiguous; pick an exact body and face index"));
+ }
+ return Ok(PlaneRef::Face { plane, at: apply_point(&author_inverse, point), name: Some(name), anchor: None });
+ }
let (body, index, path, plane) =
chosen.ok_or_else(|| bad(cmd, "the selected planar face no longer exists; choose another face or an explicit fixed plane"))?;
if fixed {
diff --git a/crates/ui-egui/src/sketch_attachment_ui.rs b/crates/ui-egui/src/sketch_attachment_ui.rs
index 1435760f..9cec8a6e 100644
--- a/crates/ui-egui/src/sketch_attachment_ui.rs
+++ b/crates/ui-egui/src/sketch_attachment_ui.rs
@@ -167,6 +167,10 @@ pub fn panel(app: &mut SolveApp, ctx: &egui::Context, at: Pos2) {
ui.label("Legacy face attachment. Rebinding to a certified face creates an exact attachment.");
*plane
}
+ FaceAttachmentStatus::Sheet { plane } => {
+ ui.label("Attached to a flat face of a sheet metal body; it follows that panel when the sheet changes.");
+ *plane
+ }
FaceAttachmentStatus::Fixed { plane } => {
ui.label("Sketch plane has no body-face attachment.");
*plane
diff --git a/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json b/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json
index 0bbcd4f8..718226ab 100644
--- a/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json
+++ b/crates/ui-egui/tests/scenarios/s22_sheet_metal_fold.json
@@ -81,16 +81,7 @@
}
},
{
- "note": "The sheet flange face is uncertified; choose the displayed fixed-plane option explicitly before drawing the fold line."
- },
- {
- "expect": {
- "sketching": false,
- "dialog": "this face has no exact planar source"
- }
- },
- {
- "widget": "Use fixed plane"
+ "note": "A flat face of a sheet attaches the sketch to its panel (by name), in the flat pattern's coordinates."
},
{
"expect": {
diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md
index 8d73337b..9552dcd4 100644
--- a/docs/book/src/guide/sheet-metal.md
+++ b/docs/book/src/guide/sheet-metal.md
@@ -89,3 +89,15 @@ tool with varying depth is refused before changing sheet geometry. Pocket profil
but a pocket crossing a bend zone cannot yet be refolded. Machining folded bend material,
tapered sheet cuts and other unsupported 3D sheet Cut/Intersect operations report an error before changing
the design; they cannot silently restore removed material during a later sheet rebuild.
+
+A sketch can be drawn on a flat face of the sheet: the base or a flange, on either side. Pick
+the face (or give `sketch.create` the point `plane: {face: [x, y, z]}`) and the sketch attaches
+to that panel instead of to a certified source face. It keeps the face's name,
+`sheet:::` (the panel is `base` or the flange's feature tag), and finds
+the face again whenever the sheet is rebuilt: after a parameter change such as the thickness or
+a flange angle, through Unfold / Refold, after the file is reopened and when the body is
+renamed. Sketch coordinates are the flat pattern's, so a point drawn on a flange lies where the
+flat pattern and its DXF have it, and on the bottom face y runs the other way, as seen from
+below. When the face is gone (its flange is suppressed or deleted) the sketch reports a lost
+attachment instead of moving to another face; **Redefine Sketch Plane** attaches it to another
+face. `fixed: true` still freezes the picked plane in space.
diff --git a/docs/face-attachments.md b/docs/face-attachments.md
index fd155cab..d8747995 100644
--- a/docs/face-attachments.md
+++ b/docs/face-attachments.md
@@ -14,6 +14,11 @@ Edited surfaces without retained construction provenance, ambiguous sources, and
generators require an explicit fixed plane instead. A nearby or parallel face is never used
to repair a new attachment automatically.
+The flat faces of a sheet metal body are the exception: they have no construction
+certificate, so a sketch on one attaches to its panel by name (`sheet:::`)
+and finds it again after every rebuild, unfold and refold. If that panel no longer exists the
+sketch reports a lost attachment, as above. See the sheet metal guide.
+
## Create and repair
Select a supported planar face and create a sketch. The desktop selection supplies the exact