From c1b6478ab5bdb3061425a3066256c792ac6e940b Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sat, 10 Oct 2026 21:31:19 -0400 Subject: [PATCH] fix: join additive extrusions and merge coplanar faces --- crates/engine/src/cmd/features.rs | 31 ++-- crates/engine/src/tests.rs | 65 ++++++++- crates/kernel/src/ops.rs | 9 ++ crates/kernel/src/prism.rs | 138 +++++++++++++++++- crates/kernel/src/tests.rs | 28 ++++ crates/ui-egui/src/dialogs.rs | 2 +- .../extrude_side_contact_defaults_join.json | 12 ++ .../s32_cut_in_active_component.json | 2 +- docs/book/src/guide/features.md | 6 + 9 files changed, 272 insertions(+), 21 deletions(-) create mode 100644 crates/ui-egui/tests/scenarios/extrude_side_contact_defaults_join.json diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 263e16b1..13641d0f 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "CREATE") .icon("extrude") .key("E") - .params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, join out of one, else new); targets?: [body]; name?; body_name?"), + .params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"), CommandSpec::new("solid.revolve", "Revolve", revolve) .at("SOLID", "CREATE") .icon("revolve") @@ -141,7 +141,8 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> { } /// What `operation: "auto"` means for an extrude with these parameters: `cut` when it goes -/// into a body, `join` when it grows out of one, `new` otherwise. +/// into a body, otherwise `join` when the active component has a solid. Keeping an +/// addition separate requires an explicit `new` operation. pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> { let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?; let dir = str_(p, "direction").map(str::to_ascii_lowercase); @@ -154,23 +155,25 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> { } // Only the active component's bodies take part (or the ones named as participants). let parts = string_list(p, "participants"); - let inside = |q: Vec3| { - st.bodies - .iter() - .filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) }) - .any(|b| b.mesh().contains(q)) - }; - let eps = (d.abs() * 1e-3).max(1e-3); - let sign = if d < 0.0 { -1.0 } else { 1.0 }; + let bodies: Vec<_> = st + .bodies + .iter() + .filter(|b| if parts.is_empty() { s.doc.body_component(&b.name, b.feature) == s.active_component } else { parts.contains(&b.name) }) + .collect(); + if bodies.is_empty() { + return Some("new"); + } + let inside = |q: Vec3| bodies.iter().any(|b| b.mesh().contains(q)); if symmetric { - return Some(if inside(base + n * (d.abs() * 0.5)) || inside(base - n * (d.abs() * 0.5)) { "cut" } else { "new" }); + return Some(if inside(base + n * (d.abs() * 0.5)) || inside(base - n * (d.abs() * 0.5)) { "cut" } else { "join" }); } if inside(base + n * (d * 0.5)) { Some("cut") - } else if inside(base - n * (eps * sign)) { - Some("join") } else { - Some("new") + // A profile can meet material along its side, or only around part of a + // screw boss. A single point behind its centre cannot establish contact. + // Default additions to Join and let the boolean merge the actual geometry. + Some("join") } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 66523e7d..e156d671 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -592,7 +592,7 @@ fn preview_of_an_edit() { assert!(Arc::ptr_eq(&doc, &s.doc)); } -/// `operation: "auto"`: into a body cuts, out of a body joins, in free space makes a new body. +/// `operation: "auto"`: into a body cuts; additions default to Join when a solid exists. #[test] fn extrude_auto_operation() { let mut s = Session::default(); @@ -605,7 +605,7 @@ fn extrude_auto_operation() { // Pulled out, it joins. assert_eq!(auto_operation(&s, &json!({"face": [20, 15, 15], "distance": 5})), Some("join")); // A sketch on XY under the box: up goes into it (cut), down grows out of it (join), and a - // profile beside the box makes a new body. + // profile beside the box still defaults to Join (New Body remains explicit). run(&mut s, "sketch.create", json!({"plane": "XY"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [15, 15]})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [60, 5], "p1": [70, 15]})); @@ -613,11 +613,70 @@ fn extrude_auto_operation() { let at = |x: f64, d: f64| json!({"profiles": [{"point": [x, 10]}], "distance": d}); assert_eq!(auto_operation(&s, &at(10.0, 8.0)), Some("cut")); assert_eq!(auto_operation(&s, &at(10.0, -8.0)), Some("join")); - assert_eq!(auto_operation(&s, &at(65.0, 8.0)), Some("new")); + assert_eq!(auto_operation(&s, &at(65.0, 8.0)), Some("join")); run(&mut s, "solid.extrude", json!({"profiles": [{"point": [10, 10]}], "distance": 8, "operation": "auto"})); assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0 - 100.0 * 8.0) < 1e-6); } +/// Additive extrusions default to joining even when only a profile side touches material. +#[test] +fn auto_extrude_joins_side_contact_elbow_and_keeps_new_body_explicit() { + for operation in ["auto", "new"] { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 30})); + run(&mut s, "sketch.create", json!({"plane": {"origin": [10, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 20], "p1": [30, 30]})); + run(&mut s, "sketch.finish", json!({})); + // The profile centre is outside the vertical arm. Contact is along its + // y=10 side, not behind the centre along the extrusion's X direction. + let p = json!({"distance": 10, "to": [0, 15, 25], "operation": operation}); + assert_eq!(auto_operation(&s, &p), Some("join")); + run(&mut s, "solid.extrude", p); + assert!((volume(&mut s) - 5000.0).abs() < 1e-6); + let st = s.model.state(); + assert_eq!(st.bodies.len(), if operation == "auto" { 1 } else { 2 }); + if operation == "auto" { + let body = &st.bodies.first().unwrap().body; + assert!(body.validity().is_empty()); + assert_eq!(body.lumps().unwrap().len(), 1); + assert_eq!(body.face_count(), 8, "the two flat elbow sides must be single faces"); + assert!(body.contains(geom::Vec3::new(5.0, 10.0, 25.0))); + } + } +} + +#[test] +fn auto_extrude_joins_corner_screw_boss() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 30})); + run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})); + run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 4})); + run(&mut s, "sketch.circle.center", json!({"center": [10, 10], "radius": 1})); + run(&mut s, "sketch.finish", json!({})); + let p = json!({"profiles": [{"point": [12, 10]}], "distance": 5, "operation": "auto"}); + assert_eq!(auto_operation(&s, &p), Some("join")); + run(&mut s, "solid.extrude", p); + assert!(rel(volume(&mut s), 3000.0 + PI * 15.0 * 5.0) < 1e-3); + let st = s.model.state(); + assert_eq!(st.bodies.len(), 1); + let body = &st.bodies.first().unwrap().body; + assert_eq!(body.lumps().unwrap().len(), 1); + assert!(body.validity().is_empty()); + assert!(!body.contains(geom::Vec3::new(10.0, 10.0, 32.0)), "the screw hole stays open"); +} + +#[test] +fn auto_extrude_starts_new_body_in_empty_component() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + run(&mut s, "component.create", json!({"name": "Other"})); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 2})); + run(&mut s, "sketch.finish", json!({})); + assert_eq!(auto_operation(&s, &json!({"distance": 5})), Some("new")); + assert_eq!(auto_operation(&s, &json!({"distance": 5, "participants": ["missing"]})), Some("new")); +} + /// Measuring picked geometry: distance between faces, angle, edge length. #[test] fn measure_items() { diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 718a7f6d..55671441 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -248,6 +248,15 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { // Prisms along one direction (plates with holes): exact, in 2D (so only the volume // bounds are checked). if let Some(Ok(r)) = crate::prism::prism_boolean(a, b, op) { + // Slab-by-slab prism joins sew a closed solid, but leave cap and wall + // pieces along slab boundaries. Merge those coplanar faces just as the + // general B-rep boolean does, retaining intentional Split Face lines. + let keep: Vec> = a.split_keep().iter().chain(b.split_keep()).cloned().collect(); + let r = if op == BoolOp::Union { + r.map(|body| guard("heal prism boolean", || Body::new(crate::heal::heal_keep((*body.solid).clone(), size, &keep))).unwrap_or(body)) + } else { + r + }; match &r { Some(body) => { let v = volume(body); diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index b5a75d95..3af72983 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -3,6 +3,8 @@ //! spanning the target's height, the result is the prism over the 2D boolean of their //! sections. Plates with holes, slots and patterns of them: exact, and immune to the mesh //! intersection's trouble with walls meeting along lines. +//! Stepped extrusions (case floors and walls) use sections between every cap level, so a +//! circular boss can meet the rim without shifting either operand off its exact plane. use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; use truck_modeling as mt; @@ -418,7 +420,6 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option> { /// meets it, so the faces share their edges. `None` when a slab is empty or a curve isn't a /// line or an arc. fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option> { - use crate::sew::{EdgeSpec, FaceSpec, SurfSpec}; let mut levels = vec![pa.lo, pa.hi, pb.lo, pb.hi]; levels.sort_by(f64::total_cmp); levels.dedup_by(|x, y| (*x - *y).abs() < tol); @@ -434,6 +435,12 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option }; slabs.push(oriented(&s)); } + sew_sections(plane, d, &levels, &slabs, tol) +} + +/// Sew the exact sections between successive levels, including empty gaps. +fn sew_sections(plane: &Plane, d: Vec3, levels: &[f64], slabs: &[Vec], tol: f64) -> Option> { + use crate::sew::{EdgeSpec, FaceSpec, SurfSpec}; // Faces at each level: up where the slab below sticks out, down where the one above does. let diff = |x: &[Region2], y: &[Region2]| -> Option> { match (x.is_empty(), y.is_empty()) { @@ -507,7 +514,7 @@ fn sew_stack(plane: &Plane, d: Vec3, pa: &Prism, pb: &Prism, tol: f64) -> Option Some(crate::sew::sew(&faces)) } -pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { +fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { let size = a.size().max(b.size()); let tol = (size * 1e-7).max(1e-9) * 10.0; // Candidate directions: a's planar face normals (each line once). @@ -573,3 +580,130 @@ pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option Option> { + use mt::{ParametricSurface3D, SearchNearestParameter}; + if let Some(p) = prism_of(b, d, plane, tol) { + return Some(vec![p]); + } + if b.face_count() > 2048 { + return None; + } + let mut events = Vec::new(); + for f in b.solid.face_iter() { + let surf = f.oriented_surface(); + if let mt::Surface::Plane(p) = &surf { + let n = p.normal(); + let n = Vec3::new(n.x, n.y, n.z).normalized()?; + if n.dot(d).abs() > 1.0 - 1e-9 { + let mut loops = Vec::new(); + for w in f.boundaries() { + let mut segs = Vec::new(); + for e in w.edge_iter() { + segs.extend(seg2(e, plane, tol)?); + } + loops.push(Loop2 { segs }); + } + let (outer, holes) = split_outer(loops)?; + let region = Region2 { outer, holes }; + events.push((plane.height(from_p3(p.origin())), n.dot(d) > 0.0, oriented(&[region]))); + continue; + } + if n.dot(d).abs() > 1e-9 { + return None; + } + } else { + // Curved walls must be extruded along d, not slanted or rounded caps. + for v in f.vertex_iter() { + let (u, w) = surf.search_nearest_parameter(v.point(), None, 50)?; + let n = surf.normal(u, w); + if Vec3::new(n.x, n.y, n.z).dot(d).abs() > 1e-7 { + return None; + } + } + } + } + let mut levels: Vec = events.iter().map(|(z, _, _)| *z).collect(); + levels.sort_by(f64::total_cmp); + levels.dedup_by(|x, y| (*x - *y).abs() < tol); + if levels.len() < 2 || levels.len() > 64 || levels.iter().any(|z| !z.is_finite()) { + return None; + } + let mut current = Vec::new(); + let mut stack = Vec::new(); + for (i, z) in levels.iter().enumerate() { + // Remove terminating material before adding any material starting here. + for up in [true, false] { + for (_, _, section) in events.iter().filter(|(h, u, _)| (*h - *z).abs() < tol && *u == up) { + current = match (current.is_empty(), up) { + (true, false) => section.clone(), + (true, true) => return None, + (false, _) => boolean2d(¤t, section, if up { BoolOp::Cut } else { BoolOp::Union }, tol)?, + }; + } + } + if let Some(next) = levels.get(i + 1) + && !current.is_empty() + { + stack.push(Prism { lo: *z, hi: *next, regions: oriented(¤t) }); + } + } + (current.is_empty() && !stack.is_empty()).then_some(stack) +} + +fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { + // This path repairs joins with a common outer cap (a boss flush with a rim). + // Other extents and subtractive operations retain their existing B-rep path. + if op != BoolOp::Union { + return None; + } + fn section(ps: &[Prism], lo: f64, hi: f64, tol: f64) -> &[Region2] { + ps.iter().find(|p| p.lo <= lo + tol && p.hi >= hi - tol).map(|p| p.regions.as_slice()).unwrap_or(&[]) + } + let tol = (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0; + let mut dirs: Vec = Vec::new(); + for f in a.solid.face_iter() { + if let mt::Surface::Plane(p) = f.oriented_surface() { + let n = p.normal(); + if let Some(n) = Vec3::new(n.x, n.y, n.z).normalized() + && !dirs.iter().any(|m| m.dot(n).abs() > 1.0 - 1e-9) + { + dirs.push(n); + } + } + } + let (d, plane, pa, pb) = dirs.into_iter().take(8).find_map(|d| { + let plane = Plane::from_normal(Vec3::ZERO, d)?; + Some((d, plane, stack_of(a, d, &plane, tol)?, stack_of(b, d, &plane, tol)?)) + })?; + let ends = |ps: &[Prism]| ps.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.lo), hi.max(p.hi))); + let (ea, eb) = (ends(&pa), ends(&pb)); + if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol { + return None; + } + let mut levels: Vec = pa.iter().chain(&pb).flat_map(|p| [p.lo, p.hi]).collect(); + levels.sort_by(f64::total_cmp); + levels.dedup_by(|x, y| (*x - *y).abs() < tol); + let mut slabs = Vec::new(); + for w in levels.windows(2) { + let (lo, hi) = (*w.first()?, *w.get(1)?); + let (x, y) = (section(&pa, lo, hi, tol), section(&pb, lo, hi, tol)); + let s = match (x.is_empty(), y.is_empty(), op) { + (true, _, BoolOp::Cut | BoolOp::Intersect) | (_, true, BoolOp::Intersect) => Vec::new(), + (true, _, BoolOp::Union) => y.to_vec(), + (_, true, _) => x.to_vec(), + _ => boolean2d(x, y, op, tol)?, + }; + slabs.push(oriented(&s)); + } + if slabs.iter().all(Vec::is_empty) { + return Some(Ok(None)); + } + sew_sections(&plane, d, &levels, &slabs, tol).map(|r| r.map(Some)) +} + +pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { + crate::guard("prism boolean", || Ok(simple_prism_boolean(a, b, op).or_else(|| stack_boolean(a, b, op)))).ok().flatten() +} diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 8eb8b6c9..bcceeb0e 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -302,6 +302,8 @@ fn planar_booleans_with_coincident_faces() { // Through the public boolean (B-rep first, polygon fallback). let u2 = boolean(&base, &wall, BoolOp::Union).unwrap().unwrap(); assert!(rel(measure(&u2).unwrap().volume, m.volume) < 1e-9); + assert_eq!(u2.face_count(), 8, "coplanar wall and base faces must merge in the B-rep"); + assert!(u2.validity().is_empty()); // Two boxes sharing a face, and a cut flush with a side. let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(); let b = box_solid(Vec3::new(10.0, 0.0, 0.0), Vec3::new(20.0, 10.0, 10.0)).unwrap(); @@ -315,6 +317,32 @@ fn planar_booleans_with_coincident_faces() { assert!(rel(measure(&i).unwrap().volume, 250.0) < 1e-9); } +#[test] +fn corner_boss_on_stepped_case_has_one_exact_top_face() { + let outer = box_solid(Vec3::new(-50.0, -25.0, 0.0), Vec3::new(50.0, 25.0, 25.0)).unwrap(); + let cavity = box_solid(Vec3::new(-48.0, -23.0, 2.0), Vec3::new(48.0, 23.0, 26.0)).unwrap(); + let case = boolean(&outer, &cavity, BoolOp::Cut).unwrap().unwrap(); + let boss = cylinder(Vec3::new(-45.0, 20.0, 2.0), Vec3::Z, 5.0, 23.0).unwrap(); + let joined = boolean(&case, &boss, BoolOp::Union).unwrap().unwrap(); + let common = boolean(&case, &boss, BoolOp::Intersect).unwrap().unwrap(); + let cut = boolean(&case, &boss, BoolOp::Cut).unwrap().unwrap(); + let (a, b, u, i, c) = + (measure(&case).unwrap(), measure(&boss).unwrap(), measure(&joined).unwrap(), measure(&common).unwrap(), measure(&cut).unwrap()); + assert!(rel(u.volume + i.volume, a.volume + b.volume) < 1e-4); + assert!(rel(c.volume + i.volume, a.volume) < 1e-4); + for body in [&joined, &common, &cut] { + assert!(body.validity().is_empty()); + } + assert!((u.bbox.max.z - 25.0).abs() < 1e-9, "the boss must not be nudged above the rim"); + let top_faces = |body: &Body| { + body.faces(0.01).unwrap().iter().filter(|f| f.plane_normal.is_some_and(|n| n.z > 0.999999) && (f.centroid.z - 25.0).abs() < 1e-9).count() + }; + assert_eq!(top_faces(&joined), 1, "the boss and both walls share one top face"); + let rounded = fillet(&joined, &[Vec3::new(-50.0, 25.0, 12.5)], 5.0).unwrap(); + assert!(rounded.validity().is_empty()); + assert_eq!(top_faces(&rounded), 1, "rounding the outside corner must retain the merged top"); +} + #[test] fn sphere_booleans() { let s = sphere(Vec3::ZERO, 15.0).unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 75476dbd..5038b79e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -33,7 +33,7 @@ pub enum Kind { distance: String, direction: usize, operation: usize, - /// The operation follows the geometry (into a body: cut, out of one: join, else new) + /// Into a body: cut; additions: join when a solid exists, otherwise new. /// until the user picks one. auto_op: bool, /// The other side's distance (two sides). diff --git a/crates/ui-egui/tests/scenarios/extrude_side_contact_defaults_join.json b/crates/ui-egui/tests/scenarios/extrude_side_contact_defaults_join.json new file mode 100644 index 00000000..d85c0871 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/extrude_side_contact_defaults_join.json @@ -0,0 +1,12 @@ +[ + {"note": "An elbow addition meets the existing arm along a profile side, not behind its centre. The automatic dialog must keep Join selected."}, + {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 30}}}, + {"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": {"origin": [10, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}}}}, + {"call": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [10, 20], "p1": [30, 30]}}}, + {"call": "engine.execute", "params": {"command": "sketch.finish", "params": {}}}, + {"start": "solid.extrude"}, + {"text": "-10"}, + {"key": "Enter"}, + {"expect": {"bodies": 1, "errors": 0, "dialog": null, "volume": 5000}}, + {"shot": "elbow_joined"} +] diff --git a/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json b/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json index e4377d17..ef40d373 100644 --- a/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json +++ b/crates/ui-egui/tests/scenarios/s32_cut_in_active_component.json @@ -208,7 +208,7 @@ "start": "solid.extrude" }, { - "widget": "New Body" + "widget": "Join" }, { "widget": "Cut" diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index cc69c0fb..555f0561 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -12,6 +12,12 @@ it. | Construct | offset plane, plane at angle | | Inspect | measure, section analysis, interference, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass | +Extrude automatically selects **Join** for additions when the active component already has a +solid, including sideways elbow connections and screw bosses that meet only part of a face. +Extruding into existing material selects **Cut**. In an empty component it selects **New Body**; +choose **New Body** explicitly to keep an addition separate. Changing the default does not alter +operations already saved in a design. + Fillets and chamfers handle straight edges between planar faces, whole smooth loops, runs of neighbouring edges with mitred or spherical corners, and closed chains of curved edges (a rolling ball). See the roadmap's known limitations for the rest.