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:<body>:<panel>:<top|bottom>` (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 <[email protected]>
This commit is contained in:
1 parent
49956c2797
commit
af0440aaab
13 files changed
+515
-13
No files matched your search
@@ -84,6 +84,10 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
|
||||
if let Some(old) = crate::naming::legacy_name(name) {
|
||||
return format!("{}{}", crate::naming::LEGACY_MARK, remap_topology(old, ids, bodies));
|
||||
}
|
||||
// A flat face of a sheet: `sheet:<body>:<panel>:<side>`, 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");
|
||||
|
||||
@@ -3074,6 +3074,38 @@ fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec<Vec3> {
|
||||
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<PlaneRef> {
|
||||
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<String>) {
|
||||
@@ -3205,6 +3237,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, 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),
|
||||
};
|
||||
|
||||
@@ -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<Plane> },
|
||||
Legacy { plane: Option<Plane> },
|
||||
Sheet { plane: Option<Plane> },
|
||||
Healthy { plane: Plane, target: ConfigFaceTarget, path: Vec<u64> },
|
||||
Lost { message: String, repair_plane: Option<Plane> },
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -2025,6 +2025,72 @@ impl SheetBody {
|
||||
best.map(|(_, q)| q)
|
||||
}
|
||||
|
||||
/// The persistent name of a panel: `base`, a flange's tag, or `#<index>` for an untagged
|
||||
/// flange; `flat` for the whole sheet while it is unfolded.
|
||||
fn panel_name(&self, pi: Option<usize>) -> 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<Plane> {
|
||||
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 (`<panel>:top` or
|
||||
/// `<panel>: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<Plane> {
|
||||
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::<usize>().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<usize>, plane: &Plane, r: &Region2) -> Result<Region2> {
|
||||
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:<body>:<panel>:<top|bottom>`.
|
||||
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 `<panel>:<side>` 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<String> {
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user