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