From 16c49c3e860a954e9f27309927d6848bcba9c607 Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Wed, 14 Oct 2026 20:29:25 -0400 Subject: [PATCH] Untrim: holes removed and a face restored to its natural bounds (#1150) Adds surface.untrim (#761); the SurfaceUntrim feature is first saved in format 21. --- assets/locales/de.json | 10 +- assets/locales/en.json | 10 +- assets/locales/ja.json | 10 +- assets/locales/ko.json | 10 +- assets/locales/zh.json | 10 +- crates/doc/src/document.rs | 15 + crates/doc/src/eval.rs | 3 +- crates/doc/src/eval_surface.rs | 26 +- crates/doc/src/feature_geometry.rs | 9 + crates/doc/src/feature_refs.rs | 1 + crates/doc/src/feature_walk_tests/fixtures.rs | 1 + .../src/feature_walk_tests/golden_exprs.txt | 1 + .../feature_walk_tests/golden_geometry.txt | 3 + .../src/feature_walk_tests/golden_refs.txt | 2 + crates/doc/src/format.rs | 3 + crates/doc/src/params.rs | 2 +- crates/engine/src/cmd/surface.rs | 138 +++- crates/kernel/src/lib.rs | 2 + crates/kernel/src/step_in/mod.rs | 2 +- crates/kernel/src/step_in/split.rs | 2 +- crates/kernel/src/surface_untrim.rs | 614 ++++++++++++++++++ crates/ui-egui/src/dialogs_surface.rs | 63 +- crates/ui-egui/src/preselect_tests.rs | 35 + crates/ui-egui/src/timeline.rs | 1 + docs/features.md | 6 +- 25 files changed, 961 insertions(+), 18 deletions(-) create mode 100644 crates/kernel/src/surface_untrim.rs diff --git a/assets/locales/de.json b/assets/locales/de.json index bf2eb8ea..ba9c920e 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -843,5 +843,13 @@ "appearance.dielectric_ior": "Brechungsindex des Dielektrikums", "appearance.pbr_help": "Diese Oberfläche in Render oder einem Produkt-PNG ansehen. Keine Brechung oder Transmission.", "render.opaque_preview_help": "Opakes PBR und einfache Farben; Transparenz durch Blending. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.", - "render.opaque_image_summary": "Vorschau-PNG · opakes PBR · Transparenz durch Blending" + "render.opaque_image_summary": "Vorschau-PNG · opakes PBR · Transparenz durch Blending", + "UNTRIM": "TRIMMEN AUFHEBEN", + "Face": "Fläche", + "Holes": "Löcher", + "Holes (edges on them)": "Löcher (ihre Kanten)", + "In place (surfaces)": "An Ort und Stelle (Flächen)", + "None": "Keine", + "Picked": "Ausgewählte", + "Restore outer boundary": "Äußere Begrenzung wiederherstellen" } diff --git a/assets/locales/en.json b/assets/locales/en.json index 7729a6e1..50eb2f6f 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1101,5 +1101,13 @@ "appearance.dielectric_ior": "Dielectric IOR", "appearance.pbr_help": "View this surface in Render or a product PNG. No refraction or transmission.", "render.opaque_preview_help": "Opaque PBR and simple colors; blended transparency. Refraction, HDRI and final rendering are not available yet.", - "render.opaque_image_summary": "Preview PNG · opaque PBR · blended transparency" + "render.opaque_image_summary": "Preview PNG · opaque PBR · blended transparency", + "UNTRIM": "UNTRIM", + "Face": "Face", + "Holes": "Holes", + "Holes (edges on them)": "Holes (edges on them)", + "In place (surfaces)": "In place (surfaces)", + "None": "None", + "Picked": "Picked", + "Restore outer boundary": "Restore outer boundary" } diff --git a/assets/locales/ja.json b/assets/locales/ja.json index f8f17f7d..913316f0 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1101,5 +1101,13 @@ "appearance.dielectric_ior": "誘電体の屈折率", "appearance.pbr_help": "この表面はレンダリング作業空間または製品PNGで確認できます。屈折や透過には対応していません。", "render.opaque_preview_help": "不透明PBRと単色表示、アルファ合成による透明表示に対応します。屈折、HDRI、最終レンダリングにはまだ対応していません。", - "render.opaque_image_summary": "プレビューPNG · 不透明PBR · アルファ合成の透明表示" + "render.opaque_image_summary": "プレビューPNG · 不透明PBR · アルファ合成の透明表示", + "UNTRIM": "トリム解除", + "Face": "面", + "Holes": "穴", + "Holes (edges on them)": "穴(そのエッジ)", + "In place (surfaces)": "その場で(サーフェス)", + "None": "なし", + "Picked": "選択したもの", + "Restore outer boundary": "外側の境界を復元" } diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 2607933e..99531df0 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -843,5 +843,13 @@ "appearance.dielectric_ior": "유전체 굴절률", "appearance.pbr_help": "이 표면은 렌더링 작업 공간이나 제품 PNG에서 확인하세요. 굴절과 투과는 지원하지 않습니다.", "render.opaque_preview_help": "불투명 PBR 및 단색 표시, 블렌딩 투명도를 지원합니다. 굴절, HDRI 및 최종 렌더링은 아직 지원하지 않습니다.", - "render.opaque_image_summary": "미리보기 PNG · 불투명 PBR · 블렌딩 투명도" + "render.opaque_image_summary": "미리보기 PNG · 불투명 PBR · 블렌딩 투명도", + "UNTRIM": "트림 해제", + "Face": "면", + "Holes": "구멍", + "Holes (edges on them)": "구멍 (구멍의 모서리)", + "In place (surfaces)": "제자리에서 (서피스)", + "None": "없음", + "Picked": "선택한 항목", + "Restore outer boundary": "외부 경계 복원" } diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 7b75f38a..6e5cb39a 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1101,5 +1101,13 @@ "appearance.dielectric_ior": "电介质折射率", "appearance.pbr_help": "在渲染工作区或产品 PNG 中查看此表面。不支持折射或透射。", "render.opaque_preview_help": "不透明 PBR 和纯色;透明效果使用混合。不支持折射、HDRI 和最终渲染。", - "render.opaque_image_summary": "预览 PNG · 不透明 PBR · 混合透明" + "render.opaque_image_summary": "预览 PNG · 不透明 PBR · 混合透明", + "UNTRIM": "取消修剪", + "Face": "面", + "Holes": "孔", + "Holes (edges on them)": "孔(孔上的边)", + "In place (surfaces)": "原位(曲面)", + "None": "无", + "Picked": "所选", + "Restore outer boundary": "恢复外边界" } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 38739e03..0c4ea839 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1019,6 +1019,19 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Vec::is_empty")] faces: Vec, }, + /// The face at `face` restored towards its surface: the inner loops at the `holes` points + /// taken away (every one when `None`; none when empty), and with `outer` its outer boundary + /// back at the surface's natural bounds (an analytic surface's: the face's own span). A new + /// surface body; with `in_place`, a surface body's face is untrimmed where it is. + SurfaceUntrim { + face: FaceAt, + #[serde(default, skip_serializing_if = "Option::is_none")] + holes: Option>, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + outer: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + in_place: bool, + }, /// A surface body turned about an axis from open curve chains and the boundaries of /// profiles: walls only, no caps (a solid where a full turn closes them). SurfaceRevolve { @@ -1444,6 +1457,7 @@ impl FeatureKind { FeatureKind::SurfaceExtend { .. } => "ExtendFeature", FeatureKind::Unstitch { .. } => "UnstitchFeature", FeatureKind::ReverseNormal { .. } => "ReverseNormalFeature", + FeatureKind::SurfaceUntrim { .. } => "UntrimFeature", FeatureKind::Import { .. } => "BaseFeature", FeatureKind::MeshImport { .. } => "MeshFeature", FeatureKind::MeshConvert { .. } => "MeshConvertFeature", @@ -1518,6 +1532,7 @@ impl FeatureKind { FeatureKind::SurfaceExtend { .. } => "Extend", FeatureKind::Unstitch { .. } => "Unstitch", FeatureKind::ReverseNormal { .. } => "ReverseNormal", + FeatureKind::SurfaceUntrim { .. } => "Untrim", FeatureKind::Import { .. } => "Import", FeatureKind::MeshImport { .. } => "Mesh", FeatureKind::MeshConvert { .. } => "ConvertMesh", diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 75c78e6c..68a3c4ab 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3925,7 +3925,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::SurfaceRevolve { .. } | FeatureKind::SurfaceSweep { .. } | FeatureKind::SurfaceLoft { .. } - | FeatureKind::SurfaceOffset { .. } => surface::eval(doc, vals, f, st), + | FeatureKind::SurfaceOffset { .. } + | FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st), FeatureKind::Emboss { .. } | FeatureKind::Rib { .. } | FeatureKind::ReplaceFace { .. } diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index e633f3f2..8a8aa50a 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -1,6 +1,6 @@ //! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend, Surface Extrude, -//! Revolve, Sweep and Loft, Offset, Unstitch and Reverse Normal (kernel `surfaces`, -//! `surface_offset`). +//! Revolve, Sweep and Loft, Offset, Untrim, Unstitch and Reverse Normal (kernel `surfaces`, +//! `surface_offset`, `surface_untrim`). use super::*; use solidcraft_geom::Seg2; @@ -234,6 +234,28 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st.bodies.push(ModelBody::traced_new(name, made, f.id)); Ok(()) } + FeatureKind::SurfaceUntrim { face, holes, outer, in_place } => { + let i = st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", face.body)))?; + let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; + let fi = crate::appearance::face_index_at(&mb, face.point) + .ok_or_else(|| DocError::Invalid(format!("Untrim: no face of {} at {:?}", mb.name, face.point)))?; + let which = match holes { + None => kernel::UntrimHoles::All, + Some(points) => kernel::UntrimHoles::At(points), + }; + // A surface body's face is untrimmed where it is when asked; a solid's is copied out. + let replace = *in_place && mb.body.is_surface(); + let made = mb.traced().then(kernel::untrim(&mb.body, fi, which, *outer, replace)?); + if replace { + if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::traced_new(mb.name, made, mb.feature); + } + } else { + let name = new_name(st, f, 0); + st.bodies.push(ModelBody::traced_new(name, made, f.id)); + } + Ok(()) + } _ => Err(DocError::Invalid("not a surface feature".into())), } } diff --git a/crates/doc/src/feature_geometry.rs b/crates/doc/src/feature_geometry.rs index 1a21adcf..22eda750 100644 --- a/crates/doc/src/feature_geometry.rs +++ b/crates/doc/src/feature_geometry.rs @@ -157,6 +157,15 @@ macro_rules! geometry_walk { f(Pos(p)); } } + // The face is picked (and named); the holes are picked by points on their edges. + FeatureKind::SurfaceUntrim { face, holes, .. } => { + f(Pick(& $($m)? face.point)); + if let Some(points) = holes { + for p in points { + f(Pos(p)); + } + } + } FeatureKind::BoundaryFill { cells, .. } => { for p in cells { f(Pos(p)); diff --git a/crates/doc/src/feature_refs.rs b/crates/doc/src/feature_refs.rs index c1e4a216..45a70eb2 100644 --- a/crates/doc/src/feature_refs.rs +++ b/crates/doc/src/feature_refs.rs @@ -174,6 +174,7 @@ macro_rules! feature_walk { f(B(b)); } } + FeatureKind::SurfaceUntrim { face, .. } => f(B(& $($m)? face.body)), FeatureKind::SheetConvert { body, .. } | FeatureKind::SurfaceExtend { body, .. } | FeatureKind::Unstitch { body, .. } diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs index 750f007e..2ca664bb 100644 --- a/crates/doc/src/feature_walk_tests/fixtures.rs +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -151,6 +151,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> { ("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})), ("unstitch", json!({"type": "unstitch", "body": "B"})), ("reverse_normal", json!({"type": "reverse_normal", "body": "B"})), + ("surface_untrim", json!({"type": "surface_untrim", "face": {"body": "B", "point": o}, "holes": [o], "outer": true})), ("surface_extend", json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance"})), ("surface_trim", json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o})), ("move", json!({"type": "move", "bodies": ["B"], "translate": ["move.x", "move.y", "move.z"], "angle": "move.angle"})), diff --git a/crates/doc/src/feature_walk_tests/golden_exprs.txt b/crates/doc/src/feature_walk_tests/golden_exprs.txt index 186d32ea..cdf4971a 100644 --- a/crates/doc/src/feature_walk_tests/golden_exprs.txt +++ b/crates/doc/src/feature_walk_tests/golden_exprs.txt @@ -325,6 +325,7 @@ surface_offset expressions: soff.distance, soff.tolerance unstitch reverse_normal +surface_untrim surface_extend inputs: Distance = extend.distance (Length) diff --git a/crates/doc/src/feature_walk_tests/golden_geometry.txt b/crates/doc/src/feature_walk_tests/golden_geometry.txt index b5c31824..6a5b5eb0 100644 --- a/crates/doc/src/feature_walk_tests/golden_geometry.txt +++ b/crates/doc/src/feature_walk_tests/golden_geometry.txt @@ -150,6 +150,9 @@ unstitch moves: faces[0][0], faces[0][1], faces[0][2] reverse_normal moves: faces[0][0], faces[0][1], faces[0][2] +surface_untrim + moves: face.point[0], face.point[1], face.point[2], holes[0][0], holes[0][1], holes[0][2] + face points: [0, 0, 0] surface_extend moves: edges[0][0], edges[0][1], edges[0][2] surface_trim diff --git a/crates/doc/src/feature_walk_tests/golden_refs.txt b/crates/doc/src/feature_walk_tests/golden_refs.txt index 5bb056dc..cf001b1c 100644 --- a/crates/doc/src/feature_walk_tests/golden_refs.txt +++ b/crates/doc/src/feature_walk_tests/golden_refs.txt @@ -168,6 +168,8 @@ unstitch remap_bodies: body reverse_normal remap_bodies: body +surface_untrim + remap_bodies: face.body surface_extend remap_bodies: body surface_trim diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index fe272ff1..d0fd79d4 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -167,6 +167,9 @@ //! Under the same version, looks can explicitly select opaque PBR parameters (`pbr`: metal //! fraction, roughness, dielectric IOR). Absent fields keep the scalar shading a look always //! had; 0.11.0 asks for an update instead of silently dropping the response. +//! A `SurfaceUntrim` feature (Untrim: a `face` as `body` and `point`, optional `holes` +//! points, `outer` and `in_place`) is also first saved in 21; 0.11.0 asks for an update +//! instead of meeting a feature it can't read. use serde_json::Value; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index a3f5510e..fd67063c 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -354,7 +354,7 @@ macro_rules! feature_walk { FeatureKind::Thicken { thickness, .. } => f("Thickness", thickness, L, I), FeatureKind::SurfaceTrim { plane, .. } => $plane(plane, f), FeatureKind::SurfaceExtend { distance, .. } => f("Distance", distance, L, I), - FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } => {} + FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } | FeatureKind::SurfaceUntrim { .. } => {} FeatureKind::SurfaceOffset { distance, tolerance, .. } => { f("Distance", distance, L, I); if let Some(t) = tolerance { diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 4e8316ab..e8fe717c 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -62,10 +62,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SURFACE", "CREATE") .icon("surface_blend") .params("planned: from, to: {body, edges: [[x,y,z]]} (two edge chains apart); continuity?: [g0|g1|g2, g0|g1|g2] (each side); bulge?: [number, number]; align?: auto|[[x,y,z], [x,y,z]]; stitch?: bool"), - planned!("surface.untrim", "Untrim", "761") + CommandSpec::new("surface.untrim", "Untrim", untrim) .at("SURFACE", "MODIFY") .icon("untrim") - .params("planned: face: {body, point}; holes?: all|[[x,y,z]] (points on the inner loops to remove); outer?: bool (restore the natural bounds); in_place?: bool (a surface body only)"), + .params("face: {body, point}; holes?: all|[[x,y,z]] (points on the inner loops to remove; default: all); outer?: bool (restore the outer boundary to the surface's natural bounds: a free-form surface's own, an analytic surface's span of the face); in_place?: bool (untrim a surface body's face where it is; a solid's face is always copied out). A surface body; the face keeps its name"), CommandSpec::new("surface.unstitch", "Unstitch", unstitch) .at("SURFACE", "MODIFY") .icon("unstitch") @@ -129,6 +129,34 @@ fn reverse_normal(s: &mut Session, p: &Value) -> Result { add_feature(s, p, FeatureKind::ReverseNormal { body, faces }) } +fn untrim(s: &mut Session, p: &Value) -> Result { + let cmd = "surface.untrim"; + let face = p.get("face").ok_or_else(|| bad(cmd, "`face` is required: {body, point}"))?; + let body = face.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`face.body` must name a body"))?.to_string(); + if s.model.state().body(&body).is_none() { + return Err(bad(cmd, format!("no body `{body}`"))); + } + let point = face.get("point").and_then(vec3).ok_or_else(|| bad(cmd, "`face.point` must be [x, y, z] on the face"))?; + let holes = match p.get("holes") { + None | Some(Value::Null) => None, + Some(Value::String(all)) if all == "all" => None, + Some(Value::Array(points)) => { + if points.len() > 10_000 { + return Err(bad(cmd, "too many holes")); + } + let points: Option> = points.iter().map(vec3).collect(); + Some(points.ok_or_else(|| bad(cmd, "`holes` must list points [x, y, z] on the inner loops"))?) + } + Some(_) => return Err(bad(cmd, "`holes` must be \"all\" or a list of points [x, y, z] on the inner loops")), + }; + let outer = bool_(p, "outer").unwrap_or(false); + if !outer && holes.as_ref().is_some_and(Vec::is_empty) { + return Err(bad(cmd, "nothing to untrim: pick holes to remove or restore the outer boundary")); + } + let kind = FeatureKind::SurfaceUntrim { face: FaceAt { body, point }, holes, outer, in_place: bool_(p, "in_place").unwrap_or(false) }; + add_feature(s, p, kind) +} + /// The profiles and curves a surface builder takes: `curves`, and `profiles` (all of them when /// neither is given). fn sheet_inputs(p: &Value, cmd: &str) -> Result<(Option, Vec)> { @@ -638,12 +666,116 @@ mod tests { assert!(s.execute("surface.offset", &json!({"distance": 1})).is_err()); } + /// A 40 × 20 × 2 plate with three holes of radius 2 through it at x = 10, 20, 30 on y = 10. + fn plate_with_three_holes(s: &mut Session) { + run(s, "solid.box", json!({"length": 40, "width": 20, "height": 2, "body_name": "Plate"})); + for x in [10, 20, 30] { + run(s, "solid.cylinder", json!({"radius": 2, "height": 4, "base": [x, 10, -1], "operation": "cut", "targets": ["Plate"]})); + } + } + + fn edge_count(s: &Session, name: &str) -> usize { + s.world_state().body(name).unwrap().body.edge_count() + } + + /// Untrim the plate's top: every hole taken away gives one face of the whole 40 × 20 + /// rectangle (800) bounded by its four edges alone, still named as the plate's top; one hole taken away + /// leaves two (800 − 2 · 4π). The plate stays; undo, redo and reopening give the same face. + #[test] + fn untrim_a_plate_with_three_holes() { + let mut s = Session::default(); + plate_with_three_holes(&mut s); + let top = [5.0, 5.0, 2.0]; + let name = + solidcraft_doc::naming::face_name_at_body(s.model.state().body("Plate").unwrap(), solidcraft_geom::Vec3::new(5.0, 5.0, 2.0)).unwrap(); + run(&mut s, "surface.untrim", json!({"face": {"body": "Plate", "point": top}, "body_name": "Top"})); + let st = s.world_state(); + let made = &st.body("Top").unwrap().body; + assert!(made.is_surface() && made.face_count() == 1); + assert_eq!(edge_count(&s, "Top"), 4, "the rectangle alone"); + assert!((area(&s, "Top") - 800.0).abs() < 1e-9, "{}", area(&s, "Top")); + assert_eq!(solidcraft_doc::naming::face_names(s.model.state().body("Top").unwrap()).first(), Some(&name), "the face keeps its name"); + assert!(st.body("Plate").is_some(), "the solid stays"); + run(&mut s, "edit.undo", json!({})); + assert!(s.world_state().body("Top").is_none()); + run(&mut s, "edit.redo", json!({})); + let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((area(&cold, "Top") - 800.0).abs() < 1e-9); + // One hole, by a point on its rim. + run(&mut s, "surface.untrim", json!({"face": {"body": "Plate", "point": top}, "holes": [[22, 10, 2]], "body_name": "Two"})); + assert!(edge_count(&s, "Two") > 4); + let want = 800.0 - 2.0 * 4.0 * std::f64::consts::PI; + assert!((area(&s, "Two") - want).abs() < want * 1e-3, "{} vs {want}", area(&s, "Two")); // (measured on meshes) + // Refused, the design left as it was: nothing to untrim, a point on no hole, bad input. + let before = s.doc.features.len(); + for bad in [ + json!({"face": {"body": "Plate", "point": top}, "holes": []}), + json!({"face": {"body": "Plate", "point": top}, "holes": [[5, 5, 2]]}), + json!({"face": {"body": "Plate", "point": [20, 0, 1]}}), + json!({"face": {"body": "Nope", "point": top}}), + json!({"face": {"body": "Plate"}}), + json!({"face": {"body": "Plate", "point": top}, "holes": "some"}), + json!({"face": {"body": "Plate", "point": top}, "holes": [[1, 2]]}), + json!({}), + ] { + assert!(s.execute("surface.untrim", &bad).is_err(), "{bad}"); + } + assert_eq!(s.doc.features.len(), before); + } + + /// A half cylinder sheet (radius 10, height 20) trimmed by the slanted plane z = 15 − y/2 + /// (area 150π − 100) and untrimmed in place with its outer boundary restored: the same body + /// fills its face's own span again, angles 0…π by heights 0…15 (150π), and its face keeps its + /// name. + #[test] + fn untrim_restores_a_trimmed_cylinder_face_in_place() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + let arc = run(&mut s, "sketch.arc.three_point", json!({"start": [10, 0], "end": [-10, 0], "through": [0, 10]}))["created"]["curves"][0] + .as_str() + .unwrap() + .to_string(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "surface.extrude", json!({"sketch": "S", "curves": [arc], "distance": 20, "body_name": "Half"})); + run(&mut s, "surface.trim", json!({"body": "Half", "plane": {"origin": [0, 0, 15], "normal": [0, 0.5, 1]}, "keep": [0, 10, 1]})); + let pi = std::f64::consts::PI; + assert!((area(&s, "Half") - (150.0 * pi - 100.0)).abs() < 150.0 * pi * 1e-3, "{}", area(&s, "Half")); + let name = solidcraft_doc::naming::face_names(s.model.state().body("Half").unwrap()).first().cloned().unwrap(); + let on = [10.0 * 2.5f64.cos(), 10.0 * 2.5f64.sin(), 5.0]; + assert!(s.execute("surface.untrim", &json!({"face": {"body": "Half", "point": on}})).is_err(), "no holes, outer kept"); + run(&mut s, "surface.untrim", json!({"face": {"body": "Half", "point": on}, "outer": true, "in_place": true})); + let st = s.world_state(); + assert_eq!(st.bodies.len(), 1, "untrimmed where it is"); + assert!((area(&s, "Half") - 150.0 * pi).abs() < 150.0 * pi * 1e-3, "{}", area(&s, "Half")); // (measured on meshes) + let bb = solidcraft_kernel::measure(&st.body("Half").unwrap().body).unwrap().bbox; + assert!(bb.min.z.abs() < 1e-9 && (bb.max.z - 15.0).abs() < 1e-6, "{bb:?}"); + assert_eq!(solidcraft_doc::naming::face_names(s.model.state().body("Half").unwrap()).first(), Some(&name), "the face keeps its name"); + } + + /// The plate written to STEP and inserted back: its imported top face untrims to the whole + /// rectangle, outer boundary restored too. + #[test] + fn untrim_an_imported_step_face() { + let mut s = Session::default(); + plate_with_three_holes(&mut s); + let step = solidcraft_kernel::step_export(&[&s.world_state().body("Plate").unwrap().body], "untrim test").unwrap(); + let path = std::env::temp_dir().join(format!("solidcraft-untrim-{}.step", std::process::id())); + std::fs::write(&path, step).unwrap(); + let mut s = Session::default(); + run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + let body = s.world_state().bodies.first().unwrap().name.clone(); + run(&mut s, "surface.untrim", json!({"face": {"body": body, "point": [5, 5, 2]}, "holes": "all", "outer": true, "body_name": "Top"})); + assert_eq!(edge_count(&s, "Top"), 4, "the rectangle alone"); + assert!((area(&s, "Top") - 800.0).abs() < 1e-9, "{}", area(&s, "Top")); + } + /// The planned surface commands are registered but refuse to run: disabled with the /// issue that tracks them, an error (never a panic) from every caller, and the design left /// as it was. #[test] fn planned_commands_refuse_and_leave_the_design_alone() { - let planned = ["surface.ruled", "surface.boundary", "surface.blend", "surface.untrim", "surface.fillet", "inspect.continuity"]; + let planned = ["surface.ruled", "surface.boundary", "surface.blend", "surface.fillet", "inspect.continuity"]; let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"})); let before = s.doc.features.len(); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 1e2fcd99..b1e2bdc1 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -94,6 +94,7 @@ mod step_in; mod step_out; mod surface_kind; mod surface_offset; +mod surface_untrim; mod surfaces; mod sweep_path; mod tangent_cylinders; @@ -137,6 +138,7 @@ pub use step_in::{ImportedBody, StepImport, StepNode, step_cache_alive, step_imp pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products}; pub use surface_kind::{FaceSurface, face_surface}; pub use surface_offset::offset_surface; +pub use surface_untrim::{UntrimHoles, untrim}; pub use surfaces::{ copy_faces, extend, extrude_sheet, loft_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet, sweep_sheet_points, thicken, trim, unstitch, diff --git a/crates/kernel/src/step_in/mod.rs b/crates/kernel/src/step_in/mod.rs index d9d655e0..b43be4e9 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -11,7 +11,7 @@ mod brep; pub(crate) mod geom; pub(crate) mod p21; mod parallel; -mod split; +pub(crate) mod split; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; diff --git a/crates/kernel/src/step_in/split.rs b/crates/kernel/src/step_in/split.rs index c11756eb..6a5d2973 100644 --- a/crates/kernel/src/step_in/split.rs +++ b/crates/kernel/src/step_in/split.rs @@ -207,7 +207,7 @@ fn iso_curve(s: &mt::Surface, d: Dir, mid: f64, q1: mt::Point3, q2: mt::Point3, } /// The iso-curve of a homogeneous B-spline surface at `v = w` (`along_u`) or `u = w`. -fn iso_bsp(b: &mt::BSplineSurface, w: f64, along_u: bool) -> R> { +pub(crate) fn iso_bsp(b: &mt::BSplineSurface, w: f64, along_u: bool) -> R> { let (ku, kv) = b.knot_vecs(); let ctrl = b.control_points(); let nu = ctrl.len(); diff --git a/crates/kernel/src/surface_untrim.rs b/crates/kernel/src/surface_untrim.rs new file mode 100644 index 00000000..4e19c583 --- /dev/null +++ b/crates/kernel/src/surface_untrim.rs @@ -0,0 +1,614 @@ +//! Untrim (Surface › Untrim): a face restored towards the surface it lies on. Picked inner +//! loops (holes), or all of them, are taken away; optionally the outer boundary goes back to +//! the surface's natural parameter bounds: a free-form surface's own parameter box, and for an +//! analytic surface (a plane, cylinder, cone, sphere or torus, which has no natural bounds of +//! its own, or bounds that go all the way round) the box of the face's own span. The restored +//! boundary runs along the surface's parameter lines, each edge exact: a line, an arc about a +//! revolved surface's axis, a rotated profile, or a B-spline iso-curve. +//! +//! Which loop is the outer one is worked out from the face's parameters (the loop enclosing the +//! most area), never from the order the loops are stored in: an imported face can list its holes +//! first. + +use mt::{BoundedCurve, Cut, InnerSpace, Invertible, MetricSpace, ParametricCurve, ParametricSurface, SearchNearestParameter, Transformed}; +use solidcraft_geom::Vec3; +use solidcraft_geom::tess::EvalSurface; +use truck_modeling as mt; + +use crate::body::{Body, from_p3}; +use crate::record::{Rebuild, rebuilt_body}; +use crate::surfaces::surface_body; +use crate::tess::uv::{self, Domain}; +use crate::{KernelError, Result, guard}; + +/// Samples along each boundary edge when working out the face's parameter loops. +const EDGE_SAMPLES: usize = 32; +/// Golden-section steps that place an extreme of the face's span on an edge. +const EXTREME_STEPS: usize = 60; + +/// The inner loops Untrim takes away. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum UntrimHoles<'a> { + /// Every inner loop of the face. + All, + /// The inner loops nearest these points (on or near their edges); none keeps every hole. + At(&'a [Vec3]), +} + +/// Face `face` of `body` (in its face order) untrimmed: the inner loops `holes` names taken +/// away and, with `outer`, the outer boundary restored to the surface's natural bounds (an +/// analytic surface's: the face's own span). The result is a surface body: the untrimmed face on +/// its own, or, with `in_place` and a surface `body`, the body with that face untrimmed (a +/// solid's face is always copied out). Its history is over `[body]`: the untrimmed face is the +/// face it came from, trimmed differently, so it keeps that face's name. +pub fn untrim(body: &Body, face: usize, holes: UntrimHoles, outer: bool, in_place: bool) -> Result { + body.require_brep("untrim")?; + if let UntrimHoles::At(points) = holes { + if points.len() > 10_000 { + return Err(KernelError::Invalid("too many holes picked".into())); + } + if points.iter().any(|p| !(p.x.is_finite() && p.y.is_finite() && p.z.is_finite())) { + return Err(KernelError::Invalid("a hole point is not finite".into())); + } + } + let faces: Vec<&mt::Face> = body.solid.face_iter().collect(); + let original = *faces.get(face).ok_or_else(|| KernelError::Invalid(format!("the body has no face {face}")))?; + let size = body.size().max(1e-6); + let untrimmed = guard("untrim", || untrimmed_face(original, holes, outer, size))?; + let mut rec = Rebuild::default(); + rec.face(&untrimmed, original); + let result = if in_place && body.is_surface() { + surface_body(faces.iter().map(|f| if f.id() == original.id() { untrimmed.clone() } else { (*f).clone() }).collect()) + } else { + surface_body(vec![untrimmed]) + }; + Ok(rebuilt_body(result, &[&body.solid], &rec)) +} + +/// A boundary loop of a face sampled in 3D and in the surface's parameters: for each of its +/// edges (as the loop runs) the curve parameters, points and parameters of its samples, ends +/// included. Periodic parameters are unwrapped along the loop. +struct LoopSamples { + edges: Vec<(mt::Curve, Vec<(f64, Vec3, [f64; 2])>)>, +} + +impl LoopSamples { + /// The loop as a polygon in parameters (each edge's samples but its last, which the next + /// edge starts on). + fn polygon(&self) -> Vec<[f64; 2]> { + self.edges.iter().flat_map(|(_, s)| s.iter().take(s.len().saturating_sub(1)).map(|x| x.2)).collect() + } + + /// Signed area of the loop in parameters (positive: counter-clockwise). + fn area(&self) -> f64 { + let poly = self.polygon(); + let n = poly.len(); + (0..n) + .filter_map(|i| { + let (a, b) = (poly.get(i)?, poly.get((i + 1) % n)?); + Some(0.5 * (a[0] * b[1] - b[0] * a[1])) + }) + .sum() + } + + /// How far `p` is from the loop (from its samples). + fn dist(&self, p: Vec3) -> f64 { + self.edges.iter().flat_map(|(_, s)| s.iter().map(|x| x.1.dist(p))).fold(f64::INFINITY, f64::min) + } +} + +/// Sample a boundary loop (absolute: as it runs on the surface) in the surface's parameters. +fn sample_loop(s: &EvalSurface, dom: &Domain, wire: &mt::Wire, accept: f64) -> Result { + let mut edges = Vec::new(); + let mut prev: Option<[f64; 2]> = None; + for e in wire.edge_iter() { + let c = e.oriented_curve(); + let (t0, t1) = c.range_tuple(); + let mut samples = Vec::with_capacity(EDGE_SAMPLES + 1); + for k in 0..=EDGE_SAMPLES { + let t = t0 + (t1 - t0) * k as f64 / EDGE_SAMPLES as f64; + let p = from_p3(c.subs(t)); + let q = parameters(s, dom, p, prev, accept)?; + samples.push((t, p, q)); + prev = Some(q); + } + edges.push((c, samples)); + } + Ok(LoopSamples { edges }) +} + +/// The parameters of `p` on the surface, unwrapped to lie near `near` along periodic directions. +fn parameters(s: &EvalSurface, dom: &Domain, p: Vec3, near: Option<[f64; 2]>, accept: f64) -> Result<[f64; 2]> { + let mut q = uv::project(s, dom, p, near, accept).ok_or_else(|| KernelError::Failed("the face's boundary does not lie on its surface".into()))?; + if let Some(n) = near { + for d in 0..2 { + q[d] = uv::unwrap(q[d], n[d], dom.period[d]); + } + } + Ok(q) +} + +/// The face untrimmed (see [`untrim`]). +fn untrimmed_face(face: &mt::Face, holes: UntrimHoles, outer: bool, size: f64) -> Result { + let surface = face.surface(); + let eval = + crate::tess::surface::eval_surface(&surface).ok_or_else(|| KernelError::TooLarge("untrimming a face on this kind of surface".into()))?; + let dom = uv::domain(&eval); + let accept = (size * 1e-5).max(1e-6); + let wires = face.absolute_boundaries(); + let loops: Vec = wires.iter().map(|w| sample_loop(&eval, &dom, w, accept)).collect::>()?; + // The outer loop encloses the others: the most area in parameters. + let areas: Vec = loops.iter().map(LoopSamples::area).collect(); + let outer_index = areas + .iter() + .enumerate() + .max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())) + .map(|(i, _)| i) + .ok_or_else(|| KernelError::Invalid("the face has no boundary".into()))?; + let inner: Vec = (0..wires.len()).filter(|i| *i != outer_index).collect(); + let removed: Vec = match holes { + UntrimHoles::All => inner.clone(), + UntrimHoles::At(points) => { + let mut picked = Vec::new(); + for p in points { + let (i, d) = inner + .iter() + .filter_map(|i| loops.get(*i).map(|l| (*i, l.dist(*p)))) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .ok_or_else(|| KernelError::Invalid("the face has no holes".into()))?; + if d > size * 0.05 + 1e-6 { + return Err(KernelError::Invalid(format!("no hole of the face at {p:?}"))); + } + if !picked.contains(&i) { + picked.push(i); + } + } + picked + } + }; + if !outer && removed.is_empty() { + return Err(KernelError::Invalid(if inner.is_empty() { + "the face has no holes to remove (restore its outer boundary instead)".into() + } else { + "nothing to untrim: pick holes to remove or restore the outer boundary".into() + })); + } + let (Some(outer_wire), Some(outer_loop), Some(outer_area)) = (wires.get(outer_index), loops.get(outer_index), areas.get(outer_index)) else { + return Err(KernelError::Invalid("the face has no boundary".into())); + }; + let first = if outer { + let analytic = crate::offset::surf_of(face, (size * 1e-7).max(1e-9) * 100.0).is_some(); + let span = natural_span(&eval, &dom, outer_loop, analytic, accept)?; + bounds_wire(&surface, span, *outer_area > 0.0, size)? + } else { + outer_wire.clone() + }; + let kept = inner.iter().filter(|i| !removed.contains(i)).filter_map(|i| wires.get(*i).cloned()); + let boundaries: Vec = std::iter::once(first).chain(kept).collect(); + let mut f = mt::Face::try_new(boundaries, surface).map_err(|e| KernelError::Failed(format!("untrimmed face: {e}")))?; + if !face.orientation() { + f.invert(); + } + Ok(f) +} + +/// The parameter box the outer boundary is restored to: along each direction the surface's own +/// range where it has one of its own (a free-form surface's; not a periodic or unbounded +/// direction, and not an analytic surface's), else the face's own span. +fn natural_span(s: &EvalSurface, dom: &Domain, outer: &LoopSamples, analytic: bool, accept: f64) -> Result<[(f64, f64); 2]> { + let mut span = [(0.0, 0.0); 2]; + for (d, out) in span.iter_mut().enumerate() { + *out = match (dom.range.get(d).copied().flatten(), dom.period.get(d).copied().flatten()) { + (Some(range), None) if !analytic => range, + (_, period) => { + let lo = extreme(s, dom, outer, d, false, accept); + let hi = extreme(s, dom, outer, d, true, accept); + if let Some(p) = period + && hi - lo >= p * (1.0 - 1e-9) + { + return Err(KernelError::TooLarge("a face that goes all the way round its surface".into())); + } + (lo, hi) + } + }; + if !(out.1 - out.0 > 1e-12 * (1.0 + out.0.abs() + out.1.abs())) || !out.0.is_finite() || !out.1.is_finite() { + return Err(KernelError::Invalid("the face has no extent along its surface".into())); + } + } + Ok(span) +} + +/// The least (`max == false`) or greatest parameter along direction `d` the loop reaches: the +/// extreme sample, then placed on its edge between the neighbouring samples. Samples where the +/// surface does not move along `d` (a pole) say nothing about `d` and are passed over. +fn extreme(s: &EvalSurface, dom: &Domain, lp: &LoopSamples, d: usize, max: bool, accept: f64) -> f64 { + let sign = if max { 1.0 } else { -1.0 }; + let mut best: Option<(f64, usize, usize)> = None; + for (ei, (_, samples)) in lp.edges.iter().enumerate() { + for (k, (_, _, q)) in samples.iter().enumerate() { + if uv::degenerate(s, *q, d) { + continue; + } + let x = sign * q[d]; + if best.is_none_or(|(b, _, _)| x > b) { + best = Some((x, ei, k)); + } + } + } + let Some((x, ei, k)) = best else { return f64::NAN }; + let Some((curve, samples)) = lp.edges.get(ei) else { return sign * x }; + let (Some(at), Some(lo), Some(hi)) = (samples.get(k), samples.get(k.saturating_sub(1)), samples.get(k + 1).or(samples.get(k))) else { + return sign * x; + }; + // Golden-section search for the extreme between the neighbouring samples. + let value = |t: f64| -> Option { + let p = from_p3(curve.subs(t)); + let q = uv::project(s, dom, p, Some(at.2), accept)?; + Some(sign * uv::unwrap(q[d], at.2[d], dom.period[d])) + }; + let g = (5f64.sqrt() - 1.0) / 2.0; + let (mut a, mut b) = (lo.0, hi.0); + let mut best = x; + for _ in 0..EXTREME_STEPS { + let (c, e) = (b - g * (b - a), a + g * (b - a)); + let (Some(fc), Some(fe)) = (value(c), value(e)) else { break }; + best = best.max(fc).max(fe); + if fc > fe { + b = e; + } else { + a = c; + } + } + sign * best +} + +/// The closed boundary of the parameter box `span` on `surface`, counter-clockwise in its +/// parameters when `ccw`: four parameter-line edges, fewer where a side shrinks to a point (a +/// pole: the corners at its ends are one vertex). +fn bounds_wire(surface: &mt::Surface, span: [(f64, f64); 2], ccw: bool, size: f64) -> Result { + let [(u0, u1), (v0, v1)] = span; + let tol = size * 1e-6 + 1e-9; + // The corners counter-clockwise from (u0, v0); each side runs between two of them. + let mut corners = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)]; + if !ccw { + corners.reverse(); + } + let points: Vec = corners.iter().map(|(u, v)| surface.subs(*u, *v)).collect(); + if points.iter().any(|p| !(p.x.is_finite() && p.y.is_finite() && p.z.is_finite())) { + return Err(KernelError::Failed("the surface cannot be evaluated at its bounds".into())); + } + // Corners at one point (a pole) are one vertex. + let mut vertices: Vec = Vec::with_capacity(4); + for (i, p) in points.iter().enumerate() { + let same = points.iter().take(i).position(|q| q.distance(*p) <= tol).and_then(|j| vertices.get(j).cloned()); + vertices.push(same.unwrap_or_else(|| mt::Vertex::new(*p))); + } + let mut edges = Vec::with_capacity(4); + for i in 0..4 { + let j = (i + 1) % 4; + let (Some(&(ua, va)), Some(&(ub, vb)), Some(a), Some(b)) = (corners.get(i), corners.get(j), vertices.get(i), vertices.get(j)) else { + continue; + }; + let along_u = va == vb; + let (w, from, to) = if along_u { (va, ua, ub) } else { (ua, va, vb) }; + let mid = if along_u { surface.subs(0.5 * (from + to), w) } else { surface.subs(w, 0.5 * (from + to)) }; + if a.id() == b.id() { + // A side shrunk to a pole is no edge; one that closes on itself can't be bounded. + if mid.distance(a.point()) > tol { + return Err(KernelError::TooLarge("a surface that closes on itself within the face's bounds".into())); + } + continue; + } + let curve = iso_curve(surface, along_u, w, from, to)?; + let close = |p: mt::Point3, q: mt::Point3| p.distance(q) <= tol * 10.0; + let (t0, t1) = curve.range_tuple(); + if !close(curve.subs(t0), a.point()) || !close(curve.subs(t1), b.point()) || !passes(&curve, mid, tol * 10.0) { + return Err(KernelError::Failed("a restored boundary edge does not follow its surface".into())); + } + edges.push(mt::Edge::try_new(a, b, curve).map_err(|e| KernelError::Failed(format!("restored boundary edge: {e}")))?); + } + if edges.len() < 2 { + return Err(KernelError::Invalid("the face's bounds shrink to a point".into())); + } + Ok(edges.into()) +} + +/// Whether the curve passes within `tol` of `p`. +fn passes(curve: &mt::Curve, p: mt::Point3, tol: f64) -> bool { + let (t0, t1) = curve.range_tuple(); + let start = (0..=64) + .map(|k| t0 + (t1 - t0) * k as f64 / 64.0) + .min_by(|a, b| curve.subs(*a).distance(p).total_cmp(&curve.subs(*b).distance(p))) + .unwrap_or(t0); + let t = curve.search_nearest_parameter(p, Some(start), 100).unwrap_or(start).clamp(t0.min(t1), t0.max(t1)); + curve.subs(t).distance(p) <= tol +} + +/// The parameter line of `surface` at `w` (v = w when `along_u`, else u = w) from parameter +/// `from` to `to` along it, exact: a line on a plane, a B-spline surface's iso-curve, and on a +/// revolved surface the profile turned to its angle or an arc about the axis. +fn iso_curve(surface: &mt::Surface, along_u: bool, w: f64, from: f64, to: f64) -> Result { + let at = |t: f64| if along_u { surface.subs(t, w) } else { surface.subs(w, t) }; + match surface { + mt::Surface::Plane(_) => Ok(mt::Curve::Line(mt::Line(at(from), at(to)))), + mt::Surface::BSplineSurface(_) | mt::Surface::NurbsSurface(_) => { + let hom: mt::BSplineSurface = match surface { + mt::Surface::BSplineSurface(b) => mt::BSplineSurface::new( + b.knot_vecs().clone(), + b.control_points().iter().map(|row| row.iter().map(|p| p.to_homogeneous()).collect()).collect(), + ), + mt::Surface::NurbsSurface(n) => n.non_rationalized().clone(), + _ => return Err(KernelError::Failed("not a B-spline surface".into())), + }; + let iso = crate::step_in::split::iso_bsp(&hom, w, along_u).map_err(KernelError::Failed)?; + Ok(piece(mt::Curve::NurbsCurve(iso), from, to)) + } + mt::Surface::RevolutedCurve(proc) => { + let rev = proc.entity(); + // Truck's revolved surface is (profile parameter, angle), swapped when the processor + // is turned over; the processor's matrix places it. + let along_profile = along_u == proc.orientation(); + let transform = *proc.transform(); + let (origin, axis) = (rev.origin(), rev.axis().normalize()); + if along_profile { + let profile = rev.entity_curve(); + if let mt::Curve::Line(_) = profile { + return Ok(mt::Curve::Line(mt::Line(at(from), at(to)))); + } + if let mt::Curve::IntersectionCurve(_) = profile { + return Err(KernelError::TooLarge("untrimming a surface revolved from an intersection curve".into())); + } + let turn = mt::Matrix4::from_translation(origin - mt::Point3::new(0.0, 0.0, 0.0)) + * mt::Matrix4::from_axis_angle(axis, mt::Rad(w)) + * mt::Matrix4::from_translation(mt::Point3::new(0.0, 0.0, 0.0) - origin); + Ok(piece(profile.transformed(transform * turn), from, to)) + } else { + // The profile point at `w` turns about the axis: an arc from angle `from` to `to`. + let p = rev.entity_curve().subs(w); + let centre = origin + axis * (p - origin).dot(axis); + let r = (p - centre).magnitude(); + if !(r > 1e-12) { + return Err(KernelError::Failed("an arc on the axis of a revolved surface".into())); + } + let x = (p - centre) / r; + let frame = crate::step_in::geom::Frame { o: centre, x, y: axis.cross(x), z: axis }; + let arc = if to > from { + crate::step_in::geom::conic_arc(&frame, r, r, from, to - from) + } else { + crate::step_in::geom::conic_arc(&frame, r, r, to, from - to).inverse() + }; + Ok(mt::Curve::NurbsCurve(arc).transformed(transform)) + } + } + } +} + +/// The piece of `curve` between its parameters `from` and `to`, running from `from`. +fn piece(mut curve: mt::Curve, from: f64, to: f64) -> mt::Curve { + let (lo, hi) = if from < to { (from, to) } else { (to, from) }; + let (t0, t1) = curve.range_tuple(); + let eps = (t1 - t0).abs() * 1e-12; + if lo > t0 + eps { + curve = curve.cut(lo); + } + if hi < t1 - eps { + let _ = curve.cut(hi); + } + if from > to { + curve.invert(); + } + curve +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{extrude, measure}; + use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2}; + use std::f64::consts::PI; + + fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-9) + } + + fn inner_loops(b: &Body) -> usize { + b.solid.face_iter().map(|f| f.boundaries().len().saturating_sub(1)).sum() + } + + /// A 40 × 20 × 2 plate with three holes of radius 2 at x = 10, 20, 30 on y = 10. + fn plate() -> Body { + let outer = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 20.0), Vec2::new(0.0, 20.0)]); + let holes = [10.0, 20.0, 30.0].iter().map(|x| Loop2::circle(Vec2::new(*x, 10.0), 2.0).reversed()).collect(); + extrude(&Plane::XY, &[Region2 { outer, holes }], 0.0, 2.0).unwrap().pop().unwrap() + } + + fn face_at(b: &Body, p: Vec3) -> usize { + crate::surfaces::nearest_face(b, p).unwrap() + } + + /// The plate's top with every hole taken away is the whole 40 × 20 rectangle, one face with + /// no inner loops; one hole taken away leaves two (area 800 − 2 · 4π); the face's own loops + /// are kept by identity. Nothing to take away is refused. + #[test] + fn plate_with_three_holes_untrimmed_to_its_rectangle() { + let b = plate(); + let top = face_at(&b, Vec3::new(5.0, 5.0, 2.0)); + let all = untrim(&b, top, UntrimHoles::All, false, false).unwrap(); + assert!(all.is_surface() && all.face_count() == 1 && inner_loops(&all) == 0); + assert!(rel(measure(&all).unwrap().area, 800.0) < 1e-9, "{}", measure(&all).unwrap().area); + let one = untrim(&b, top, UntrimHoles::At(&[Vec3::new(22.0, 10.0, 2.0)]), false, false).unwrap(); + assert_eq!(inner_loops(&one), 2); + let want = 800.0 - 2.0 * 4.0 * PI; + assert!(rel(measure(&one).unwrap().area, want) < 1e-3, "{} vs {want}", measure(&one).unwrap().area); // (measured on meshes) + // The plate is untouched (three holes through its top and bottom); the history says the + // face is the top, trimmed differently. + assert_eq!(inner_loops(&b), 6); + let h = all.history().unwrap(); + assert_eq!(h.faces.first().map(|f| f.change), Some(crate::history::Change::Modified)); + // Nothing to do: a face without holes, a point on no hole, an empty pick. + let side = b.solid.face_iter().position(|f| f.boundaries().len() == 1).unwrap(); + assert!(untrim(&b, side, UntrimHoles::All, false, false).is_err()); + assert!(untrim(&b, top, UntrimHoles::At(&[Vec3::new(5.0, 5.0, 2.0)]), false, false).is_err()); + assert!(untrim(&b, top, UntrimHoles::At(&[]), false, false).is_err()); + assert!(untrim(&b, 99, UntrimHoles::All, false, false).is_err()); + } + + /// A face whose hole is stored before its outer loop (as an imported face may list them): + /// the outer loop is the one enclosing the most, so the 10 × 10 square comes back whole. + #[test] + fn the_outer_loop_is_found_whatever_order_the_loops_are_in() { + let r = Region2 { + outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]), + holes: vec![Loop2::polygon(&[Vec2::new(4.0, 4.0), Vec2::new(4.0, 6.0), Vec2::new(6.0, 6.0), Vec2::new(6.0, 4.0)])], + }; + let sheet = crate::patch_region(&Plane::XY, &r).unwrap(); + let f = sheet.solid.face_iter().next().unwrap().clone(); + let mut loops = f.absolute_boundaries().clone(); + loops.reverse(); + let swapped = mt::Face::new_unchecked(loops, f.surface()); + let swapped = if f.orientation() { swapped } else { swapped.inverse() }; + let b = surface_body(vec![swapped]); + assert!(rel(measure(&b).unwrap().area, 96.0) < 1e-9); + let back = untrim(&b, 0, UntrimHoles::All, false, true).unwrap(); + assert!(back.face_count() == 1 && inner_loops(&back) == 0); + assert!(rel(measure(&back).unwrap().area, 100.0) < 1e-9, "{}", measure(&back).unwrap().area); + } + + /// A half cylinder sheet (radius 10, angles 0…π, height 20) trimmed by the slanted plane + /// z = 15 − y/2: its top edge runs from 15 down to 10 and back, its area ∫ (15 − 5 sin θ) r dθ + /// = 150π − 100. Restored, it fills its own span again, angles 0…π and heights 0…15: 150π, + /// on the same cylinder. + #[test] + fn a_trimmed_cylinder_face_is_restored_to_its_full_span() { + let arc = vec![Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: 10.0, start: 0.0, sweep: PI }]; + let sheet = crate::extrude_sheet(&Plane::XY, &[arc], 0.0, 20.0).unwrap(); + let tool = crate::SplitTool::Plane(Plane::from_normal(Vec3::new(0.0, 0.0, 15.0), Vec3::new(0.0, 0.5, 1.0)).unwrap()); + let cut = crate::trim(&sheet, &tool, Vec3::new(0.0, 10.0, 1.0)).unwrap(); + assert_eq!(cut.face_count(), 1); + let trimmed = measure(&cut).unwrap().area; + assert!(rel(trimmed, 150.0 * PI - 100.0) < 1e-3, "{trimmed}"); // (measured on meshes) + // Nothing to take away and the outer boundary kept: nothing to do. + assert!(untrim(&cut, 0, UntrimHoles::All, false, false).is_err()); + let restored = untrim(&cut, 0, UntrimHoles::All, true, false).unwrap(); + assert!(restored.face_count() == 1 && inner_loops(&restored) == 0); + let a = measure(&restored).unwrap().area; + assert!(rel(a, 150.0 * PI) < 1e-3, "{a} vs {}", 150.0 * PI); // (measured on meshes) + let bb = measure(&restored).unwrap().bbox; + assert!(bb.min.z.abs() < 1e-9 && (bb.max.z - 15.0).abs() < 1e-6, "{bb:?}"); + assert!(matches!( + crate::face_surface(&restored, 0), + Some(crate::FaceSurface::Cylinder { radius, .. }) if (radius - 10.0).abs() < 1e-6 + )); + } + + /// A revolved face: the cone frustum swept by the line from radius 5 at z = 0 to radius 10 + /// at z = −10 (slant √125), its first face (angles 0…½), trimmed by the plane z = −8 + y/2. + /// The cut reaches lowest (profile parameter 0.8) at angle 0, so the face's own span is + /// angles 0…½ by parameters 0…0.8, restored with arcs about the axis and straight sides: + /// ½ · √125 · ∫₀^0.8 (5 + 5t) dt = ½ · √125 · 5.6. + #[test] + fn a_trimmed_cone_face_is_restored_to_its_span() { + let line = vec![Seg2::Line { a: Vec2::new(5.0, 0.0), b: Vec2::new(10.0, 10.0) }]; + let sheet = crate::revolve_sheet(&Plane::XZ, &[line], Vec2::ZERO, Vec2::Y, 0.0, 1.0).unwrap(); + let tool = crate::SplitTool::Plane(Plane::from_normal(Vec3::new(0.0, 0.0, -8.0), Vec3::new(0.0, -0.5, 1.0)).unwrap()); + let (c, s) = (0.25f64.cos(), 0.25f64.sin()); + let cut = crate::trim(&sheet, &tool, Vec3::new(5.0 * c, 5.0 * s, 0.0)).unwrap(); + let face = face_at(&cut, Vec3::new(5.5 * c, 5.5 * s, -1.0)); + let restored = untrim(&cut, face, UntrimHoles::All, true, false).unwrap(); + let want = 0.5 * 125f64.sqrt() * 5.6; + let a = measure(&restored).unwrap().area; + assert!(rel(a, want) < 1e-3, "{a} vs {want}"); // (measured on meshes) + let bb = measure(&restored).unwrap().bbox; + assert!((bb.min.z + 8.0).abs() < 1e-6 && bb.max.z.abs() < 1e-6, "{bb:?}"); + assert!(matches!(crate::face_surface(&restored, 0), Some(crate::FaceSurface::Cone { .. }))); + } + + /// A torus face (a revolved arc) with a piece of its outer equator cut off by the plane + /// x = 12: the cut touches one side of the face only, so its span is the face's whole span + /// and the restored face is the face as it was before the cut. + #[test] + fn a_torus_face_cut_at_one_side_is_restored_whole() { + let torus = crate::torus(Vec3::ZERO, 10.0, 3.0).unwrap(); + let f = torus.solid.face_iter().next().unwrap().clone(); + let face = surface_body(vec![f]); + let before = measure(&face).unwrap().area; + let tool = crate::SplitTool::Plane(Plane::from_normal(Vec3::new(12.0, 0.0, 0.0), Vec3::X).unwrap()); + let cut = crate::trim(&face, &tool, Vec3::new(0.0, 13.0, 0.0)).unwrap(); + let trimmed = measure(&cut).unwrap().area; + assert!(trimmed < before * 0.99, "{trimmed} vs {before}"); + let restored = untrim(&cut, 0, UntrimHoles::All, true, false).unwrap(); + let a = measure(&restored).unwrap().area; + assert!(rel(a, before) < 1e-3, "{a} vs {before}"); // (measured on meshes) + assert!(matches!( + crate::face_surface(&restored, 0), + Some(crate::FaceSurface::Torus { major, minor, .. }) if (major - 10.0).abs() < 1e-6 && (minor - 3.0).abs() < 1e-6 + )); + } + + /// A free-form face goes back to its surface's own parameter box: half of the saddle + /// z = x y / 10 over 0…10 × 0…10 (a bilinear patch, its iso-lines straight), x from 0 to 5, + /// restored to the whole patch: x and y 0…10, z 0…10. + #[test] + fn a_free_form_face_is_restored_to_its_surfaces_bounds() { + let pt = |x: f64, y: f64| mt::Point3::new(x, y, x * y / 10.0); + let knots = mt::KnotVec::bezier_knot(1); + let saddle = mt::BSplineSurface::new((knots.clone(), knots), vec![vec![pt(0.0, 0.0), pt(0.0, 10.0)], vec![pt(10.0, 0.0), pt(10.0, 10.0)]]); + let corners = [pt(0.0, 0.0), pt(5.0, 0.0), pt(5.0, 10.0), pt(0.0, 10.0)]; + let v: Vec = corners.iter().map(|p| mt::Vertex::new(*p)).collect(); + let edges: Vec = + (0..4).map(|i| mt::Edge::new_unchecked(&v[i], &v[(i + 1) % 4], mt::Curve::Line(mt::Line(corners[i], corners[(i + 1) % 4])))).collect(); + let half = surface_body(vec![mt::Face::new_unchecked(vec![edges.into()], mt::Surface::BSplineSurface(saddle))]); + assert!(crate::face_surface(&half, 0).is_none(), "not analytic"); + assert!((measure(&half).unwrap().bbox.max.x - 5.0).abs() < 1e-9); + let whole = untrim(&half, 0, UntrimHoles::All, true, false).unwrap(); + let bb = measure(&whole).unwrap().bbox; + assert!(bb.min.x.abs() < 1e-9 && bb.min.y.abs() < 1e-9 && bb.min.z.abs() < 1e-9, "{bb:?}"); + assert!((bb.max.x - 10.0).abs() < 1e-9 && (bb.max.y - 10.0).abs() < 1e-9 && (bb.max.z - 10.0).abs() < 1e-9, "{bb:?}"); + assert_eq!(whole.solid.face_iter().next().unwrap().boundaries().first().map(|w| w.len()), Some(4)); + } + + /// The plate exported to STEP and read back: its imported top face untrims to the whole + /// rectangle as the plate's own did. + #[test] + fn an_imported_step_face_is_untrimmed() { + let text = crate::step_export(&[&plate()], "untrim test").unwrap(); + let imported = crate::step_import(&text).unwrap(); + let b = &imported.bodies.first().unwrap().body; + let top = face_at(b, Vec3::new(5.0, 5.0, 2.0)); + assert_eq!(b.solid.face_iter().nth(top).unwrap().boundaries().len(), 4); + let all = untrim(b, top, UntrimHoles::All, false, false).unwrap(); + assert!(all.face_count() == 1 && inner_loops(&all) == 0); + assert!(rel(measure(&all).unwrap().area, 800.0) < 1e-9); + let restored = untrim(b, top, UntrimHoles::All, true, false).unwrap(); + assert!(rel(measure(&restored).unwrap().area, 800.0) < 1e-9); + } + + /// In place: a surface body of the plate's top and bottom keeps its bottom (and its three + /// holes), the top replaced by the whole rectangle: 800 + 800 − 3 · 4π. A solid's face is + /// copied out whatever `in_place` says. + #[test] + fn in_place_untrims_a_surface_and_copies_a_solids_face() { + let b = plate(); + let top = face_at(&b, Vec3::new(5.0, 5.0, 2.0)); + let copied = untrim(&b, top, UntrimHoles::All, false, true).unwrap(); + assert_eq!(copied.face_count(), 1); + let sheet = crate::copy_faces(&b, &[Vec3::new(5.0, 5.0, 2.0), Vec3::new(5.0, 5.0, 0.0)]).unwrap(); + assert!(sheet.is_surface() && sheet.face_count() == 2 && inner_loops(&sheet) == 6); + let top = face_at(&sheet, Vec3::new(5.0, 5.0, 2.0)); + let whole = untrim(&sheet, top, UntrimHoles::All, false, true).unwrap(); + assert!(whole.is_surface()); + assert_eq!((whole.face_count(), inner_loops(&whole)), (2, 3)); + let want = 1600.0 - 3.0 * 4.0 * PI; + assert!(rel(measure(&whole).unwrap().area, want) < 1e-3, "{} vs {want}", measure(&whole).unwrap().area); // (measured on meshes) + } + + /// Points that are not finite and too many picks are refused, never panic. + #[test] + fn hostile_inputs_are_refused() { + let b = plate(); + let top = face_at(&b, Vec3::new(5.0, 5.0, 2.0)); + assert!(untrim(&b, top, UntrimHoles::At(&[Vec3::new(f64::NAN, 0.0, 0.0)]), false, false).is_err()); + let many = vec![Vec3::ZERO; 10_001]; + assert!(untrim(&b, top, UntrimHoles::At(&many), false, false).is_err()); + } +} diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index 7b14a25b..74a83aae 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -1,5 +1,5 @@ //! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Revolve, Sweep and -//! Loft, Patch, Stitch, Thicken, Extend, Trim and Split Face. Each has a live preview and Edit Feature. +//! Loft, Patch, Stitch, Thicken, Extend, Trim, Untrim and Split Face. Each has a live preview and Edit Feature. use serde_json::{Map, Value, json}; use solidcraft_engine::Sel; @@ -49,6 +49,12 @@ pub enum Surf { distance: String, }, Trim, + /// `holes`: an index into [`UNTRIM_HOLES`]. + Untrim { + holes: usize, + outer: bool, + in_place: bool, + }, SplitFace, } @@ -68,6 +74,7 @@ impl Surf { Surf::Thicken { .. } => "THICKEN", Surf::Extend { .. } => "EXTEND", Surf::Trim => "TRIM", + Surf::Untrim { .. } => "UNTRIM", Surf::SplitFace => "SPLIT FACE", } } @@ -83,6 +90,9 @@ impl Surf { } } +/// Which holes Untrim takes away: every one, the picked ones, or none (the outer boundary only). +const UNTRIM_HOLES: [&str; 3] = ["All", "Picked", "None"]; + /// The sides a surface extrude goes to (labels, and the `direction` parameter). const EXTRUDE_SIDES: [(&str, &str); 3] = [("One Side", "positive"), ("Symmetric", "symmetric"), ("Flipped", "negative")]; @@ -105,6 +115,7 @@ fn inputs(k: &Surf) -> Vec { SelInput::new("Trimming tool", PLANES | FACES, false), SelInput::new("Side to keep", FACES, false), ], + Surf::Untrim { .. } => vec![SelInput::new("Face", FACES, false), SelInput::new("Holes (edges on them)", EDGES, true)], Surf::SplitFace => vec![SelInput::new("Faces to split", FACES, true), SelInput::new("Splitting tool", PLANES | FACES, false)], } } @@ -125,6 +136,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { "surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 }, "surface.extend" => Surf::Extend { distance: "10 mm".into() }, "surface.trim" => Surf::Trim, + "surface.untrim" => Surf::Untrim { holes: 0, outer: false, in_place: false }, "solid.split_face" => Surf::SplitFace, _ => return None, }; @@ -187,6 +199,17 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool { combo(ui, "sx_dir", &EXTRUDE_SIDES.map(|(label, _)| label), direction); ui.end_row(); } + Surf::Untrim { holes, outer, in_place } => { + row_label(ui, "Holes"); + combo(ui, "ut_holes", &UNTRIM_HOLES, holes); + ui.end_row(); + row_label(ui, "Restore outer boundary"); + ui.checkbox(outer, ""); + ui.end_row(); + row_label(ui, "In place (surfaces)"); + ui.checkbox(in_place, ""); + ui.end_row(); + } Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep | Surf::Loft => {} } enter @@ -402,6 +425,33 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map { + need(0, "the face to untrim")?; + let (body, point) = items(inputs, 0) + .first() + .and_then(|x| if let Sel::Face { body, point, .. } = x { Some((body.clone(), pt(*point))) } else { None }) + .ok_or("pick the face to untrim")?; + let mut p = json!({"face": {"body": body, "point": point}}); + match holes { + 0 => p["holes"] = json!("all"), + 1 => { + let picked: Vec = + items(inputs, 1).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect(); + if picked.is_empty() { + return Err("pick edges of the holes to remove".into()); + } + p["holes"] = json!(picked); + } + _ => p["holes"] = json!([]), + } + if *outer { + p["outer"] = json!(true); + } + if *in_place { + p["in_place"] = json!(true); + } + ("surface.untrim", p) + } Surf::SplitFace => { need(0, "the faces to split")?; need(1, "the splitting tool")?; @@ -519,6 +569,17 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K crate::dialogs::face_sel(s, *keep).into_iter().collect(), ], ), + FeatureKind::SurfaceUntrim { face, holes, outer, in_place } => { + let (index, picked) = match holes { + None => (0, Vec::new()), + Some(points) if points.is_empty() => (2, Vec::new()), + Some(points) => (1, points.iter().filter_map(edge).collect()), + }; + ( + Surf::Untrim { holes: index, outer: *outer, in_place: *in_place }, + vec![crate::dialogs::face_sel(s, face.point).into_iter().collect(), picked], + ) + } FeatureKind::SplitFace { faces, plane, tool, .. } => ( Surf::SplitFace, vec![faces.iter().filter_map(|p| crate::dialogs::face_sel(s, *p)).collect(), tool_sel(app, plane, tool.as_ref()).into_iter().collect()], diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index bf96ba49..7ae56dc7 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -968,6 +968,41 @@ fn surface_loft_dialog_makes_a_section_per_sketch() { assert_eq!(back.inputs[0].items, vec![Sel::SketchCurve { id: "a".into() }, Sel::SketchCurve { id: "b".into() }]); } +#[test] +fn untrim_dialog_takes_a_face_its_holes_and_the_outer_boundary() { + // A 40 × 20 × 2 plate with three holes: its top untrimmed (every hole) is the whole 800 mm² + // rectangle; one hole picked by an edge; Edit Feature brings the picks and options back. + let mut app = app_with(json!([ + {"command": "solid.box", "params": {"length": 40, "width": 20, "height": 2, "body_name": "Plate"}}, + {"command": "solid.cylinder", "params": {"radius": 2, "height": 4, "base": [10, 10, -1], "operation": "cut", "targets": ["Plate"]}}, + {"command": "solid.cylinder", "params": {"radius": 2, "height": 4, "base": [20, 10, -1], "operation": "cut", "targets": ["Plate"]}}, + {"command": "solid.cylinder", "params": {"radius": 2, "height": 4, "base": [30, 10, -1], "operation": "cut", "targets": ["Plate"]}} + ])); + let mut d = start(&mut app, vec![], "surface.untrim"); + assert!(apply_commands(&app, &d).is_err(), "no face yet"); + d.inputs[0].items = vec![Sel::Face { body: "Plate".into(), index: 0, point: Vec3::new(5.0, 5.0, 2.0) }]; + let (id, p) = apply_commands(&app, &d).unwrap().remove(0); + assert_eq!((id.as_str(), &p["face"], &p["holes"]), ("surface.untrim", &json!({"body": "Plate", "point": [5.0, 5.0, 2.0]}), &json!("all")), "{p}"); + app.session.execute(&id, &p).unwrap(); + let m = app.session.model.state().bodies.last().unwrap().mesh().measure(); + assert!((m.area - 800.0).abs() < 1e-6, "{}", m.area); + app.session.execute("edit.undo", &json!({})).unwrap(); + // Picked holes: none picked is refused; an edge of the middle hole sends its point. + if let Kind::Surface(crate::dialogs_surface::Surf::Untrim { holes, outer, .. }) = &mut d.kind { + *holes = 1; + *outer = true; + } + assert!(apply_commands(&app, &d).is_err(), "no holes picked"); + d.inputs[1].items = vec![Sel::Edge { body: "Plate".into(), index: 0, point: Vec3::new(22.0, 10.0, 2.0) }]; + let (id, p) = apply_commands(&app, &d).unwrap().remove(0); + assert_eq!((&p["holes"], &p["outer"]), (&json!([[22.0, 10.0, 2.0]]), &json!(true)), "{p}"); + app.session.execute(&id, &p).unwrap(); + let fid = app.session.doc.features.last().unwrap().id; + let back = crate::dialogs::for_feature(&app, fid, None).unwrap(); + assert!(matches!(back.kind, Kind::Surface(crate::dialogs_surface::Surf::Untrim { holes: 1, outer: true, in_place: false }))); + assert_eq!((back.inputs[0].items.len(), back.inputs[1].items.len()), (1, 1)); +} + #[test] fn offset_surface_dialog_takes_faces_a_distance_and_the_chain() { // A box top picked, moved up 5: a 10 × 10 square at z = 15; Edit Feature brings it back. diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index bd9b73c6..45516fae 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -19,6 +19,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::SurfaceSweep { .. } => "surface_sweep", FeatureKind::SurfaceLoft { .. } => "surface_loft", FeatureKind::SurfaceOffset { .. } => "surface_offset", + FeatureKind::SurfaceUntrim { .. } => "untrim", FeatureKind::Revolve { .. } => "revolve", FeatureKind::Fillet { .. } => "fillet", FeatureKind::Chamfer { .. } => "chamfer", diff --git a/docs/features.md b/docs/features.md index 42b4acec..fcd7a5e4 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **363 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **364 commands.** Planned (registered, not built yet): 5. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (99) @@ -146,7 +146,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Align | `solid.align` | SOLID › MODIFY | | Remove | `solid.remove` | SOLID › MODIFY | -## Surfaces (12, 5 planned) +## Surfaces (13, 4 planned) | Command | Id | Toolbar | |---|---|---| @@ -163,7 +163,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Ruled Surface *(planned: [#762](https://github.com/bherbruck/solidcraft/issues/762))* | `surface.ruled` | SURFACE › CREATE | | Boundary Surface *(planned: [#763](https://github.com/bherbruck/solidcraft/issues/763))* | `surface.boundary` | SURFACE › CREATE | | Blend Surface *(planned: [#764](https://github.com/bherbruck/solidcraft/issues/764))* | `surface.blend` | SURFACE › CREATE | -| Untrim *(planned: [#761](https://github.com/bherbruck/solidcraft/issues/761))* | `surface.untrim` | SURFACE › MODIFY | +| Untrim | `surface.untrim` | SURFACE › MODIFY | | Unstitch | `surface.unstitch` | SURFACE › MODIFY | | Reverse Normal | `surface.reverse_normal` | SURFACE › MODIFY | | Surface Fillet *(planned: [#769](https://github.com/bherbruck/solidcraft/issues/769))* | `surface.fillet` | SURFACE › MODIFY |