From 4cbf9cf6074c663e28ad6840ad38a2906a4a71c7 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:22:31 +0200 Subject: [PATCH] Sheet metal: an Edge Flange can choose its own corner relief (#1481) An Edge Flange feature can set the shape and size of the relief at the corners its flanges make, in place of the sheet metal rule's. Design format 24. Co-authored-by: NOWN-DEV Co-Authored-By: Claude Sonnet 5.5 --- assets/locales/de.json | 5 + assets/locales/en.json | 5 + assets/locales/fr.json | 5 + assets/locales/ja.json | 5 + assets/locales/ko.json | 5 + assets/locales/ru.json | 5 + assets/locales/uk.json | 5 + assets/locales/zh.json | 5 + crates/doc/src/document.rs | 9 + crates/doc/src/eval_more.rs | 18 +- crates/doc/src/feature_walk_tests/fixtures.rs | 2 +- .../src/feature_walk_tests/golden_exprs.txt | 3 +- crates/doc/src/format.rs | 49 +++- crates/doc/src/sheet.rs | 59 +++-- crates/doc/src/sheet_convert.rs | 4 +- crates/engine/src/cmd/sheet.rs | 55 ++++- crates/engine/src/cmd/sheet_tests.rs | 220 ++++++++++++++++++ crates/ui-egui/src/dialogs_sheet.rs | 35 ++- crates/ui-egui/src/dialogs_sheet_tests.rs | 72 ++++++ docs/book/src/guide/files.md | 2 +- docs/book/src/guide/sheet-metal.md | 16 ++ docs/design-container.md | 2 +- 22 files changed, 543 insertions(+), 43 deletions(-) diff --git a/assets/locales/de.json b/assets/locales/de.json index a3e96c2b..5e916456 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -1011,6 +1011,11 @@ "Inside": "Innen", "Outside": "Außen", "Profiles/Curves": "Profile/Kurven", + "Corner Relief": "Eckenentlastung", + "Notch": "Ausklinkung", + "Round": "Rund", + "Rectangular": "Rechteckig", + "Tear": "Reißen", "update.button": "Update", "update.button_tip": "SolidCraft {version} ist verfügbar", "install.menu": "App installieren…", diff --git a/assets/locales/en.json b/assets/locales/en.json index 6c392dd4..8971a167 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -977,7 +977,12 @@ "Copy all licences": "Copy all licences", "Copy diagnostic information": "Copy diagnostic information", "Copy report": "Copy report", + "Corner Relief": "Corner Relief", "Corner Relief Shape": "Corner Relief Shape", + "Notch": "Notch", + "Round": "Round", + "Rectangular": "Rectangular", + "Tear": "Tear", "Counterbore depth": "Counterbore depth", "Countersink angle": "Countersink angle", "Create Issue — review draft": "Create Issue — review draft", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index 2cfea4c4..4ea27181 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -824,7 +824,12 @@ "Copy all licences": "Copier toutes les licences", "Copy diagnostic information": "Copier les informations de diagnostic", "Copy report": "Copier le rapport", + "Corner Relief": "Dégagement d'angle", "Corner Relief Shape": "Forme du dégagement d'angle", + "Notch": "Encoche", + "Round": "Rond", + "Rectangular": "Rectangulaire", + "Tear": "Déchirure", "Counterbore depth": "Profondeur du lamage", "Countersink angle": "Angle de la fraisure", "Create Issue — review draft": "Créer un ticket — relire le brouillon", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index d338ae8e..abc53ec2 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -977,7 +977,12 @@ "Copy all licences": "すべてのライセンスをコピー", "Copy diagnostic information": "診断情報をコピー", "Copy report": "レポートをコピー", + "Corner Relief": "コーナーリリーフ", "Corner Relief Shape": "コーナーリリーフ形状", + "Notch": "ノッチ", + "Round": "円形", + "Rectangular": "長方形", + "Tear": "引き裂き", "Counterbore depth": "座ぐり深さ", "Countersink angle": "皿穴角度", "Create Issue — review draft": "Issueを作成 — 下書きの確認", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 4d5596fb..ccde0722 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -1011,6 +1011,11 @@ "Inside": "안쪽", "Outside": "바깥쪽", "Profiles/Curves": "프로파일/곡선", + "Corner Relief": "모서리 릴리프", + "Notch": "노치", + "Round": "원형", + "Rectangular": "직사각형", + "Tear": "찢김", "update.button": "업데이트", "update.button_tip": "SolidCraft {version} 사용 가능", "install.menu": "앱 설치…", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index 70822a6a..b91b2e89 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -795,7 +795,12 @@ "Copy all licences": "Копировать все лицензии", "Copy diagnostic information": "Копировать диагностическую информацию", "Copy report": "Копировать отчёт", + "Corner Relief": "Разгрузка угла", "Corner Relief Shape": "Форма разгрузки угла", + "Notch": "Вырез", + "Round": "Круглая", + "Rectangular": "Прямоугольная", + "Tear": "Разрыв", "Counterbore depth": "Глубина цековки", "Countersink angle": "Угол зенковки", "Create Issue — review draft": "Создать Issue — проверить черновик", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index fd32c912..2e8234ef 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -741,7 +741,12 @@ "Copy all licences": "Копіювати всі ліцензії", "Copy diagnostic information": "Копіювати діагностичну інформацію", "Copy report": "Копіювати звіт", + "Corner Relief": "Полегшення кута", "Corner Relief Shape": "Форма полегшення кута", + "Notch": "Виріз", + "Round": "Круглий", + "Rectangular": "Прямокутний", + "Tear": "Розрив", "Counterbore depth": "Глибина циліндричного розширення", "Countersink angle": "Кут зенкування", "Create Issue — review draft": "Створити повідомлення про проблему — перегляд чернетки", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index bc14ab2b..6c79eb62 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -975,7 +975,12 @@ "Copy all licences": "复制所有许可证", "Copy diagnostic information": "复制诊断信息", "Copy report": "复制报告", + "Corner Relief": "拐角释放", "Corner Relief Shape": "拐角释放形状", + "Notch": "缺口", + "Round": "圆形", + "Rectangular": "矩形", + "Tear": "撕裂", "Counterbore depth": "沉孔深度", "Countersink angle": "沉头角度", "Create Issue — review draft": "创建 Issue——审阅草稿", diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 89394a89..0463746a 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1013,6 +1013,15 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] #[walk(body)] body: Option, + /// The relief cut where two of this feature's bends meet at a corner of the base, in place + /// of the rule's: `corner_relief_shape` (notch | round | rectangular | tear) and + /// `corner_relief`, the size across. Either one left out follows the rule. + #[serde(default, skip_serializing_if = "Option::is_none")] + corner_relief_shape: Option, + /// Walked last, after the edges' values, so the inputs before it keep their names. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[walk(length = "Corner relief")] + corner_relief: Option, }, /// Flat hems on sheet edges. SheetHem { diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 6cebb3ee..e2407d87 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -449,7 +449,7 @@ pub(super) fn sheet_eval( st.sheets.push(sh); Ok(()) } - FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => { + FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body, corner_relief_shape, corner_relief } => { let i = sheet_for(st, body, edges.first().map(|e| e.point))?; let (h, a) = (val(vals, height, Kind::Length)?, val(vals, angle, Kind::Angle)?); // Each edge's own height and angle, else the feature's. @@ -472,6 +472,20 @@ pub(super) fn sheet_eval( doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius } }; + // The corner relief these flanges ask for in place of the rule's. + let relief = sheet::ReliefOverride { + shape: match corner_relief_shape { + Some(name) => Some( + sheet::ReliefShape::parse(name) + .ok_or_else(|| DocError::Invalid(format!("unknown corner relief shape `{name}` (notch, round, rectangular or tear)")))?, + ), + None => None, + }, + size: match corner_relief { + Some(e) => Some(val(vals, e, Kind::Length)?), + None => None, + }, + }; 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 adding flanges".into())); @@ -483,7 +497,7 @@ pub(super) fn sheet_eval( .map(|(pick, (height, angle))| sheet::EdgeFlange { pick, height, angle }) .collect(); let n0 = sh.flanges.len(); - let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip)?; + let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip, relief)?; if !notes.is_empty() { let notes = notes.join("; "); *warning = Some(match warning.take() { diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs index 1b388a61..eaf315dc 100644 --- a/crates/doc/src/feature_walk_tests/fixtures.rs +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -114,7 +114,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> { ("sheet_contour", json!({"type": "sheet_contour", "sketch": 1, "curves": ["l1"], "distance": "contour.distance"})), ( "sheet_flange", - json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius"}), + json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius", "corner_relief_shape": "round", "corner_relief": "flange.relief"}), ), ("sheet_hem", json!({"type": "sheet_hem", "edges": [o], "length": "hem.length", "gap": "hem.gap"})), ( diff --git a/crates/doc/src/feature_walk_tests/golden_exprs.txt b/crates/doc/src/feature_walk_tests/golden_exprs.txt index ace4b70d..c21df323 100644 --- a/crates/doc/src/feature_walk_tests/golden_exprs.txt +++ b/crates/doc/src/feature_walk_tests/golden_exprs.txt @@ -243,7 +243,8 @@ sheet_flange Edge angle = flange.e1_angle (Angle) Edge height = flange.e1_height (Length) Edge height = flange.e2_height (Length) - expressions: flange.height, flange.angle, flange.radius, flange.e1_angle, flange.e1_height, flange.e2_height + Corner relief = flange.relief (Length) + expressions: flange.height, flange.angle, flange.radius, flange.e1_angle, flange.e1_height, flange.e2_height, flange.relief sheet_hem inputs: Length = hem.length (Length) diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 284d9bf5..1286678b 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -233,13 +233,18 @@ //! read through cadmpeg); its `step` is still the STEP text the file was restated as, so the //! feature's fields are unchanged and version 22 designs need no transformation; 0.13.x asks //! for an update before it meets these imports. +//! - 24: an Edge Flange feature can choose its own corner relief (`corner_relief_shape`: notch, +//! round, rectangular or tear, and `corner_relief`, the size across), for the corners its +//! flanges make. Version 23 designs need no transformation: a feature without them follows the +//! sheet metal rule, as it always did, and saves as before; 0.13.0 (which reads up to 22) asks +//! for an update instead of silently building the rule's relief where a design asks for another. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 23; +pub const FORMAT: u32 = 24; pub fn format_string(n: u32) -> String { format!("solidcraft/{n}") @@ -256,7 +261,7 @@ pub fn version_of(s: &str) -> Option { /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. type JsonStep = fn(&mut Value) -> Result<()>; -const JSON_STEPS: [JsonStep; 22] = [ +const JSON_STEPS: [JsonStep; 23] = [ |_| Ok(()), v2_to_v3, v3_to_v4, @@ -279,6 +284,7 @@ const JSON_STEPS: [JsonStep; 22] = [ |_| Ok(()), |_| Ok(()), |_| Ok(()), + |_| Ok(()), ]; /// Stored face and edge names were worked out from geometry before format 14; they get the @@ -1011,6 +1017,45 @@ fn normalise(d: &mut Document) -> Result<()> { mod tests { use super::*; + /// 24 added the corner relief of an edge flange: a no-op step carries a 23 design up, a flange + /// without a relief of its own saves as before, and one with it is saved under 24 and kept. + #[test] + fn version_twenty_three_designs_open_and_an_edge_flange_relief_is_saved_in_twenty_four() { + assert_eq!(JSON_STEPS.len(), FORMAT as usize - 1, "one upgrade step for each format"); + let flange = |shape: Option<&str>, size: Option<&str>| crate::FeatureKind::SheetFlange { + edges: vec![crate::FlangeEdge::at(solidcraft_geom::Vec3::ZERO)], + height: "10 mm".into(), + angle: "90 deg".into(), + radius: None, + position: "inside".into(), + flip: false, + body: None, + corner_relief_shape: shape.map(Into::into), + corner_relief: size.map(Into::into), + }; + let saved = |document: &Document| -> Value { serde_json::from_str(&document.to_json()).unwrap() }; + // A flange that follows the rule saves neither key, and a design of format 23 opens as it was. + let mut plain = Document::new("A"); + plain.add_feature(flange(None, None), None).unwrap(); + let mut file = saved(&plain); + let feature = file["features"].as_array().and_then(|f| f.last()).cloned().unwrap(); + assert_eq!(feature["type"], "sheet_flange"); + assert!(feature.get("corner_relief_shape").is_none() && feature.get("corner_relief").is_none(), "{feature}"); + file["format"] = Value::from("solidcraft/23"); + let opened = read(&file.to_string()).unwrap(); + assert_eq!(opened.format, format_string(FORMAT)); + assert_eq!(opened.features, plain.features); + // One that chooses its relief says so in the file, which is stamped 24 and keeps it. + let mut own = Document::new("A"); + own.add_feature(flange(Some("round"), Some("6 mm")), None).unwrap(); + let file = saved(&own); + let feature = file["features"].as_array().and_then(|f| f.last()).cloned().unwrap(); + assert_eq!((&feature["corner_relief_shape"], &feature["corner_relief"]), (&Value::from("round"), &Value::from("6 mm"))); + // A build that reads up to 23 asks for an update on that stamp instead of dropping the relief. + assert_eq!(version_of(file["format"].as_str().unwrap_or_default()), Some(FORMAT)); + assert_eq!(read(&file.to_string()).unwrap(), own); + } + #[test] fn version_thirteen_names_keep_their_meaning_under_the_legacy_mark() { let file = serde_json::json!({ diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 18d647f7..6538b3bb 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -361,6 +361,15 @@ impl ReliefShape { } } +/// The corner relief an edge flange feature asks for in place of the rule's. A value left out +/// follows the rule. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct ReliefOverride { + pub shape: Option, + /// How far across the relief is (mm). + pub size: Option, +} + /// A rule's values (mm, unit-less K). #[derive(Clone, Copy, Debug, PartialEq, Serialize)] pub struct RuleValues { @@ -2151,7 +2160,7 @@ impl SheetBody { flip: bool, ) -> Result<(Vec, Vec)> { let each: Vec = picks.iter().map(|&pick| EdgeFlange { pick, height, angle }).collect(); - self.add_flanges_each(&each, radius, position, flip) + self.add_flanges_each(&each, radius, position, flip, ReliefOverride::default()) } /// Edge flanges that each have their own height and angle, in one go: every edge is trimmed @@ -2164,8 +2173,16 @@ impl SheetBody { /// the base that ends beside more of the base gets the rule's bend relief there /// (`relieve_bend_ends`). Where cut-outs have taken part of a picked edge away, the flange /// runs along the stretch they leave nearest the pick and takes its setback off that stretch - /// only; an edge they took whole is refused. - pub fn add_flanges_each(&mut self, edges: &[EdgeFlange], radius: f64, position: &str, flip: bool) -> Result<(Vec, Vec)> { + /// only; an edge they took whole is refused. The corners between these flanges are relieved + /// as the rule says, except where `relief` names another shape or size. + pub fn add_flanges_each( + &mut self, + edges: &[EdgeFlange], + radius: f64, + position: &str, + flip: bool, + relief: ReliefOverride, + ) -> Result<(Vec, Vec)> { if edges.is_empty() || edges.len() > 1000 { return Err(DocError::Invalid("pick 1…1000 edges".into())); } @@ -2293,7 +2310,7 @@ impl SheetBody { for (&i, &free) in made.iter().zip(&freed) { notes.extend(self.relieve_bend_ends(i, free)); } - notes.extend(self.close_corners(&made)); + notes.extend(self.close_corners(&made, relief)); notes.extend(self.close_return_corners(&made)); Ok((made, notes)) } @@ -2711,9 +2728,10 @@ impl SheetBody { out } - /// Close the corners the new flanges make, then cut the rule's relief at each (or tear it). - /// Returns a note for every corner left open, without its relief or not torn. - fn close_corners(&mut self, made: &[usize]) -> Vec { + /// Close the corners the new flanges make, then cut the relief at each (or tear it): the + /// rule's, but for the shape or size `relief` names. Returns a note for every corner left + /// open, without its relief or not torn. + fn close_corners(&mut self, made: &[usize], relief: ReliefOverride) -> Vec { let corners = self.corners(made); let mut notes = Vec::new(); let mut closed = Vec::with_capacity(corners.len()); @@ -2723,7 +2741,7 @@ impl SheetBody { Err(why) => notes.push(format!("the flanges at {} are left open: {why}", self.shown(k.c))), } } - notes.extend(self.relieve_corners(&corners, &closed)); + notes.extend(self.relieve_corners(&corners, &closed, relief)); notes } @@ -2733,16 +2751,18 @@ impl SheetBody { format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z) } - /// Cut the rule's relief at each of `corners`, or tear those of them that are `closed`. - /// Returns a note for every corner without its relief or not torn. - fn relieve_corners(&mut self, corners: &[Corner], closed: &[Corner]) -> Vec { + /// Cut the relief at each of `corners`, or tear those of them that are `closed`: the rule's, + /// but for the shape or size `relief` names. Returns a note for every corner without its + /// relief or not torn. + fn relieve_corners(&mut self, corners: &[Corner], closed: &[Corner], relief: ReliefOverride) -> Vec { let mut notes = Vec::new(); - match self.relief { + let (shape, size) = (relief.shape.unwrap_or(self.relief), relief.size.unwrap_or(self.relief_size)); + match shape { ReliefShape::Notch => {} // The reliefs come last: they move the bends' ends, which is how the corners were found. ReliefShape::Round | ReliefShape::Rectangular => { for k in corners { - if let Err(why) = self.relieve_corner(k, corners) { + if let Err(why) = self.relieve_corner(k, corners, shape, size) { notes.push(format!("no corner relief at {}: {why}", self.shown(k.c))); } } @@ -2918,7 +2938,7 @@ impl SheetBody { Ok(()) } - /// Cut the rule's relief where two bends meet at a corner of the base, `relief_size / 2` + /// Cut a relief `size` across where two bends meet at a corner of the base, `size / 2` /// from the corner along both edges: a round about the corner through the angle the base /// fills there, or the square with that side (a kite where the corner isn't square, its /// cuts square to the two edges). The base's edges then start that far from the corner, so @@ -2928,8 +2948,7 @@ impl SheetBody { /// that would leave nothing of an edge it starts on (with the relief at that edge's other /// end, where there is one), or one reaching as far from the corner as another edge of the /// base is, is refused with the reason. - fn relieve_corner(&mut self, k: &Corner, corners: &[Corner]) -> std::result::Result<(), String> { - let size = self.relief_size; + fn relieve_corner(&mut self, k: &Corner, corners: &[Corner], shape: ReliefShape, size: f64) -> std::result::Result<(), String> { if !(size >= MIN_CORNER_RELIEF) { return Err(format!("the relief size {size:.4} mm is below {MIN_CORNER_RELIEF} mm")); } @@ -2961,7 +2980,7 @@ impl SheetBody { } // The relief from the edge that ends at the corner to the one that starts there, and // how far from the corner it reaches. - let (relief, reach) = match self.relief { + let (relief, reach) = match shape { ReliefShape::Notch | ReliefShape::Tear => return Ok(()), ReliefShape::Round => (vec![Seg2::Arc { center: c, radius: r, start: da.angle(), sweep: -alpha }], r), ReliefShape::Rectangular => { @@ -6075,7 +6094,7 @@ mod tests { /// sheet and the notes on corners left open. fn flanges_each(base: Region2, rv: &RuleValues, each: &[EdgeFlange]) -> (SheetBody, Vec) { let mut s = base_flange("Body1", "AL2", rv, Plane::XY, base); - let notes = s.add_flanges_each(each, rv.bend_radius, "inside", false).unwrap().1; + let notes = s.add_flanges_each(each, rv.bend_radius, "inside", false, ReliefOverride::default()).unwrap().1; (s, notes) } @@ -6630,7 +6649,7 @@ mod tests { let (mut s, _) = tray(&rv, 90.0, 20.0); let each: Vec = (0..4).map(|i| EdgeFlange { pick: far_edge_pick(&s, i, true), height: 12.0, angle: [90f64, 70.0][i % 2].to_radians() }).collect(); - let (made, notes) = s.add_flanges_each(&each, 1.0, "inside", false).unwrap(); + let (made, notes) = s.add_flanges_each(&each, 1.0, "inside", false, ReliefOverride::default()).unwrap(); assert!(notes.is_empty() && made == [4, 5, 6, 7], "{made:?} {notes:?}"); for (a, a_end, b, b_end) in TRAY_RETURNS { let (fa, fb) = (&s.flanges[a], &s.flanges[b]); @@ -6803,7 +6822,7 @@ mod tests { EdgeFlange { pick: far_edge_pick(&s, i, true), height: 12.0, angle: deg.to_radians() } }) .collect(); - let (_, notes) = s.add_flanges_each(&each, 1.0, "inside", false).unwrap(); + let (_, notes) = s.add_flanges_each(&each, 1.0, "inside", false, ReliefOverride::default()).unwrap(); assert!(notes.is_empty(), "{what}: {notes:?}"); returns_keep_out_of_the_walls(&s, &what); holds_the_flat_material(&s, &what); diff --git a/crates/doc/src/sheet_convert.rs b/crates/doc/src/sheet_convert.rs index 82add169..660dae18 100644 --- a/crates/doc/src/sheet_convert.rs +++ b/crates/doc/src/sheet_convert.rs @@ -53,7 +53,7 @@ use std::f64::consts::{FRAC_PI_2, PI, TAU}; use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3}; use solidcraft_kernel::{Body, FaceSurface, face_surface}; -use super::{FlatWork, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap}; +use super::{FlatWork, ReliefOverride, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap}; use crate::{DocError, ModelBody, Result, apply_point, mat_inverse}; const MAX_BENDS: usize = 1000; @@ -1002,7 +1002,7 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben Err(why) => notes.push(format!("the corner at {} stays cut open: {why}", sh.shown(k.c))), } } - notes.extend(sh.relieve_corners(&corners, &closed)); + notes.extend(sh.relieve_corners(&corners, &closed, ReliefOverride::default())); Ok((sh, notes)) } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 396e2f9f..0d0d55a1 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -17,8 +17,11 @@ pub static COMMANDS: &[CommandSpec] = &[ "base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \ edge: edges: [[x,y,z] on top/bottom edges, or {point: [x,y,z], angle?, height?} for an edge with its own angle and height] + \ height (to the outer face), angle? (default 90 deg), radius? (default: rule), \ - position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body). \ - Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused", + position?: inside|outside|middle, flip?, body?, corner_relief_shape?: notch|round|rectangular|tear and corner_relief? (size across): \ + the relief at the corners these flanges make (default: the rule's); \ + type?: base|edge|contour (default from the inputs); name?, body_name? (the new body). \ + Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused, \ + and so is the corner relief on a base or contour flange", ), CommandSpec::new("sheet.perforate", "Perforate", perforate).at("SHEET METAL", "CREATE").icon("hem").params( "diameter, pitch: expr; margin?: expr (clear of edges, cut-outs and bends; default: the diameter); pattern?: staggered (60°, default) | square; body? - a perforation field over the whole flat sheet: holes in the flat pattern (perforations), the DXF (layer PERFORATION) and the measured volume, not cut into the B-rep (#732)", @@ -133,10 +136,20 @@ fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result> { const RULE_ONLY: [&str; 8] = ["thickness", "k_factor", "bend_radius", "relief_width", "relief_depth", "corner_relief", "corner_relief_shape", "hem_gap"]; +/// The two rule values an edge flange may set for itself: the corner relief. +const EDGE_RELIEF: [&str; 2] = ["corner_relief", "corner_relief_shape"]; + fn flange(s: &mut Session, p: &Value) -> Result { let cmd = "sheet.flange"; let nested = p.get("base").filter(|b| b.is_object()); - if let Some(key) = RULE_ONLY.iter().find(|k| p.get(**k).is_some() || nested.is_some_and(|b| b.get(**k).is_some())) { + let ty = match str_(p, "type") { + Some(t) => t.to_ascii_lowercase(), + None if p.get("edges").is_some() => "edge".into(), + None if p.get("curves").is_some() => "contour".into(), + None => "base".into(), + }; + let refused = |k: &&&str| !(ty == "edge" && EDGE_RELIEF.contains(*k)); + if let Some(key) = RULE_ONLY.iter().filter(refused).find(|k| p.get(**k).is_some() || nested.is_some_and(|b| b.get(**k).is_some())) { return Err(bad( cmd, format!( @@ -145,12 +158,6 @@ fn flange(s: &mut Session, p: &Value) -> Result { ), )); } - let ty = match str_(p, "type") { - Some(t) => t.to_ascii_lowercase(), - None if p.get("edges").is_some() => "edge".into(), - None if p.get("curves").is_some() => "contour".into(), - None => "base".into(), - }; match ty.as_str() { "base" => { let sketch = feature_sketch(s, p, cmd)?; @@ -194,7 +201,35 @@ fn flange(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "`position` must be inside, outside or middle")); } let body = str_(p, "body").map(str::to_string); - add_feature(s, p, FeatureKind::SheetFlange { edges, height, angle, radius, position, flip: bool_(p, "flip").unwrap_or(false), body }) + // The corner relief these flanges make, in place of the rule's; null is not given. + let corner_relief_shape = match p.get("corner_relief_shape").filter(|v| !v.is_null()) { + Some(v) => Some( + v.as_str() + .and_then(solidcraft_doc::sheet::ReliefShape::parse) + .map(|shape| shape.name().to_string()) + .ok_or_else(|| bad(cmd, "`corner_relief_shape` must be notch, round, rectangular or tear"))?, + ), + None => None, + }; + let corner_relief = expr(p, "corner_relief"); + if let Some(c) = &corner_relief { + check_expr(s, c, Kind::Length, cmd, "corner_relief")?; + } + add_feature( + s, + p, + FeatureKind::SheetFlange { + edges, + height, + angle, + radius, + position, + flip: bool_(p, "flip").unwrap_or(false), + body, + corner_relief_shape, + corner_relief, + }, + ) } other => Err(bad(cmd, format!("unknown flange type `{other}` (base, edge or contour)"))), } diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index a48f4254..96f53ba0 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -3082,6 +3082,226 @@ fn the_corner_relief_shape_is_a_rule_setting_checked_and_undone() { assert!(w.contains("would take a whole bend away") && volume(&mut s) == v0, "{w}"); } +/// A 120 x 90 base in 2 mm aluminium (R 1, K 0.5, gap 0.5; `rule` adds to its settings) with two edge +/// flange features, 20 high at 90°: `near` on the bottom and left edges, which meet at the corner at +/// the origin, and `far` on the top and right edges, which meet at the corner at (120, 90). The two +/// objects hold what each feature is given besides its edges. +fn two_flange_groups(rule: Value, near: Value, far: Value) -> Session { + let mut s = Session::default(); + let mut r = json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}); + if let (Some(r), Some(extra)) = (r.as_object_mut(), rule.as_object()) { + r.extend(extra.clone()); + } + run(&mut s, "sheet.manage_rules", r); + base(&mut s, 120.0, 90.0); + for (edges, own) in [(json!([[60, 0, 2], [0, 45, 2]]), near), (json!([[60, 90, 2], [120, 45, 2]]), far)] { + let mut p = json!({"edges": edges, "height": 20, "angle": "90 deg"}); + if let (Some(p), Some(own)) = (p.as_object_mut(), own.as_object()) { + p.extend(own.clone()); + } + run(&mut s, "sheet.flange", p); + } + s +} + +/// What the DXF of the flat pattern draws of the corner reliefs: the arcs (centre, radius) and the +/// lines' end points. +fn relief_drawing(s: &mut Session) -> (Vec<(solidcraft_geom::Vec2, f64)>, Vec) { + let dxf = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); + let read = solidcraft_io::read_dxf(&dxf).unwrap_or_default(); + let arcs = read.iter().filter_map(|g| if let solidcraft_io::Geom2::Arc { c, a, .. } = g { Some((*c, a.dist(*c))) } else { None }).collect(); + let ends = read.iter().flat_map(|g| if let solidcraft_io::Geom2::Line(a, b) = g { vec![*a, *b] } else { Vec::new() }).collect(); + (arcs, ends) +} + +#[test] +fn each_edge_flange_feature_chooses_the_corner_relief_of_its_own_corners() { + use solidcraft_geom::Vec2; + // At 90° the bends of a corner meet at (s0, s0) in from the sheet's edges, s0 = (R + T)·tan 45° = 3, + // and are BA = π/2·(R + K·T) = π wide. A relief d across reaches r = d/2 along both bends and takes, + // at its corner, r²·π/4 (round) or r² (rectangular) of the base and r of each bend, 2 mm thick. + let (s0, ba) = (3.0, PI); + let at = |ends: &[Vec2], x: f64, y: f64| ends.iter().any(|p| p.dist(Vec2::new(x, y)) < 1e-6); + // The rule says notch, as it does by default: the corners are not relieved. + let mut plain = two_flange_groups(json!({}), json!({}), json!({})); + assert!(no_errors_or_warnings(&plain), "{:?}", plain.model.results); + let v0 = volume(&mut plain); + assert!(relief_drawing(&mut plain).0.is_empty()); + // The flanges at the origin ask for a round 6 mm across, those at (120, 90) for a rectangular 3 mm. + let near = json!({"corner_relief_shape": "round", "corner_relief": "6 mm"}); + let far = json!({"corner_relief_shape": "Rectangular", "corner_relief": "3 mm"}); + let mut s = two_flange_groups(json!({}), near, far); + assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); + let listed = run(&mut s, "sheet.rules", json!({})); + let rule = &listed["rules"][0]; + assert_eq!((&rule["rule"]["corner_relief_shape"], &rule["values"]["corner_relief_shape"]), (&json!("notch"), &json!("notch"))); + // The folded part loses what the two reliefs take. + let (v, want) = (volume(&mut s), ((PI * 9.0 / 4.0 + 6.0 * ba) + (1.5 * 1.5 + 3.0 * ba)) * 2.0); + assert!((v0 - v - want).abs() < 0.1, "{v0} - {v} vs {want}"); + // The DXF: one arc of radius 3 about the corner at the origin and nothing round at the other corner; + // there the relief is a kite ending 1.5 mm in from the corner of the bends, and none at the origin. + let (arcs, ends) = relief_drawing(&mut s); + assert!(arcs.len() == 1 && arcs[0].0.dist(Vec2::new(s0, s0)) < 1e-6 && (arcs[0].1 - 3.0).abs() < 1e-6, "{arcs:?}"); + assert!(at(&ends, 120.0 - s0 - 1.5, 90.0 - s0 - 1.5), "the corner at (120, 90) is relieved with a rectangular relief"); + assert!(!at(&ends, s0 + 1.5, s0 + 1.5), "the corner at the origin is not"); + // Unfolding, refolding and reopening the design keep both. + run(&mut s, "sheet.unfold", json!({})); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - v).abs() < 1e-6 * v); + let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((volume(&mut reopened) - v).abs() < 1e-6 * v); + assert_eq!(relief_drawing(&mut reopened).0.len(), 1); + // The size is an input of the feature like its height: another size moves the relief. + let near_feature = s.doc.features.iter().find(|f| matches!(f.kind, solidcraft_doc::FeatureKind::SheetFlange { .. })); + let names = near_feature.map(|f| f.param_names.clone()).unwrap_or_default(); + assert_eq!(names.len(), 3, "height, angle and the relief: {names:?}"); + run(&mut s, "parameters.change", json!({"name": names[2], "expression": "4 mm"})); + let arcs = relief_drawing(&mut s).0; + assert!(arcs.len() == 1 && (arcs[0].1 - 2.0).abs() < 1e-6, "{arcs:?}"); +} + +#[test] +fn an_edge_flange_feature_follows_the_rule_for_the_relief_it_leaves_out() { + use solidcraft_geom::Vec2; + let s0 = 3.0; + let at = |ends: &[Vec2], x: f64, y: f64| ends.iter().any(|p| p.dist(Vec2::new(x, y)) < 1e-6); + let round8 = json!({"corner_relief_shape": "round", "corner_relief": "8 mm"}); + // The rule says a round 8 mm across. A feature that asks for nothing gets it, one that asks for a + // size alone keeps the rule's shape. + let mut s = two_flange_groups(round8.clone(), json!({"corner_relief": "6 mm"}), json!({})); + assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); + let mut arcs = relief_drawing(&mut s).0; + arcs.sort_by(|a, b| a.1.total_cmp(&b.1)); + assert_eq!(arcs.len(), 2, "{arcs:?}"); + assert!(arcs[0].0.dist(Vec2::new(s0, s0)) < 1e-6 && (arcs[0].1 - 3.0).abs() < 1e-6, "{arcs:?}"); + assert!(arcs[1].0.dist(Vec2::new(120.0 - s0, 90.0 - s0)) < 1e-6 && (arcs[1].1 - 4.0).abs() < 1e-6, "{arcs:?}"); + // A shape alone keeps the rule's size. + let mut s = two_flange_groups(round8, json!({"corner_relief_shape": "rectangular"}), json!({})); + assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); + let (arcs, ends) = relief_drawing(&mut s); + assert!(arcs.len() == 1 && arcs[0].0.dist(Vec2::new(120.0 - s0, 90.0 - s0)) < 1e-6 && (arcs[0].1 - 4.0).abs() < 1e-6, "{arcs:?}"); + assert!(at(&ends, s0 + 4.0, s0 + 4.0), "a kite 8 mm across at the origin"); + // A change of the rule still reaches the feature that asks for nothing. + run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief": "6 mm"})); + let arcs = relief_drawing(&mut s).0; + assert!(arcs.len() == 1 && (arcs[0].1 - 3.0).abs() < 1e-6, "{arcs:?}"); +} + +#[test] +fn an_edge_flange_feature_can_tear_its_corners_under_a_notch_rule() { + use std::f64::consts::SQRT_2; + // Torn, the two bends of a corner run to the corner of the bends and each is cut straight to its + // flange's end: at 90° it is grow = sin 90° − gap/√2 past the corner, a triangle BA·grow/2 at each of + // the two bend ends, and a corner less at each (see a_tear_of_the_rule_reaches_the_part_the_flat_pattern_and_the_dxf). + let (ba, grow) = (PI, 1.0 - 0.5 / SQRT_2); + let mut notch = two_flange_groups(json!({}), json!({}), json!({})); + let (v0, stepped) = (volume(&mut notch), outline_points(&flat(&mut notch))); + let mut s = two_flange_groups(json!({}), json!({"corner_relief_shape": "tear"}), json!({})); + assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); + let torn = s.model.state().sheets.last().map(|b| b.flanges.iter().map(|f| f.tear).collect::>()).unwrap_or_default(); + assert_eq!(torn.iter().filter(|t| **t != (false, false)).count(), 2, "{torn:?}"); + let want = 2.0 * ba * grow / 2.0 * 2.0; + assert!((volume(&mut s) - v0 - want).abs() < 0.05, "{} - {v0} vs {want}", volume(&mut s)); + assert_eq!(outline_points(&flat(&mut s)).len() + 2, stepped.len()); +} + +#[test] +fn only_an_edge_flange_takes_a_corner_relief_of_its_own() { + let mut s = Session::default(); + run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "active": true})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [120, 90]})); + run(&mut s, "sketch.finish", json!({})); + let features = s.doc.features.len(); + // A base flange and a contour flange take their relief from the rule, as before: refused, and + // the message names the key. + for (params, key) in [ + (json!({"sketch": "Base", "corner_relief_shape": "round"}), "corner_relief_shape"), + (json!({"sketch": "Base", "corner_relief": "6 mm"}), "corner_relief"), + (json!({"type": "base", "sketch": "Base", "corner_relief_shape": null}), "corner_relief_shape"), + (json!({"sketch": "Base", "curves": ["l1"], "distance": 10, "corner_relief": "6 mm"}), "corner_relief"), + (json!({"type": "contour", "sketch": "Base", "curves": ["l1"], "distance": 10, "corner_relief_shape": "tear"}), "corner_relief_shape"), + (json!({"base": {"sketch": "Base", "corner_relief": "6 mm"}}), "corner_relief"), + ] { + let e = s.execute("sheet.flange", ¶ms).map(|_| ()).unwrap_err().to_string(); + let today = format!( + "`{key}` is not a sheet.flange parameter: the sheet metal rule sets it. Make a rule with sheet.manage_rules {{name, {key}, …}} and pass its name as `rule`" + ); + assert!(e.contains(&today), "{params}: {e}"); + } + assert_eq!(s.doc.features.len(), features, "a refused flange changes nothing"); + run(&mut s, "sheet.flange", json!({"sketch": "Base"})); + // The rest of the rule stays with the rule for an edge flange as well, and a relief that is no shape + // or no length is refused before anything changes. + let features = s.doc.features.len(); + let edge = |extra: Value| { + let mut p = json!({"edges": [[60, 0, 2], [0, 45, 2]], "height": 20}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + p + }; + for (extra, want) in [ + (json!({"thickness": "3 mm"}), "`thickness` is not a sheet.flange parameter"), + (json!({"relief_width": "1 mm"}), "`relief_width` is not a sheet.flange parameter"), + (json!({"corner_relief_shape": "star"}), "`corner_relief_shape` must be notch, round, rectangular or tear"), + (json!({"corner_relief_shape": 2}), "`corner_relief_shape` must be notch, round, rectangular or tear"), + (json!({"corner_relief": "wide"}), "corner_relief"), + (json!({"corner_relief": "30 deg"}), "corner_relief"), + ] { + let e = s.execute("sheet.flange", &edge(extra.clone())).map(|_| ()).unwrap_err().to_string(); + assert!(e.contains(want), "{extra}: {e}"); + } + assert_eq!(s.doc.features.len(), features); + // Null is not given: the corners follow the rule. A shape is stored by its name. + run(&mut s, "sheet.flange", edge(json!({"corner_relief_shape": null}))); + let mut torn = edge(json!({"corner_relief_shape": " Tear ", "corner_relief": 5})); + torn["edges"] = json!([[60, 90, 2], [120, 45, 2]]); + run(&mut s, "sheet.flange", torn); + let saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap_or_default(); + let own: Vec<(Value, Value)> = saved["features"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .filter(|f| f["type"] == "sheet_flange") + .map(|f| (f["corner_relief_shape"].clone(), f["corner_relief"].clone())) + .collect(); + assert_eq!(own, [(Value::Null, Value::Null), (json!("tear"), json!("5"))]); +} + +#[test] +fn a_corner_relief_of_an_edge_flange_is_saved_with_it_and_designs_without_one_save_as_before() { + let flange_features = |s: &Session| -> Vec { + let saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap_or_default(); + saved["features"].as_array().cloned().unwrap_or_default().into_iter().filter(|f| f["type"] == "sheet_flange").collect() + }; + // Without a relief of its own the feature saves what it always did: neither key. + let plain = two_flange_groups(json!({}), json!({}), json!({})); + let features = flange_features(&plain); + assert_eq!(features.len(), 2); + assert!(features.iter().all(|f| f.get("corner_relief_shape").is_none() && f.get("corner_relief").is_none()), "{features:?}"); + // With one, the file has it and a design read from it builds the same part. + let near = json!({"corner_relief_shape": "round", "corner_relief": "6 mm"}); + let mut s = two_flange_groups(json!({}), near, json!({"corner_relief_shape": "tear"})); + let features = flange_features(&s); + assert_eq!((&features[0]["corner_relief_shape"], &features[0]["corner_relief"]), (&json!("round"), &json!("6 mm"))); + assert_eq!(features[1]["corner_relief_shape"], "tear"); + assert!(features[1].get("corner_relief").is_none(), "{}", features[1]); + let saved = s.doc.to_json(); + let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap()); + assert_eq!(reopened.doc.features, s.doc.features); + assert!((volume(&mut reopened) - volume(&mut s)).abs() < 1e-6); + assert_eq!(relief_drawing(&mut reopened), relief_drawing(&mut s)); + // timeline.edit changes the relief of a saved feature, and null takes it back to the rule's. + let id = s.doc.features.iter().rev().nth(1).map_or(0, |f| f.id); + run(&mut s, "timeline.edit", json!({"feature": id, "set": {"corner_relief_shape": "rectangular"}})); + assert!(relief_drawing(&mut s).0.is_empty()); + run(&mut s, "timeline.edit", json!({"feature": id, "set": {"corner_relief_shape": null, "corner_relief": null}})); + assert!(flange_features(&s)[0].get("corner_relief_shape").is_none()); + assert!(flange_features(&s)[0].get("corner_relief").is_none()); +} + #[test] fn corners_left_open_are_reported_on_the_flange() { let mut s = Session::default(); diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index 07c1f0aa..1b52d08d 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -35,6 +35,9 @@ const RULE_FIELDS: [(&str, &str); 8] = [ /// The corner relief shapes the manager offers (the rule's `corner_relief_shape`) and their labels. const RELIEF_SHAPES: [&str; 4] = ["notch", "round", "rectangular", "tear"]; const RELIEF_SHAPE_LABELS: [&str; 4] = ["Notch", "Round", "Rectangular", "Tear"]; +/// The corner relief shapes an edge flange offers: the rule's (index 0, nothing of its own), then +/// the shapes of `RELIEF_SHAPES` in order. +const FLANGE_RELIEF_LABELS: [&str; 5] = ["Rule", "Notch", "Round", "Rectangular", "Tear"]; #[derive(Clone, Debug)] pub enum Sm { @@ -52,6 +55,10 @@ pub enum Sm { /// Edge flanges: the edges with an angle and/or a height of their own (the picked point, /// angle, height; an empty text means the dialog's), from the rows per edge. own: Vec<(Vec3, String, String)>, + /// Edge flanges: the corner relief shape of their own (index into `FLANGE_RELIEF_LABELS`; + /// 0 is the rule's) and its size (empty: the rule's). + relief: usize, + relief_size: String, }, Hem { length: String, @@ -134,6 +141,8 @@ fn new_flange(ty: usize) -> Sm { reverse: false, rule: 0, own: Vec::new(), + relief: 0, + relief_size: String::new(), } } @@ -199,7 +208,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel let t = Tokens::get(); let mut enter = false; match k { - Sm::Flange { ty, height, angle, radius, position, flip, distance, reverse, rule, own } => { + Sm::Flange { ty, height, angle, radius, position, flip, distance, reverse, rule, own, relief, relief_size } => { *ty = flange_type(inputs); row_label(ui, "Type"); ui.label(FLANGE_TYPES.get(*ty).copied().unwrap_or("")); @@ -220,6 +229,15 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel crate::params_dialog::complete(ui, &r, radius); enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.end_row(); + // The relief at the corners these flanges make: the rule's unless set here. + row_label(ui, "Corner Relief Shape"); + combo(ui, "sm_flange_relief", &FLANGE_RELIEF_LABELS, relief); + ui.end_row(); + row_label(ui, "Corner Relief"); + let r = ui.add(egui::TextEdit::singleline(relief_size).hint_text(crate::i18n::tr_label("from the rule"))); + crate::params_dialog::complete(ui, &r, relief_size); + enter |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.end_row(); // With several edges, a row for each: an angle and a height of its own, left // empty for the values above (shown as the hints). let points = edge_point_list(inputs); @@ -514,7 +532,7 @@ fn flange_edges(inputs: &[SelInput], own: &[(Vec3, String, String)]) -> Vec) -> Result, String> { let s = &app.session; let (cmd, mut p): (&str, Value) = match k { - Sm::Flange { height, angle, radius, position, flip, distance, reverse, rule, own, .. } => { + Sm::Flange { height, angle, radius, position, flip, distance, reverse, rule, own, relief, relief_size, .. } => { let mut p = match flange_type(inputs) { 1 => { let mut p = json!({"type": "edge", "edges": flange_edges(inputs, own), "height": height, "angle": angle, @@ -522,6 +540,12 @@ pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map { @@ -667,7 +691,7 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec { + FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body, corner_relief_shape, corner_relief } => { if let Some(b) = body { extra.insert("body".into(), json!(b)); } @@ -692,6 +716,11 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec = [[60.0, 0.0, 2.0], [0.0, 45.0, 2.0]].iter().filter_map(|p| edge_sel(&h.app.session, Vec3::new(p[0], p[1], p[2]))).collect(); + assert_eq!(edges.len(), 2); + h.app.session.selection = edges; + h.app.start("sheet.flange"); + h.frames(3); + let shown = |app: &SolveApp| match app.dialog.as_ref().map(|d| &d.kind) { + Some(Kind::Sheet(Sm::Flange { relief, relief_size, .. })) => Some((*relief, relief_size.clone())), + _ => None, + }; + let relief = |app: &SolveApp| match &app.session.doc.features.last().map(|f| &f.kind) { + Some(FeatureKind::SheetFlange { corner_relief_shape, corner_relief, .. }) => Some((corner_relief_shape.clone(), corner_relief.clone())), + _ => None, + }; + // Untouched, the dialog asks for nothing: the rule's relief. + assert_eq!(shown(&h.app), Some((0, String::new()))); + let d = h.app.dialog.clone().unwrap(); + let c = apply_commands(&h.app, &d).unwrap(); + assert!(c[0].1.get("corner_relief_shape").is_none() && c[0].1.get("corner_relief").is_none(), "{}", c[0].1); + // Round from the selector, 6 mm in the size row. + h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape"}})).unwrap(); + h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape:Round"}})).unwrap(); + assert_eq!(shown(&h.app), Some((2, String::new()))); + if let Some(Kind::Sheet(Sm::Flange { relief_size, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) { + *relief_size = "6 mm".into(); + } + let d = h.app.dialog.clone().unwrap(); + let c = apply_commands(&h.app, &d).unwrap(); + assert_eq!((&c[0].1["corner_relief_shape"], &c[0].1["corner_relief"]), (&json!("round"), &json!("6 mm"))); + h.step(&json!({"widget": "OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + assert_eq!(relief(&h.app), Some((Some("round".into()), Some("6 mm".into())))); + assert!(h.app.session.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{:?}", h.app.session.model.results); + let arcs = |app: &SolveApp| { + app.session + .model + .state() + .sheets + .last() + .map(|s| s.base.outer.segs.iter().filter(|g| matches!(g, solidcraft_engine::geom::Seg2::Arc { .. })).count()) + }; + assert_eq!(arcs(&h.app), Some(1), "the round is cut at the one corner the two flanges make"); + // Edit Feature shows both again; Rule and an empty size take the feature back to the rule's relief. + let id = h.app.session.doc.features.last().map(|f| f.id).unwrap(); + let mut e = edit_dialog(&h.app, id, None).unwrap(); + assert!(matches!(&e.kind, Kind::Sheet(Sm::Flange { relief: 2, relief_size, .. }) if relief_size == "6 mm")); + let c = apply_commands(&h.app, &e).unwrap(); + assert_eq!((&c[0].1["params"]["corner_relief_shape"], &c[0].1["params"]["corner_relief"]), (&json!("round"), &json!("6 mm"))); + if let Kind::Sheet(Sm::Flange { relief, relief_size, .. }) = &mut e.kind { + *relief = 0; + relief_size.clear(); + } + let commands = apply_commands(&h.app, &e).unwrap(); + run_all(&mut h.app, commands); + assert_eq!(relief(&h.app), Some((None, None))); + assert_eq!(arcs(&h.app), Some(0)); +} + #[test] fn pocket_depth_labels_follow_inches_without_changing_flat_data() { let mut app = plate(); diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index d2c2900d..32ca2581 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -86,7 +86,7 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio The default `cache: false` omits the portable thumbnail too. The repository's `docs/design-container.md` describes limits and encoding details. -Current designs use format 21, including design properties, named render setups, opaque PBR looks, thin extrudes, sheet metal perforation fields, associative editable sketch patterns, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, the expressions behind a sketch text's height, place and angle, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis +Current designs use format 24, including the corner relief of an edge flange, the gear, rack and pinion and screw couplings of motion links, design properties, named render setups, opaque PBR looks, thin extrudes, sheet metal perforation fields, associative editable sketch patterns, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, the expressions behind a sketch text's height, place and angle, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis links, persistent names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and association profile references, durable sketch face anchors, periodic fit splines and versioned diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 098a3784..b3ad5f80 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -64,6 +64,22 @@ warning saying where. "bend_radius":"1 mm","corner_relief_shape":"round","corner_relief":"8 mm","active":true}} ``` +The rule's shape and size are the default. An edge flange feature can choose its own for the +corners its flanges make: `corner_relief_shape` (`notch`, `round`, `rectangular` or `tear`) and +`corner_relief` (the size across, a length or an expression) in `sheet.flange`, or Corner Relief +Shape and Corner Relief in the Flange dialog. In the dialog, Rule as the shape and an empty size +take the rule's; either one left out follows the rule while the other is the feature's. Corners are +relieved only between the flanges of one feature, so a feature's choice never meets another's at a +corner, and two edge flange features of one part can have different reliefs under one rule. A +base or contour flange takes its corners from the rule only and refuses both parameters. The +flange gap and the rule's other values stay with the rule for every flange. The feature's choice is +saved with the design (file format 23). + +```json +{"command":"sheet.flange","params":{"edges":[[60,0,2],[120,45,2],[60,90,2],[0,45,2]], + "height":20,"corner_relief_shape":"round","corner_relief":"6 mm"}} +``` + Where the edge flanges of one feature meet at a corner of the base, their panels are closed toward each other at any bend angle: each end is cut square to the sheet, on a line in the flat chosen so that the edges nearest each other (on the inner faces) run parallel, the rule's diff --git a/docs/design-container.md b/docs/design-container.md index 9bde0bfe..c391879f 100644 --- a/docs/design-container.md +++ b/docs/design-container.md @@ -7,7 +7,7 @@ self-contained input/output format. `io::write_design` keeps its JSON contract; `io::write_design_container` is the fallible container writer. Packages have `container.json` with `{"format":"solidcraft-container/1"}` and -`document.json` with the versioned parametric document (currently `solidcraft/23`). +`document.json` with the versioned parametric document (currently `solidcraft/24`). The independent versions let document migrations evolve without changing blob encodings. Existing serialized document fields are retained automatically, without a second copy of the document schema or a deep clone of embedded payloads.