From 05b38e0f5f41dad6d99886f009ab77caa3ec7ab7 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 12:51:32 +0000 Subject: [PATCH] Persistent naming for sheet metal: base panel edges are named after the base sketch line they lie on, flange edges after their flange (F.) and side (far / side0 / side1, top or bottom face); flanges and hems store edge names and pick by name first (a name that is gone keeps the point with a warning) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/eval.rs | 2 +- crates/doc/src/eval_more.rs | 65 ++++++++++++++++++-- crates/doc/src/sheet.rs | 91 +++++++++++++++++++++++++++- crates/engine/src/cmd/features.rs | 4 ++ crates/engine/src/cmd/sheet_tests.rs | 29 +++++++++ 5 files changed, 185 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 5ae008d8..4cdc7b5e 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1838,7 +1838,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::SheetHem { .. } | FeatureKind::SheetFold { .. } | FeatureKind::SheetUnfold { .. } - | FeatureKind::SheetConvert { .. } => more::sheet_eval(doc, vals, f, st), + | FeatureKind::SheetConvert { .. } => more::sheet_eval(doc, vals, f, st, warning), FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. } => { more::plastic_eval(doc, vals, f, st) } diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index e88401ff..1eb92373 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -288,7 +288,13 @@ fn mirror_y(r: &Region2) -> Region2 { Region2 { outer: m(&r.outer), holes: r.holes.iter().map(|h| m(h).reversed()).collect() } } -pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { +pub(super) fn sheet_eval( + doc: &Document, + vals: &BTreeMap, + f: &Feature, + st: &mut ModelState, + warning: &mut Option, +) -> Result<()> { use crate::sheet; match &f.kind { FeatureKind::SheetBase { sketch, profiles, rule, flip } => { @@ -303,7 +309,22 @@ pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap, f: &Fea (ss.plane, region) }; let name = new_name(st, f, k); - let sh = sheet::base_flange(&name, &r.name, &rv, plane, region); + let mut sh = sheet::base_flange(&name, &r.name, &rv, plane, region); + // The sketch's lines name the base panel's edges. + sh.base_curves = ss + .sketch + .curves + .iter() + .enumerate() + .filter(|(_, c)| !c.construction) + .flat_map(|(ci, c)| { + ss.sketch.segs(ci).into_iter().filter_map(move |sg| match sg { + Seg2::Line { a, b } => Some((c.id.clone(), a, b)), + _ => None, + }) + }) + .map(|(id, a, b)| if *flip { (id, Vec2::new(a.x, -a.y), Vec2::new(b.x, -b.y)) } else { (id, a, b) }) + .collect(); let solid = sh.solid()?; st.bodies.push(ModelBody::new(name, solid, f.id)); st.sheets.push(sh); @@ -349,7 +370,10 @@ pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap, f: &Fea if sh.flat { return Err(DocError::Invalid("refold the sheet before adding flanges".into())); } - sh.add_flanges(edges, h, a, rr, position, *flip)?; + let picks = sheet_picks(sh, f, edges, warning); + let n0 = sh.flanges.len(); + sh.add_flanges(&picks, h, a, rr, position, *flip)?; + tag_flanges(sh, n0, f.id); rebuild_sheet(st, i) } FeatureKind::SheetHem { edges, length, gap, flip, body } => { @@ -363,9 +387,12 @@ pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap, f: &Fea } }; let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?; - for e in edges { + let picks = sheet_picks(sh, f, edges, warning); + let n0 = sh.flanges.len(); + for e in &picks { sh.add_hem(*e, l, g, *flip)?; } + tag_flanges(sh, n0, f.id); rebuild_sheet(st, i) } FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip, fixed, body } => { @@ -394,7 +421,9 @@ pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap, f: &Fea if sh.flat { return Err(DocError::Invalid("refold the sheet before folding it".into())); } + let n0 = sh.flanges.len(); sh.add_fold(a, b, ang, rr, position, *flip, *fixed)?; + tag_flanges(sh, n0, f.id); rebuild_sheet(st, i) } FeatureKind::SheetUnfold { body, refold } => { @@ -843,3 +872,31 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap, f: &F _ => Err(DocError::Invalid(format!("{} is not a plastic feature", f.name))), } } + +/// Edge picks of a sheet feature, moved onto their named edges (a name that is gone keeps the +/// point, with a warning). +fn sheet_picks(sh: &crate::sheet::SheetBody, f: &Feature, edges: &[Vec3], warning: &mut Option) -> Vec { + let mut gone = Vec::new(); + let out = edges + .iter() + .enumerate() + .map(|(i, p)| match f.edge_names.get(i).filter(|n| !n.is_empty()) { + Some(n) => sh.edge_by_name(n, *p).unwrap_or_else(|| { + gone.push(n.clone()); + *p + }), + None => *p, + }) + .collect(); + if !gone.is_empty() { + *warning = Some(format!("sheet edge(s) {} no longer exist; using the edges at the picked points", gone.join(", "))); + } + out +} + +/// Tag the flanges a feature just added (`F.`). +fn tag_flanges(sh: &mut crate::sheet::SheetBody, from: usize, feature: u64) { + for (k, fl) in sh.flanges.iter_mut().enumerate().skip(from) { + fl.tag = format!("F{feature}.{}", k - from); + } +} diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 074cb3d2..09568cc2 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -160,6 +160,9 @@ pub struct SheetFlange { /// instead of a rectangle `leg` long. #[serde(default, skip_serializing_if = "Option::is_none")] pub shape: Option>, + /// Which feature made it: `F.` (persistent edge names). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub tag: String, } /// A sheet metal body (see the module docs). @@ -179,6 +182,9 @@ pub struct SheetBody { pub flat: bool, /// Gap between flanges that meet at a corner. pub gap: f64, + /// The base sketch's lines (id, ends in flat coordinates), naming the base panel's edges. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub base_curves: Vec<(String, Vec2, Vec2)>, } /// A piece of the sheet: a panel (None = the base) or a flange's bend zone. @@ -839,6 +845,7 @@ impl SheetBody { radius, leg: leg.max(0.0), shape: None, + tag: String::new(), ext: (0.0, 0.0), }); made.push(self.flanges.len() - 1); @@ -905,6 +912,7 @@ impl SheetBody { leg, ext: (0.0, 0.0), shape: None, + tag: String::new(), }); Ok(self.flanges.len() - 1) } @@ -968,7 +976,7 @@ impl SheetBody { n = n * -1.0; } let l = a2.dot(n); - let probe = SheetFlange { parent: panel, p: a2, d, len: 1.0, n, angle, radius, leg: 0.0, ext: (0.0, 0.0), shape: None }; + let probe = SheetFlange { parent: panel, p: a2, d, len: 1.0, n, angle, radius, leg: 0.0, ext: (0.0, 0.0), shape: None, tag: String::new() }; let ba = self.allowance(&probe); let s0 = match position { "centerline" | "center" | "centre" => l - ba / 2.0, @@ -1018,6 +1026,7 @@ impl SheetBody { leg, ext: (0.0, 0.0), shape: Some(ccw(moving)), + tag: String::new(), }); let i = self.flanges.len() - 1; // Flanges on the moving part's edges now hang off the fold and move with it. @@ -1040,6 +1049,69 @@ impl SheetBody { Ok(i) } + /// Name a panel edge (flat coordinates a→b on `panel`, top or bottom face): base edges by the + /// base sketch line they lie on, flange edges by the flange (its tag) and side. + fn edge_name_of(&self, panel: Option, a: Vec2, b: Vec2, top: bool) -> Option { + let face = if top { "top" } else { "bottom" }; + let d = (b - a).normalized()?; + match panel { + None => { + let on = |p: Vec2, s: Vec2, e: Vec2| { + let t = e - s; + t.len() > 1e-9 && ((p - s).cross(t) / t.len()).abs() < 1e-6 + }; + self.base_curves.iter().find(|(_, s, e)| on(a, *s, *e) && on(b, *s, *e)).map(|(id, _, _)| format!("base:{id}:{face}")) + } + Some(i) => { + let f = self.flanges.get(i)?; + if f.tag.is_empty() { + return None; + } + let side = if d.cross(f.d).abs() < 1e-6 { + // Along the bend: the far edge (or the bend side, never picked). + if ((a - f.p).dot(f.n)) > self.allowance(f) + 1e-6 { "far" } else { "bend" } + } else if ((a + b) * 0.5 - f.p).dot(f.d) < f.len / 2.0 { + "side0" + } else { + "side1" + }; + Some(format!("{}:{side}:{face}", f.tag)) + } + } + } + + /// The persistent name of the sheet edge nearest a world point. + pub fn edge_name(&self, pick: Vec3) -> Option { + let (panel, a, b, top, _) = self.edge_at(pick)?; + self.edge_name_of(panel, a, b, top) + } + + /// A world point on the sheet edge with this name (the one nearest `near` if several). + pub fn edge_by_name(&self, name: &str, near: Vec3) -> Option { + let mut best: Option<(f64, Vec3)> = None; + for (pi, r) in self.panels() { + let pts = r.outer.ccw().polyline(1e-3); + let m = pts.len(); + let w = self.world(pi); + for i in 0..m { + let (Some(&a), Some(&b)) = (pts.get(i), pts.get((i + 1) % m)) else { continue }; + if a.dist(b) < 1e-9 { + continue; + } + for (z, top) in [(0.0, false), (self.t, true)] { + if self.edge_name_of(pi, a, b, top).as_deref() == Some(name) { + let mid = (a + b) * 0.5; + let q = apply_point(&w, Vec3::new(mid.x, mid.y, z)); + if best.is_none_or(|(d, _)| q.dist(near) < d) { + best = Some((q.dist(near), q)); + } + } + } + } + } + best.map(|(_, q)| q) + } + /// 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) -> Option { let inv = mat_inverse(&self.world(panel))?; @@ -1239,6 +1311,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region holes: Vec::new(), flat: false, gap: rv.gap, + base_curves: Vec::new(), } } @@ -1289,6 +1362,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt holes: Vec::new(), flat: false, gap: rv.gap, + base_curves: Vec::new(), }; let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) }; // First segment: the base panel. @@ -1320,6 +1394,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt leg: leg.max(0.0), ext: (0.0, 0.0), shape: None, + tag: String::new(), }; let ba = sheet.allowance(&f); sheet.flanges.push(f); @@ -1421,3 +1496,17 @@ mod tests { assert!(s.solid().is_ok()); } } + +/// 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() + .map(|p| { + st.sheets + .iter() + .filter_map(|sh| sh.edge_at(*p).map(|e| (e.4, sh))) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .and_then(|(_, sh)| sh.edge_name(*p)) + .unwrap_or_default() + }) + .collect() +} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index c03616b0..b638069b 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -263,6 +263,10 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu let st = s.model.state(); (solvecraft_doc::edge_refs(&st, body, edges), solvecraft_doc::edge_names_for(&st, body, edges)) } + // Sheet metal edges are named by the sheet model. + FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => { + (Vec::new(), solvecraft_doc::sheet::sheet_edge_names(&s.model.state(), edges)) + } _ => (Vec::new(), Vec::new()), }; let face_names = { diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index f591f171..4663a3d1 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -298,3 +298,32 @@ fn fold_through_cut_outs() { let e = c.execute("SheetMetalFoldCmd", &json!({"sketch": "FoldLine", "curve": "fl"})).map(|_| ()).unwrap_err().to_string(); assert!(e.contains("not supported yet"), "{e}"); } + +#[test] +fn flange_edges_are_named_and_follow_the_base_sketch() { + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Base"})); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [100, 60]})); + let d = run(&mut s, "SketchDimension", json!({"entities": ["l1"], "value": 100})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Base"})); + run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[100, 30, 2.5]], "height": 20, "name": "Side"})); + let side = s.doc.find_feature("Side").cloned().unwrap(); + assert_eq!(side.edge_names, vec!["base:l2:top".to_string()]); + let v0 = volume(&mut s); + // Narrower base: the flange stays on the right edge (now at x = 40). + let name = d["param"].as_str().or_else(|| d["name"].as_str()).map(str::to_string).unwrap_or_else(|| "d1".into()); + run(&mut s, "ChangeParameterCommand", json!({"name": name, "expression": "40"})); + let b = run(&mut s, "MeasureCommand", json!({}))["bodies"][0]["bbox"].clone(); + let w = b["max"][0].as_f64().unwrap_or(0.0) - b["min"][0].as_f64().unwrap_or(0.0); + assert!((w - 40.0).abs() < 1e-3 && b["max"][2].as_f64().unwrap_or(0.0) > 19.0, "{b}"); + assert!(volume(&mut s) < v0); + let id = side.id; + assert_eq!(s.model.result(id).and_then(|r| r.warning.clone()), None); + // A hem on the flange's far edge is named after the flange. + let top = b["max"][2].as_f64().unwrap_or(0.0); + let x = b["max"][0].as_f64().unwrap_or(0.0); + run(&mut s, "FusionSheetMetalHemFlangeCommand", json!({"edges": [[x, 30, top]], "length": 6, "name": "H"})); + let h = s.doc.find_feature("H").cloned().unwrap(); + assert!(h.edge_names[0].starts_with(&format!("F{id}.0:far")), "{:?}", h.edge_names); +}