Persistent naming for sheet metal: base panel edges are named after the base sketch line they lie on, flange edges after their flange (F<feature>.<k>) 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 14:25:27 +00:00
1 parent c6cb3dd1e6
commit 05b38e0f5f
5 files changed
+185 -6

No files matched your search

+1 -1
View File
@@ -1838,7 +1838,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, 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)
}
+61 -4
View File
@@ -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<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
pub(super) fn sheet_eval(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
st: &mut ModelState,
warning: &mut Option<String>,
) -> 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<String, Value>, 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<String, Value>, 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<String, Value>, 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<String, Value>, 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<String, Value>, 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<String>) -> Vec<Vec3> {
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<feature>.<k>`).
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);
}
}
+90 -1
View File
@@ -160,6 +160,9 @@ pub struct SheetFlange {
/// instead of a rectangle `leg` long.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<Vec<Vec2>>,
/// Which feature made it: `F<feature>.<k>` (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<usize>, a: Vec2, b: Vec2, top: bool) -> Option<String> {
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<String> {
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<Vec3> {
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<usize>, plane: &Plane, r: &Region2) -> Option<Region2> {
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<String> {
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()
}
+4
View File
@@ -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 = {
+29
View File
@@ -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);
}