fix: join additive extrusions and merge coplanar faces
This commit is contained in:
1 parent
17e8e3eff2
commit
c1b6478ab5
9 files changed
+272
-21
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -248,6 +248,15 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
// 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<Vec<Vec3>> = 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);
|
||||
|
||||
+136
-2
@@ -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<Vec<Seg2>> {
|
||||
/// 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<Result<Body>> {
|
||||
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<Region2>], tol: f64) -> Option<Result<Body>> {
|
||||
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<Vec<Region2>> {
|
||||
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<Result<Option<Body>>> {
|
||||
fn simple_prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
|
||||
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<Result<Opt
|
||||
}
|
||||
Some(Ok(Some(first)))
|
||||
}
|
||||
|
||||
/// A stepped extrusion (case floor plus walls), recovered from its cap events.
|
||||
/// Downward caps add a section above their level; upward caps remove one.
|
||||
fn stack_of(b: &Body, d: Vec3, plane: &Plane, tol: f64) -> Option<Vec<Prism>> {
|
||||
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<f64> = 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<Result<Option<Body>>> {
|
||||
// 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<Vec3> = 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<f64> = 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<Result<Option<Body>>> {
|
||||
crate::guard("prism boolean", || Ok(simple_prism_boolean(a, b, op).or_else(|| stack_boolean(a, b, op)))).ok().flatten()
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
@@ -208,7 +208,7 @@
|
||||
"start": "solid.extrude"
|
||||
},
|
||||
{
|
||||
"widget": "New Body"
|
||||
"widget": "Join"
|
||||
},
|
||||
{
|
||||
"widget": "Cut"
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user