diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 778db358..c63bb19e 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -325,6 +325,21 @@ fn sheet_for(st: &ModelState, body: &Option, pick: Option) -> 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())); diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index a67636fc..e4520911 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -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"); +}