fix(sheet): refuse a fold's fixed point far above or below the sheet (#371) (#538)

sheet.fold tests its fixed point against the whole sheet body, so a point far above or below the sheet is refused atomically, while a point on any face (including a flange's leg) still works. A stored fold whose point ends up in the air recomputes, keeps its side and warns. Un-ignores the #371 regression test. Fixes #371.

Co-authored-by: jonaswre <[email protected]>
This commit is contained in:
Jonas Wrede authored and GitHub committed 2026-10-13 04:20:16 -04:00
1 parent 9c94e1b96d
commit dc060f8b86
2 files changed
+68 -1

No files matched your search

+28
View File
@@ -325,6 +325,21 @@ fn sheet_for(st: &ModelState, body: &Option<String>, pick: Option<Vec3>) -> Resu
.ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))
}
/// Whether a point touches sheet `i`'s material: inside it, on one of its faces, or within its
/// display tolerance of one (picks land on the display mesh). A sheet with no mesh to test
/// against counts as touched.
fn on_sheet_material(st: &ModelState, i: usize, p: Vec3) -> bool {
let Some(mb) = st.sheets.get(i).and_then(|sh| st.body(&sh.body)) else { return true };
let mesh = mb.mesh();
if mesh.triangles.is_empty() {
return true;
}
// Twice the display mesh's chord tolerance (see `ModelBody::mesh`).
let step = 2.0 * (mb.body.size() * 1e-3).max(1e-3);
let offsets = [-step, 0.0, step];
offsets.iter().any(|&dx| offsets.iter().any(|&dy| offsets.iter().any(|&dz| mesh.contains(p + Vec3::new(dx, dy, dz)))))
}
/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines.
fn mirror_y(r: &Region2) -> Region2 {
let f = |p: Vec2| Vec2::new(p.x, -p.y);
@@ -500,6 +515,19 @@ pub(super) fn sheet_eval(
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
}
};
// A fixed point that touches no material at all (e.g. far above the sheet) still picks
// the side it lies on, so a stored fold keeps recomputing; it says so (#371).
if let Some(p) = fixed
&& !on_sheet_material(st, i, *p)
{
*warning = Some(format!(
"{} at ({:.3}, {:.3}, {:.3}); the side of the fold line it lies on stays",
sheet::FOLD_FIXED_OFF_SHEET,
p.x,
p.y,
p.z
));
}
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
if sh.flat {
return Err(DocError::Invalid("refold the sheet before folding it".into()));
+40 -1
View File
@@ -1990,7 +1990,6 @@ fn fold_keeps_working_when_a_parameter_moves_the_sheet_off_its_fixed_point() {
/// `fixed` 20 mm above a 2.5 mm sheet touches no material: refuse it or say so, like a point in a
/// cut-out (#371).
#[test]
#[ignore = "fails until #371 is fixed"]
fn fold_fixed_point_above_the_sheet_is_not_silently_accepted() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
@@ -2626,3 +2625,43 @@ fn fold_refuses_a_new_fixed_point_off_the_sheet_but_warns_on_recompute() {
assert!(fold["error"].is_null(), "{fold}");
assert!(fold["warning"].as_str().is_some_and(|w| w.contains("fixed point is not on the sheet") && w.contains("stays")), "{fold}");
}
/// Points away from the sheet are refused atomically; points on any face of it, on a flange's
/// leg, or a hair off a face still pick the side that stays (#371).
#[test]
fn fold_fixed_point_must_touch_the_sheet_but_any_face_will_do() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
let before = s.doc.to_json();
let undo = s.undo.len();
for fixed in [json!([90, 30, 20]), json!([90, 30, -10])] {
let e = s.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": fixed})).unwrap_err().to_string();
assert!(e.contains("fixed") && e.contains("not on the sheet"), "{fixed}: {e}");
assert_eq!(s.doc.to_json(), before);
assert_eq!(s.undo.len(), undo);
}
// The left (smaller) side stays: the right part stands up.
for fixed in [json!([10, 30, 2.5]), json!([10, 30, 0]), json!([0, 30, 1.25]), json!([10, 30, 2.500001])] {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": fixed, "name": "Fold1"}));
let bb = bbox(&mut s);
assert!(bb["min"][0].as_f64().unwrap_or(f64::NAN).abs() < 1e-6 && bb["max"][0].as_f64().unwrap_or(f64::NAN) < 50.0, "{fixed}: {bb}");
assert!(run(&mut s, "timeline.inspect", json!({"feature": "Fold1"}))["warning"].is_null(), "{fixed}");
}
// A point on an edge flange's leg (far above the base panel's slab) is on the sheet.
let mut f = Session::default();
run(&mut f, "parameters.change", json!({"name": "h", "expression": "20 mm"}));
base(&mut f, 100.0, 60.0);
run(&mut f, "sheet.flange", json!({"edges": [[100, 30, 2.5]], "height": "h"}));
let bb = bbox(&mut f);
let x = bb["max"][0].as_f64().unwrap_or(f64::NAN);
run(&mut f, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [x, 30, 15], "name": "Fold1"}));
assert!(run(&mut f, "timeline.inspect", json!({"feature": "Fold1"}))["warning"].is_null());
// A shorter leg leaves the stored point in the air: the fold recomputes, keeps its side and warns.
run(&mut f, "parameters.change", json!({"name": "h", "expression": "10 mm"}));
let fold = run(&mut f, "timeline.inspect", json!({"feature": "Fold1"}));
assert!(fold["error"].is_null(), "{fold}");
assert!(fold["warning"].as_str().is_some_and(|w| w.contains("fixed point is not on the sheet")), "{fold}");
assert!((bbox(&mut f)["max"][0].as_f64().unwrap_or(f64::NAN) - x).abs() < 1e-6, "the right side stays");
}