mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 19:45:54 +00:00
doc: a join groups pieces that touch nothing; only edge and point contacts are kept apart
With touching: separate (the default) a join groups the joined body's
pieces by face contact. The groups that hold the material of the body
before the feature stay in it (each unless it touches one already kept;
the first group where the lineage doesn't say), and so do the groups that
touch nothing: a virtual grouping later operations take as one body. Every
other group that touches another along an edge or at a point becomes a
body of its own, and the feature's notice says so. The joined body keeps
its name and never holds an edge or point contact. A join that only
grouped pieces apart gives no notice, so KeptSeparate::Apart is gone.
one_body still keeps every piece in one body.
SMLib's export copies an edge or vertex that touching solids share once
per solid, so the record never listed those contacts:
Brep::region_contacts now takes such copies (the same curve and interval
between vertices at the same points, a vertex at the same point) as the
edge or vertex they share. A contact SMLib leaves out of the topology (the
3612 spoke tangent to its hub) is found on the exact geometry, the least
distance within the record tolerance, and reported as KeptSeparate::
Touching ("along a line or at a point").
The pending format change join-touching-bodies stays: older designs kept
pieces touching along an edge or at a point in one body too. Its notes say
so, and the reopened legacy emboss still expects touching: one_body.
Tests restored to their form before cc49081df and 733d3bd19: kernel-cases
2896-join-arm-end-near-ring-wall-gap (one body of two shells), 3379, 3455
and 3526 (no touching: one_body); the engine tests of posts joined while
apart (twice), two cubes for interference, a ring with a cylinder, the
buried tab and a_join_adds_only_its_sketch's 0.1 mm gap.
join_meeting_nothing_keeps_the_first_body expects one body and no notice.
The #3378 spoke stays 1 mm inside the hub.
New tests: blocks touching along an edge are split with the along-an-edge
notice; blocks apart stay one body without one; a ring with an arm on its
corner edge and a hub apart gives ring and hub in one body and the arm on
its own (doc); the engine's notice for an edge contact; the record's
contacts of cubes sharing an edge or a vertex (kernel).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -196,15 +196,16 @@ impl Operation {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a join makes of solids that only touch, along an edge or at a point, or don't touch at
|
||||
/// all: they share no volume and no area, so nothing fuses them.
|
||||
/// What a join makes of pieces that share no face: they share no volume and no area, so nothing
|
||||
/// fuses them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Touching {
|
||||
/// Each becomes a body of its own.
|
||||
/// A piece that touches another only along an edge or at a point cannot be one solid with it
|
||||
/// and becomes a body of its own; pieces that touch nothing stay one body of several pieces.
|
||||
#[default]
|
||||
Separate,
|
||||
/// They stay one body of several pieces.
|
||||
/// Every piece stays in one body, also those that touch along an edge or at a point.
|
||||
OneBody,
|
||||
}
|
||||
|
||||
@@ -2585,8 +2586,9 @@ pub struct Feature {
|
||||
/// its faces are named after them and references follow them through Update Links (#3330).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub link_faces: Vec<Vec<String>>,
|
||||
/// What a join of this feature makes of solids that only touch or don't touch: separate
|
||||
/// bodies (the default) or one body of several pieces.
|
||||
/// What a join of this feature makes of pieces that share no face: those that touch along an
|
||||
/// edge or at a point become separate bodies and those that touch nothing stay one body (the
|
||||
/// default), or every piece stays one body.
|
||||
#[serde(default, skip_serializing_if = "Touching::is_separate")]
|
||||
pub touching: Touching,
|
||||
#[serde(flatten)]
|
||||
|
||||
+118
-26
@@ -777,7 +777,8 @@ impl ModelState {
|
||||
self.fitted.get(&feature)?.iter().fold(None, |acc, r| Some(acc.map_or((*r, *r), |(lo, hi): (f64, f64)| (lo.min(*r), hi.max(*r)))))
|
||||
}
|
||||
|
||||
/// How the solids join `feature` kept as separate bodies met, when it kept any apart.
|
||||
/// How the pieces join `feature` split off into bodies of their own touched the others, when
|
||||
/// it split any off.
|
||||
pub fn kept_separate(&self, feature: u64) -> Option<KeptSeparate> {
|
||||
self.kept_separate.get(&feature).copied()
|
||||
}
|
||||
@@ -830,28 +831,30 @@ pub struct ModelState {
|
||||
/// The radius spline of each edge a fillet tapered (a point on the edge with it, by feature
|
||||
/// id, see `FilletFit::Taper`).
|
||||
tapered: BTreeMap<u64, Vec<(Vec3, kernel::Taper)>>,
|
||||
/// How the solids a join kept as separate bodies met (by feature id), for its notice.
|
||||
/// How the pieces a join split off into bodies of their own touched the others (by feature
|
||||
/// id), for its notice.
|
||||
kept_separate: BTreeMap<u64, KeptSeparate>,
|
||||
}
|
||||
|
||||
/// How the solids a join kept as separate bodies (`Touching::Separate`) met: the most any two
|
||||
/// of them shared, least first.
|
||||
/// How a piece a join split off into a body of its own (`Touching::Separate`) touched the
|
||||
/// pieces it was joined with: the most any such piece shared with another, least first.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum KeptSeparate {
|
||||
/// Nothing: they don't touch.
|
||||
Apart,
|
||||
/// A point.
|
||||
AtPoint,
|
||||
/// A line or a point their boundaries don't hold (a face tangent to another): found on the
|
||||
/// exact geometry, which doesn't say which.
|
||||
Touching,
|
||||
/// A curve: an edge of both.
|
||||
AlongEdge,
|
||||
}
|
||||
|
||||
impl KeptSeparate {
|
||||
/// Its id in a feature's result: `apart`, `at_point` or `along_edge`.
|
||||
/// Its id in a feature's result: `at_point`, `touching` or `along_edge`.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
KeptSeparate::Apart => "apart",
|
||||
KeptSeparate::AtPoint => "at_point",
|
||||
KeptSeparate::Touching => "touching",
|
||||
KeptSeparate::AlongEdge => "along_edge",
|
||||
}
|
||||
}
|
||||
@@ -859,8 +862,8 @@ impl KeptSeparate {
|
||||
/// What a feature's result says about it.
|
||||
pub fn notice(self) -> &'static str {
|
||||
match self {
|
||||
KeptSeparate::Apart => "These bodies don't touch, so they were kept as separate bodies.",
|
||||
KeptSeparate::AtPoint => "These bodies only touch at a point, so they were kept as separate bodies.",
|
||||
KeptSeparate::Touching => "These bodies only touch along a line or at a point, so they were kept as separate bodies.",
|
||||
KeptSeparate::AlongEdge => "These bodies only touch along an edge, so they were kept as separate bodies.",
|
||||
}
|
||||
}
|
||||
@@ -3143,10 +3146,14 @@ fn join(state: &mut ModelState, tools: Vec<Traced>, target_idx: &[usize]) -> Res
|
||||
Ok(joined)
|
||||
}
|
||||
|
||||
/// With `Touching::Separate`: the pieces of the joined body `name` that share no area with each
|
||||
/// other become bodies of their own, named as a cut's pieces are; pieces that share a face stay
|
||||
/// one body. How the pieces kept apart met, when it has any (a sheet metal or mesh body keeps
|
||||
/// its pieces).
|
||||
/// With `Touching::Separate`: the joined body `name` never keeps two pieces that touch only
|
||||
/// along an edge or at a point, since they cannot be one solid. Its pieces that share a face are
|
||||
/// one group. The groups that hold the material of the body before the feature (the first group
|
||||
/// where that is not known) stay in it, each unless it touches one already kept; every other
|
||||
/// group that touches another along an edge or at a point (as the record says, or on the exact
|
||||
/// geometry where SMLib left the contact out) becomes a body of its own, named as a cut's pieces
|
||||
/// are; groups that touch nothing stay in the joined body, which keeps its name. How
|
||||
/// the pieces split off touched, when any were (a sheet metal or mesh body keeps its pieces).
|
||||
fn separate_touching(state: &mut ModelState, name: &str) -> Result<Option<KeptSeparate>> {
|
||||
let Some(i) = state.bodies.iter().position(|b| b.name == name) else { return Ok(None) };
|
||||
let Some(mb) = state.bodies.get(i).filter(|b| !b.body.is_mesh() && !state.sheets.iter().any(|s| s.body == name)) else { return Ok(None) };
|
||||
@@ -3170,31 +3177,116 @@ fn separate_touching(state: &mut ModelState, name: &str) -> Result<Option<KeptSe
|
||||
}
|
||||
}
|
||||
let mut groups: Vec<Vec<usize>> = Vec::new();
|
||||
let mut group_of: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let mut group_of: Vec<usize> = Vec::with_capacity(n);
|
||||
let mut by_root: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
for k in 0..n {
|
||||
let g = *group_of.entry(find(&root, k)).or_insert_with(|| {
|
||||
let g = *by_root.entry(find(&root, k)).or_insert_with(|| {
|
||||
groups.push(Vec::new());
|
||||
groups.len() - 1
|
||||
});
|
||||
if let Some(list) = groups.get_mut(g) {
|
||||
list.push(k);
|
||||
}
|
||||
group_of.push(g);
|
||||
}
|
||||
if groups.len() < 2 {
|
||||
return Ok(None);
|
||||
// The groups each group touches along an edge or at a point (groups never share a face), and
|
||||
// the most any two of them share.
|
||||
let mut touches: Vec<Vec<usize>> = vec![Vec::new(); groups.len()];
|
||||
let mut most: Option<KeptSeparate> = None;
|
||||
for c in &contacts {
|
||||
let (Some(&a), Some(&b)) = (group_of.get(c.regions[0]), group_of.get(c.regions[1])) else { continue };
|
||||
if a == b {
|
||||
continue;
|
||||
}
|
||||
for (g, h) in [(a, b), (b, a)] {
|
||||
if let Some(list) = touches.get_mut(g).filter(|l| !l.contains(&h)) {
|
||||
list.push(h);
|
||||
}
|
||||
}
|
||||
most = most.max(Some(if c.contact == Contact::Vertex { KeptSeparate::AtPoint } else { KeptSeparate::AlongEdge }));
|
||||
}
|
||||
// The most any two of the separate pieces share (never a face).
|
||||
let kept = contacts
|
||||
.iter()
|
||||
.filter(|c| find(&root, c.regions[0]) != find(&root, c.regions[1]))
|
||||
.map(|c| if c.contact == Contact::Vertex { KeptSeparate::AtPoint } else { KeptSeparate::AlongEdge })
|
||||
.max()
|
||||
.unwrap_or(KeptSeparate::Apart);
|
||||
let pieces = mb.body.parts(&groups)?;
|
||||
// Groups the record has apart may still touch where SMLib left the contact out of the
|
||||
// topology (a face tangent to another along a line).
|
||||
if groups.len() > 1 {
|
||||
for [a, b] in unrecorded_touches(&mb.body.parts(&groups)?, &touches)? {
|
||||
for (g, h) in [(a, b), (b, a)] {
|
||||
if let Some(list) = touches.get_mut(g) {
|
||||
list.push(h);
|
||||
}
|
||||
}
|
||||
most = most.max(Some(KeptSeparate::Touching));
|
||||
}
|
||||
}
|
||||
let Some(most) = most else { return Ok(None) };
|
||||
// What stays in the joined body: the groups that touch nothing, and those of the body before
|
||||
// the feature, each unless it touches one already kept.
|
||||
let mut anchor = groups_from_body(mb, &groups).filter(|a| a.contains(&true)).unwrap_or_default();
|
||||
if anchor.is_empty() {
|
||||
anchor = (0..groups.len()).map(|g| g == 0).collect();
|
||||
}
|
||||
let mut stays: Vec<bool> = touches.iter().map(Vec::is_empty).collect();
|
||||
for g in 0..groups.len() {
|
||||
let free = touches.get(g).is_some_and(|t| !t.iter().any(|h| stays.get(*h) == Some(&true)));
|
||||
if free
|
||||
&& anchor.get(g) == Some(&true)
|
||||
&& let Some(slot) = stays.get_mut(g)
|
||||
{
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
let mut kept: Vec<usize> = groups.iter().zip(&stays).filter(|(_, s)| **s).flat_map(|(g, _)| g.iter().copied()).collect();
|
||||
kept.sort_unstable();
|
||||
let parts: Vec<Vec<usize>> = std::iter::once(kept).chain(groups.into_iter().zip(&stays).filter(|(_, s)| !**s).map(|(g, _)| g)).collect();
|
||||
let pieces = mb.body.parts(&parts)?;
|
||||
let mut taken: std::collections::BTreeSet<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
|
||||
let named = named_pieces(mb, pieces, &mut taken);
|
||||
state.bodies.splice(i..=i, named);
|
||||
Ok(Some(kept))
|
||||
Ok(Some(most))
|
||||
}
|
||||
|
||||
/// The pairs of `pieces` (positions) that touch though `touches` (the pieces each touches, as
|
||||
/// the record says) doesn't list them: their least distance on the exact faces and edges is
|
||||
/// within the larger of their record tolerances. A distance only the meshes give is not trusted
|
||||
/// to say they touch.
|
||||
fn unrecorded_touches(pieces: &[Body], touches: &[Vec<usize>]) -> Result<Vec<[usize; 2]>> {
|
||||
let meshes = pieces
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let chord = (p.size() * 1e-4).max(1e-6);
|
||||
Ok((p.mesh(&kernel::MeshTolerance::chord(chord))?, chord))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let shapes: Vec<kernel::DistanceShape> = pieces.iter().zip(&meshes).map(|(p, (m, chord))| kernel::DistanceShape::new(p, m, *chord)).collect();
|
||||
let mut out = Vec::new();
|
||||
for (a, (sa, pa)) in shapes.iter().zip(pieces).enumerate() {
|
||||
for (b, (sb, pb)) in shapes.iter().zip(pieces).enumerate().skip(a + 1) {
|
||||
if touches.get(a).is_some_and(|t| t.contains(&b)) || !sa.bounds().meets(&sb.bounds()) {
|
||||
continue;
|
||||
}
|
||||
let tol = pa.record().tolerance.max(pb.record().tolerance);
|
||||
if kernel::body_distance(sa, sb, tol)?.is_some_and(|d| d.exact && d.distance <= tol) {
|
||||
out.push([a, b]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Which of `groups` (of positions in `mb`'s material regions) hold material of the body named
|
||||
/// as `mb` before the feature: a face of theirs came from one of its faces. `None` where the
|
||||
/// body's lineage is not known.
|
||||
fn groups_from_body(mb: &ModelBody, groups: &[Vec<usize>]) -> Option<Vec<bool>> {
|
||||
let from = mb.lineage()?.faces_from_body(&mb.name);
|
||||
let record = mb.body.record();
|
||||
let regions = record.material_regions();
|
||||
let holds = |k: usize| {
|
||||
let shells = regions.get(k).and_then(|&r| record.regions.get(r)).map(|r| r.shells.as_slice()).unwrap_or_default();
|
||||
shells.iter().any(|&s| match record.shells.get(s as usize) {
|
||||
Some(solidcraft_geom::brep::Shell::Faces { faces, .. }) => faces.iter().any(|f| from.get(f.face as usize) == Some(&true)),
|
||||
_ => false,
|
||||
})
|
||||
};
|
||||
Some(groups.iter().map(|g| g.iter().any(|&k| holds(k))).collect())
|
||||
}
|
||||
|
||||
/// The bodies the pieces of `mb` become: the first keeps its name, the others are numbered after
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! A feature that joins saves what it makes of solids that only touch, along an edge or at a
|
||||
//! point, or don't touch at all: `touching`, `separate` (each becomes a body of its own, the
|
||||
//! default, not saved) or `one_body` (one body of several pieces). Older builds kept every join's
|
||||
//! pieces in one body, so this step gives each feature of an older design that can join
|
||||
//! `one_body`, which keeps its bodies as they were. The last release refuses a design with this
|
||||
//! change (the newer format number).
|
||||
//! A feature that joins saves what it makes of pieces that share no face: `touching`,
|
||||
//! `separate` (the default, not saved: a piece that touches another only along an edge or at a
|
||||
//! point becomes a body of its own, pieces that touch nothing stay one body) or `one_body` (every
|
||||
//! piece in one body). Older builds kept every join's pieces in one body, also those touching
|
||||
//! along an edge or at a point, so this step gives each feature of an older design that can join
|
||||
//! `one_body`, which keeps its bodies as they were (only a join with such a contact would
|
||||
//! change). The last release refuses a design with this change (the newer format number).
|
||||
use serde_json::Value;
|
||||
|
||||
/// Feature types that join without an `operation` saying so: a combine (whose `new` joins too),
|
||||
|
||||
@@ -96,6 +96,24 @@ impl Lineage {
|
||||
Lineage { history: self.history.clone(), operands }
|
||||
}
|
||||
|
||||
/// Whether each face of the body this is the lineage of came, in whole or in part, from a
|
||||
/// face or edge of `name`, a body of the model before the feature.
|
||||
pub(crate) fn faces_from_body(&self, name: &str) -> Vec<bool> {
|
||||
let from: Vec<bool> = self.operands.iter().map(|op| matches!(op.as_ref(), Operand::Body(n) if n == name)).collect();
|
||||
self.history
|
||||
.faces
|
||||
.iter()
|
||||
.map(|face| {
|
||||
face.origins.iter().any(|origin| match *origin {
|
||||
FaceOrigin::Face { operand, .. } | FaceOrigin::Edge { operand, .. } | FaceOrigin::Offset { operand, .. } => {
|
||||
from.get(operand) == Some(&true)
|
||||
}
|
||||
FaceOrigin::Profile { .. } | FaceOrigin::Cap { .. } => false,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A tool of `faces` faces and no edges (a mesh's triangles) whose faces have no role.
|
||||
pub(crate) fn unnamed_faces(faces: usize) -> Lineage {
|
||||
let faces = (0..faces).map(|face| FaceProv { origins: vec![FaceOrigin::Face { operand: 0, face }], change: Change::Kept }).collect();
|
||||
|
||||
@@ -672,3 +672,78 @@ fn empty_boolean_targets_without_overlap_keep_plain_message() {
|
||||
assert_eq!(model.result(cut).unwrap().error.as_deref(), Some("there is no body to cut or intersect"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A box of `size` at `corner`, made with `operation`.
|
||||
fn cube(doc: &mut Document, corner: Vec3, size: [f64; 3], operation: Operation) -> u64 {
|
||||
let [length, width, height] = size.map(|x| Expr::new(x.to_string()));
|
||||
doc.add_feature(FeatureKind::Box { corner: point_expr(corner), length, width, height, operation }, None).unwrap()
|
||||
}
|
||||
|
||||
/// The bodies of `doc` evaluated, by name with their volumes and pieces, and how feature
|
||||
/// `feature`'s join touched the pieces it split off.
|
||||
fn joined(doc: &Document, feature: u64) -> (Vec<(String, f64, usize)>, Option<KeptSeparate>) {
|
||||
let mut m = Model::new();
|
||||
m.evaluate(doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
|
||||
let kept = m.results.iter().find(|r| r.id == feature).and_then(|r| r.state.kept_separate(feature));
|
||||
let bodies = m.state().bodies.iter().map(|b| (b.name.clone(), measure(&b.body).unwrap().volume, b.body.lumps().unwrap().len())).collect();
|
||||
(bodies, kept)
|
||||
}
|
||||
|
||||
/// Two blocks joined along an edge cannot be one solid: the join splits them into two bodies and
|
||||
/// says they touch along an edge.
|
||||
#[test]
|
||||
fn a_join_splits_blocks_touching_along_an_edge() {
|
||||
let mut doc = Document::new("edge");
|
||||
cube(&mut doc, Vec3::ZERO, [10.0; 3], Operation::NewBody);
|
||||
let join = cube(&mut doc, Vec3::new(10.0, 10.0, 0.0), [10.0; 3], Operation::Join);
|
||||
let (bodies, kept) = joined(&doc, join);
|
||||
assert_eq!(bodies.len(), 2, "{bodies:?}");
|
||||
assert!(bodies.iter().all(|(_, v, lumps)| rel(*v, 1000.0) < 1e-6 && *lumps == 1), "{bodies:?}");
|
||||
assert_eq!(kept, Some(KeptSeparate::AlongEdge));
|
||||
assert!(KeptSeparate::AlongEdge.notice().contains("along an edge"));
|
||||
}
|
||||
|
||||
/// Two blocks apart joined stay one body of two pieces, and the join says nothing.
|
||||
#[test]
|
||||
fn a_join_keeps_blocks_apart_in_one_body() {
|
||||
let mut doc = Document::new("apart");
|
||||
cube(&mut doc, Vec3::ZERO, [10.0; 3], Operation::NewBody);
|
||||
let join = cube(&mut doc, Vec3::new(20.0, 0.0, 0.0), [10.0; 3], Operation::Join);
|
||||
let (bodies, kept) = joined(&doc, join);
|
||||
assert_eq!(bodies.len(), 1, "{bodies:?}");
|
||||
assert!(rel(bodies[0].1, 2000.0) < 1e-6 && bodies[0].2 == 2, "{bodies:?}");
|
||||
assert_eq!(kept, None);
|
||||
}
|
||||
|
||||
/// A square ring joined with an arm touching its outer corner along an edge and a hub apart in
|
||||
/// its hole: the ring and the hub stay one body under the ring's name, the arm is split off, and
|
||||
/// the join says it touched along an edge.
|
||||
#[test]
|
||||
fn a_join_splits_off_only_what_touches_along_an_edge() {
|
||||
let mut doc = Document::new("ring");
|
||||
cube(&mut doc, Vec3::new(-30.0, -30.0, 0.0), [60.0, 60.0, 6.0], Operation::NewBody);
|
||||
cube(&mut doc, Vec3::new(-24.0, -24.0, -1.0), [48.0, 48.0, 8.0], Operation::Cut);
|
||||
cube(&mut doc, Vec3::new(-5.0, -5.0, 0.0), [10.0, 10.0, 6.0], Operation::NewBody);
|
||||
cube(&mut doc, Vec3::new(30.0, 30.0, 0.0), [20.0, 20.0, 6.0], Operation::NewBody);
|
||||
let combine = doc
|
||||
.add_feature(
|
||||
FeatureKind::Combine {
|
||||
target: "Body1".into(),
|
||||
tools: vec!["Body3".into(), "Body2".into()],
|
||||
operation: Operation::Join,
|
||||
keep_tools: false,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (bodies, kept) = joined(&doc, combine);
|
||||
let (ring, hub, arm) = (60.0 * 60.0 * 6.0 - 48.0 * 48.0 * 6.0, 600.0, 2400.0);
|
||||
assert_eq!(bodies.len(), 2, "{bodies:?}");
|
||||
let named = |name: &str| bodies.iter().find(|(n, _, _)| n == name).cloned();
|
||||
let one = named("Body1").unwrap_or_else(|| panic!("{bodies:?}"));
|
||||
assert!(rel(one.1, ring + hub) < 1e-6 && one.2 == 2, "the ring and the hub: {bodies:?}");
|
||||
let other = bodies.iter().find(|(n, _, _)| n != "Body1").cloned().unwrap_or_else(|| panic!("{bodies:?}"));
|
||||
assert!(rel(other.1, arm) < 1e-6 && other.2 == 1, "the arm: {bodies:?}");
|
||||
assert_eq!(kept, Some(KeptSeparate::AlongEdge));
|
||||
}
|
||||
|
||||
@@ -284,8 +284,7 @@ fn buried_join_end_ignores_bodies_in_other_components() {
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [2, 2], "p1": [4, 4]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Child's box is apart from the tab: `touching: one_body` keeps them one body to measure.
|
||||
run(&mut s, "solid.extrude", json!({"distance": 16, "operation": "join", "body_name": "Tab", "touching": "one_body"}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 16, "operation": "join", "body_name": "Tab"}));
|
||||
let got = volume(&s, if own_body { "Child" } else { "Tab" });
|
||||
let expected = 64.0 + if own_body { 800.0 } else { 0.0 };
|
||||
assert!((got - expected).abs() < 1e-6, "{got}");
|
||||
@@ -1011,11 +1010,11 @@ fn multi_target_join_keeps_nearby_bodies_and_original_absorption_order() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Two posts joined into one body while apart (`touching: one_body`), trimmed by a cut that
|
||||
/// splits neither, then joined to a case they stand on. The cut split the joined posts into
|
||||
/// "Posts" and "Posts (1)" without saying so (`created_bodies` was empty), and the join then took
|
||||
/// only "Posts", leaving the other post behind as a body of its own. A cut that does cut a body in
|
||||
/// two still makes two bodies, and its reply names the new one.
|
||||
/// Two posts joined into one body while apart, trimmed by a cut that splits neither, then joined
|
||||
/// to a case they stand on. The cut split the joined posts into "Posts" and "Posts (1)" without
|
||||
/// saying so (`created_bodies` was empty), and the join then took only "Posts", leaving the other
|
||||
/// post behind as a body of its own. A cut that does cut a body in two still makes two bodies,
|
||||
/// and its reply names the new one.
|
||||
#[test]
|
||||
fn cut_keeps_posts_joined_while_apart_in_one_body() {
|
||||
let mut s = Session::default();
|
||||
@@ -1023,7 +1022,7 @@ fn cut_keeps_posts_joined_while_apart_in_one_body() {
|
||||
s.execute("solid.box", &json!({"length": 60, "width": 40, "height": 10, "center": [0, 0, 5], "operation": "new", "body_name": "Case"})).unwrap();
|
||||
s.execute("solid.box", &post("Posts", -20.0)).unwrap();
|
||||
s.execute("solid.box", &post("Post2", 20.0)).unwrap();
|
||||
s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join", "touching": "one_body"})).unwrap();
|
||||
s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join"})).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 80, "width": 20, "height": 10, "center": [0, 0, 40], "operation": "new", "body_name": "Trim"})).unwrap();
|
||||
let cut = s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Trim"], "operation": "cut"})).unwrap();
|
||||
// Each post loses its top 5 mm: 10 x 10 x 25 left of each.
|
||||
@@ -1064,7 +1063,7 @@ fn combine_of_a_piece_a_cut_used_to_split_off_takes_the_body_it_is_part_of() {
|
||||
.unwrap();
|
||||
s.execute("solid.box", &post("Posts", -20.0)).unwrap();
|
||||
s.execute("solid.box", &post("Post2", 20.0)).unwrap();
|
||||
s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join", "touching": "one_body"})).unwrap();
|
||||
s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join"})).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 80, "width": 20, "height": 10, "center": [0, 0, 40], "operation": "new", "body_name": "Trim"}))
|
||||
.unwrap();
|
||||
s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Trim"], "operation": "cut"})).unwrap();
|
||||
|
||||
@@ -112,8 +112,9 @@ fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renam
|
||||
legacy["format"] = json!("solidcraft/9");
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&legacy.to_string()).unwrap());
|
||||
// Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order,
|
||||
// and numbering the pieces of its faces as that build did (#3902); saved before joins kept
|
||||
// solids that only touch apart, it keeps them one body.
|
||||
// and numbering the pieces of its faces as that build did (#3902); saved before joins split
|
||||
// off pieces that touch only along an edge or at a point, it keeps them in
|
||||
// one body.
|
||||
let anchored = solidcraft_doc::Feature { shell_piece_order: true, kernel_piece_order: true, touching: solidcraft_doc::Touching::OneBody, ..anchored };
|
||||
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
|
||||
// Names saved before format 14 keep naming the face they named, marked so they are found as
|
||||
|
||||
@@ -25,7 +25,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.key("E")
|
||||
.contract(&EXTRUDE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); corner?: preserve_curvature|tangent|round (how a taper closes a sharp corner its offset opens: each wall continues as itself, a line's plane or an arc's cone, until it meets its neighbour; each continues straight along its end tangent; or an arc about the corner. Without it, each profile preserves curvature where every curve is a line or an arc and takes tangent where another curve has a corner, and the feature's warning says so; a corner rule given is kept, and refused where the kernel cannot build it); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile; a pick on a drawn profile that reference geometry, such as the projected outline of the face the sketch is on, cuts into regions takes the whole drawn profile); exact_regions?: bool (each index or point pick is exactly the region it names, as the dialogs send); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?: expr (Two Sides: a second distance the other way from the first side, which may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it); 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); touching?: separate|one_body (a join's solids that only touch, along an edge or at a point, or don't touch: separate bodies, the default, or one body); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); corner?: preserve_curvature|tangent|round (how a taper closes a sharp corner its offset opens: each wall continues as itself, a line's plane or an arc's cone, until it meets its neighbour; each continues straight along its end tangent; or an arc about the corner. Without it, each profile preserves curvature where every curve is a line or an arc and takes tangent where another curve has a corner, and the feature's warning says so; a corner rule given is kept, and refused where the kernel cannot build it); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile; a pick on a drawn profile that reference geometry, such as the projected outline of the face the sketch is on, cuts into regions takes the whole drawn profile); exact_regions?: bool (each index or point pick is exactly the region it names, as the dialogs send); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?: expr (Two Sides: a second distance the other way from the first side, which may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it); 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); touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -51,7 +51,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.key("F")
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: a patch repeating each blend's surface up to the second order, constant-radius exact fillets only, refused where it cannot be built); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
|
||||
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; touching?: separate|one_body (a join's solids that only touch, along an edge or at a point, or don't touch: separate bodies, the default, or one body); keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
|
||||
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
|
||||
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pattern_rect")
|
||||
@@ -611,7 +611,7 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
|
||||
/// `add_feature`; `world` is false when `kind` is already in the active component's frame.
|
||||
fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> Result<Value> {
|
||||
let name = str_(p, "name");
|
||||
// What a join makes of solids that only touch or don't touch: separate bodies unless asked.
|
||||
// What a join makes of pieces that share no face (`Touching`): separate unless asked.
|
||||
let touching = match p.get("touching") {
|
||||
None | Some(Value::Null) => Touching::Separate,
|
||||
Some(v) => v.as_str().and_then(Touching::parse).ok_or_else(|| EngineError::Other("`touching` must be separate or one_body".into()))?,
|
||||
@@ -780,7 +780,8 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
// A warning (a reference found again elsewhere, a reduced radius) is part of
|
||||
// the reply: a script or an agent sees it without asking the timeline (#616).
|
||||
let warning = r.as_ref().and_then(|r| r.warning.clone());
|
||||
// A join that kept solids apart says so, with the edit that makes them one body instead.
|
||||
// A join that split pieces touching along an edge or at a point into bodies of their own says
|
||||
// so, with the edit that makes them one body instead.
|
||||
let notice = r.as_ref().and_then(|r| r.state.kept_separate(id)).map(|kept| FeatureNotice {
|
||||
message: kept.notice().to_string(),
|
||||
contact: kept.id().to_string(),
|
||||
|
||||
@@ -69,11 +69,7 @@ fn a_piece_of_a_body_inside_another_is_still_found() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [0, 0, 0], "body_name": "Block"}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [5, 5, 5], "operation": "new", "body_name": "Pieces"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.box",
|
||||
json!({"length": 2, "width": 2, "height": 2, "corner": [30, 5, 5], "operation": "join", "targets": ["Pieces"], "touching": "one_body"}),
|
||||
);
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [30, 5, 5], "operation": "join", "targets": ["Pieces"]}));
|
||||
let st = s.world_state().unwrap();
|
||||
let pieces = st.bodies.iter().find(|b| b.name == "Pieces").map(|b| b.body.shell_count());
|
||||
assert_eq!(pieces, Some(2), "the two cubes are one body in two pieces");
|
||||
|
||||
@@ -103,9 +103,8 @@ fn a_cut_leaves_what_stands_beside_it() {
|
||||
}
|
||||
|
||||
/// A join adds only its sketch: a block against a wall narrower than the block adds nothing
|
||||
/// beside the wall, a block 0.1 mm from a wall leaves the gap (and, meeting nothing, stays a body
|
||||
/// of its own), and a pad flush with a block's side does not reach a neighbour 0.8 mm from that
|
||||
/// side.
|
||||
/// beside the wall, a block 0.1 mm from a wall leaves the gap, and a pad flush with a block's
|
||||
/// side does not reach a neighbour 0.8 mm from that side.
|
||||
#[test]
|
||||
fn a_join_adds_only_its_sketch() {
|
||||
let wall = || {
|
||||
@@ -120,11 +119,8 @@ fn a_join_adds_only_its_sketch() {
|
||||
|
||||
let mut s = wall();
|
||||
rectangle(&mut s, at_height(0.0), [2.1, 10.0], [20.0, 50.0], 30.0, "join", Value::Null);
|
||||
let names = bodies(&s);
|
||||
assert_eq!(names.len(), 2, "{names:?}");
|
||||
let total: f64 = names.iter().map(|b| volume(&s, b)).sum();
|
||||
assert!(same(total, 2.0 * 40.0 * 30.0 + 17.9 * 40.0 * 30.0), "{total}");
|
||||
assert!(names.iter().all(|b| !has(&s, b, [2.05, 30.0, 15.0])), "the 0.1 mm gap is filled");
|
||||
assert!(same(volume(&s, "Wall"), 2.0 * 40.0 * 30.0 + 17.9 * 40.0 * 30.0), "{}", volume(&s, "Wall"));
|
||||
assert!(!has(&s, "Wall", [2.05, 30.0, 15.0]), "the 0.1 mm gap is filled");
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 300, "width": 200, "height": 100, "body_name": "A"}));
|
||||
|
||||
@@ -38,7 +38,7 @@ pub struct BoxParams {
|
||||
/// The bodies a join, cut or intersect works on. Default: the bodies the box meets.
|
||||
#[serde(default)]
|
||||
pub targets: Option<StringList>,
|
||||
/// What a join makes of solids that only touch or don't touch. Default: separate.
|
||||
/// What a join makes of pieces that share no face. Default: separate.
|
||||
#[serde(default)]
|
||||
pub touching: Option<TouchingParam>,
|
||||
/// The bodies taking part (the same as `targets` for a primitive).
|
||||
@@ -138,7 +138,7 @@ pub struct ExtrudeParams {
|
||||
/// that body.
|
||||
#[serde(default)]
|
||||
pub targets: Option<StringList>,
|
||||
/// What a join makes of solids that only touch or don't touch. Default: separate.
|
||||
/// What a join makes of pieces that share no face. Default: separate.
|
||||
#[serde(default)]
|
||||
pub touching: Option<TouchingParam>,
|
||||
/// The bodies taking part; overrides the default scope.
|
||||
@@ -177,7 +177,8 @@ pub struct ExtrudeParams {
|
||||
pub struct FeatureNotice {
|
||||
/// The text to show.
|
||||
pub message: String,
|
||||
/// How the solids kept apart met: `along_edge`, `at_point` or `apart`.
|
||||
/// How the bodies split off touched the others: `along_edge`, `at_point` or `touching` (along
|
||||
/// a line or at a point their boundaries don't hold).
|
||||
pub contact: String,
|
||||
/// The command that changes the feature as the notice offers.
|
||||
pub action: NoticeAction,
|
||||
@@ -212,9 +213,10 @@ pub struct FeatureResult {
|
||||
/// A warning from the evaluation (a Boolean that needed a retry, a reference found again
|
||||
/// elsewhere), or `null`.
|
||||
pub warning: Option<String>,
|
||||
/// A join that kept solids as separate bodies because they only touch along an edge or at a
|
||||
/// point, or don't touch (its `touching: separate`): what to tell the user, and the edit that
|
||||
/// makes them one body instead. Left out otherwise.
|
||||
/// A join that split pieces into bodies of their own because they touch the others only along
|
||||
/// an edge or at a point (its `touching: separate`): what to tell the user, and the edit that
|
||||
/// makes them one body instead. Left out otherwise, also when the join kept pieces that touch
|
||||
/// nothing in one body.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notice: Option<FeatureNotice>,
|
||||
/// How long the feature took to evaluate, in milliseconds.
|
||||
|
||||
@@ -379,7 +379,7 @@ impl JsonSchema for OperationParam {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a join makes of solids that only touch or don't touch, as `Touching::parse` reads it.
|
||||
/// What a join makes of pieces that share no face, as `Touching::parse` reads it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TouchingParam(pub Touching);
|
||||
|
||||
@@ -402,7 +402,7 @@ impl JsonSchema for TouchingParam {
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
enum_schema(
|
||||
&["separate", "one_body"],
|
||||
"What a join makes of solids that only touch, along an edge or at a point, or don't touch: separate bodies (separate, the default) or one body of several pieces (one_body).",
|
||||
"What a join makes of pieces that share no face: separate (the default) makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-10
@@ -203,21 +203,13 @@ fn join_merges_into_the_body_it_meets() {
|
||||
assert!(rel(volume(&mut s), 2064.0) < 1e-6);
|
||||
}
|
||||
|
||||
/// A join that meets no body keeps what it made as a body of its own beside the first body
|
||||
/// (`touching: separate`, the default), and says so with the edit that makes them one; with
|
||||
/// `touching: one_body` it joins the first body as a separate lump.
|
||||
/// A join that meets no body still joins the first body, as a separate lump, and says nothing:
|
||||
/// pieces that touch nothing stay one body.
|
||||
#[test]
|
||||
fn join_meeting_nothing_keeps_the_first_body() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let r = run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [40, 0, 0], "operation": "join"}));
|
||||
assert_eq!(r["notice"]["contact"], "apart", "{r}");
|
||||
assert_eq!(r["notice"]["action"]["params"]["set"], json!({"touching": "one_body"}), "{r}");
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
assert!(rel(volume(&mut s), 1064.0) < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let r =
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [40, 0, 0], "operation": "join", "touching": "one_body"}));
|
||||
assert!(r.get("notice").is_none(), "{r}");
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
@@ -225,6 +217,19 @@ fn join_meeting_nothing_keeps_the_first_body() {
|
||||
assert!(rel(volume(&mut s), 1064.0) < 1e-6);
|
||||
}
|
||||
|
||||
/// A join of a box touching the first only along an edge keeps it a body of its own and says
|
||||
/// so, with the edit that makes them one body.
|
||||
#[test]
|
||||
fn join_touching_along_an_edge_keeps_a_separate_body() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
let r = run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [10, 10, 0], "operation": "join"}));
|
||||
assert_eq!(r["notice"]["contact"], "along_edge", "{r}");
|
||||
assert_eq!(r["notice"]["message"], "These bodies only touch along an edge, so they were kept as separate bodies.", "{r}");
|
||||
assert_eq!(r["notice"]["action"]["params"]["set"], json!({"touching": "one_body"}), "{r}");
|
||||
assert_eq!(s.model.state().bodies.len(), 2);
|
||||
}
|
||||
|
||||
/// A 1 mm floor extruded into a through hole, from the sketch the plate was extruded from: the
|
||||
/// disc's wall lies on the hole's and its foot in the plate's bottom face. A wedge extruded
|
||||
/// sideways on top keeps the plate from being a prism along Z (the 2D route would take it
|
||||
|
||||
@@ -141,8 +141,7 @@ fn fillet_that_would_open_the_surface_is_refused() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell of a body in two pieces (a cylinder joined clear of a ring, kept one body with
|
||||
/// `touching: one_body`): each piece is shelled.
|
||||
/// Shell of a body in two pieces (a cylinder joined clear of a ring): each piece is shelled.
|
||||
#[test]
|
||||
fn shell_of_a_body_in_two_pieces() {
|
||||
script(json!([
|
||||
@@ -150,7 +149,7 @@ fn shell_of_a_body_in_two_pieces() {
|
||||
{"command": "sketch.rectangle.two_point", "params": {"p0": [2.5, 0], "p1": [22.5, 17.5]}},
|
||||
{"command": "sketch.finish", "params": {}},
|
||||
{"command": "solid.revolve", "params": {"angle": "360 deg", "axis": "Z"}},
|
||||
{"command": "solid.cylinder", "params": {"axis": [0.0, 1.0, 0.0], "base": [-22.5, 22.5, 0.0], "height": 30.0, "operation": "join", "radius": 10.0, "touching": "one_body"}},
|
||||
{"command": "solid.cylinder", "params": {"axis": [0.0, 1.0, 0.0], "base": [-22.5, 22.5, 0.0], "height": 30.0, "operation": "join", "radius": 10.0}},
|
||||
{"command": "solid.shell", "params": {"faces": [[-22.5, 22.5, 0.0]], "thickness": 2.0}}
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! of bodies apart from each other join into one by [`Brep::compound`].
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{Brep, EdgeUseRef, Invalid, Loop, OrientedFace, Region, Result, Shell, require};
|
||||
use super::{Brep, Edge, EdgeUseRef, Invalid, Loop, OrientedFace, Region, Result, Shell, require};
|
||||
|
||||
/// A record cut from a larger one, and the source index of each of its items.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -70,8 +70,10 @@ impl Brep {
|
||||
/// How the material regions meet, read from the record: each pair of regions that shares a
|
||||
/// face, an edge or a vertex, with the most they share. `regions` index
|
||||
/// [`Brep::material_regions`] (so a body's lumps); pairs not listed are apart. Regions share
|
||||
/// an edge where it is used by faces of both (more than two faces), a vertex where edges or
|
||||
/// vertex loops of both end at it. Sorted by pair.
|
||||
/// an edge where it is used by faces of both (more than two faces) or each has a copy of it
|
||||
/// (an edge of the same curve and interval between vertices at the same points), a vertex
|
||||
/// where edges or vertex loops of both end at it or at a copy of it (a vertex at the same
|
||||
/// point). Sorted by pair.
|
||||
pub fn region_contacts(&self) -> Result<Vec<RegionContact>> {
|
||||
let material = self.material_regions();
|
||||
// The material regions (positions in `material`) each face, edge and vertex belongs to.
|
||||
@@ -126,8 +128,45 @@ impl Brep {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A producer that keeps each solid's boundary closed on its own gives solids that touch a
|
||||
// copy each of the edge or vertex they share (SMLib's export splits them so): the same
|
||||
// curve over the same interval between vertices at the same points, a vertex at the same
|
||||
// point. Copies are one edge or vertex here; their owners are listed together.
|
||||
let point = |v: u32| self.vertices.get(v as usize).map(|x| x.point.map(f64::to_bits)).ok_or(Invalid("vertex reference"));
|
||||
let mut at_point = std::collections::BTreeMap::<[u64; 3], Vec<usize>>::new();
|
||||
for (v, owners) in on_vertex.iter().enumerate().filter(|(_, o)| !o.is_empty()) {
|
||||
at_point.entry(point(u32::try_from(v).map_err(|_| Invalid("vertex index"))?)?).or_default().extend(owners);
|
||||
}
|
||||
let mut edge_copies = std::collections::BTreeMap::<([[u64; 3]; 2], [u64; 2]), Vec<(&Edge, Vec<usize>)>>::new();
|
||||
for (e, owners) in on_edge.iter().enumerate().filter(|(_, o)| !o.is_empty()) {
|
||||
let edge = self.edges.get(e).ok_or(Invalid("edge reference"))?;
|
||||
let key = ([point(edge.vertices[0])?, point(edge.vertices[1])?], edge.interval.map(f64::to_bits));
|
||||
let same = edge_copies.entry(key).or_default();
|
||||
match same.iter_mut().find(|(other, _)| other.curve == edge.curve && other.nurbs == edge.nurbs) {
|
||||
Some((_, all)) => all.extend(owners),
|
||||
None => same.push((edge, owners.clone())),
|
||||
}
|
||||
}
|
||||
let copied = |lists: Vec<Vec<usize>>| -> Vec<Vec<usize>> {
|
||||
lists
|
||||
.into_iter()
|
||||
.map(|mut l| {
|
||||
l.sort_unstable();
|
||||
l.dedup();
|
||||
l
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let edge_copies = copied(edge_copies.into_values().flatten().map(|(_, owners)| owners).collect());
|
||||
let vertex_copies = copied(at_point.into_values().collect());
|
||||
let mut pairs = std::collections::BTreeMap::<[usize; 2], Contact>::new();
|
||||
for (owners, contact) in [(&on_face, Contact::Face), (&on_edge, Contact::Edge), (&on_vertex, Contact::Vertex)] {
|
||||
for (owners, contact) in [
|
||||
(&on_face, Contact::Face),
|
||||
(&on_edge, Contact::Edge),
|
||||
(&edge_copies, Contact::Edge),
|
||||
(&on_vertex, Contact::Vertex),
|
||||
(&vertex_copies, Contact::Vertex),
|
||||
] {
|
||||
for list in owners.iter().filter(|l| l.len() > 1) {
|
||||
for (i, &a) in list.iter().enumerate() {
|
||||
for &b in list.iter().skip(i + 1) {
|
||||
|
||||
@@ -486,13 +486,19 @@ mod smlib {
|
||||
|
||||
/// Cubes sharing an edge, and cubes sharing a vertex: SMLib's union touches itself there;
|
||||
/// it comes back as two closed shells, the shared edge and vertices split into one copy per
|
||||
/// cube, each copy with its edge's history (verified against the geometry).
|
||||
/// cube, each copy with its edge's history (verified against the geometry). The copies say
|
||||
/// how the two meet.
|
||||
#[test]
|
||||
fn cubes_touching_along_an_edge_or_at_a_vertex_unite_into_two_solids() {
|
||||
use solidcraft_geom::brep::part::{Contact, RegionContact};
|
||||
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
for (name, b) in [("edge", boxed(10.0, 10.0, 0.0, 20.0, 20.0, 10.0)), ("vertex", boxed(10.0, 10.0, 10.0, 20.0, 20.0, 20.0))] {
|
||||
for (name, b, contact) in [
|
||||
("edge", boxed(10.0, 10.0, 0.0, 20.0, 20.0, 10.0), Contact::Edge),
|
||||
("vertex", boxed(10.0, 10.0, 10.0, 20.0, 20.0, 20.0), Contact::Vertex),
|
||||
] {
|
||||
let r = through_smlib(&a, &b, BoolOp::Union);
|
||||
assert_eq!(r.shell_count(), 2, "{name}");
|
||||
assert_eq!(r.region_contacts().unwrap(), vec![RegionContact { regions: [0, 1], contact }], "{name}");
|
||||
assert_eq!((r.face_count(), r.edge_count()), (12, 24), "{name}");
|
||||
let v = crate::measure(&r).unwrap().volume;
|
||||
assert!((v - 2000.0).abs() < 1e-6 * 2000.0, "{name}: {v}");
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
],
|
||||
"what": "a join of a ring and an arm whose R5 convex tip stops 0.01 mm short of the ring's outer wall: nothing overlaps, so nothing fuses",
|
||||
"source": "analytic",
|
||||
"method": "ring pi*(30^2-24^2)*6 and arm 6*(350+12.5*pi) are 0.01 mm apart (arm tip at x=30.01): the join has nothing to fuse, so V is the sum of the two. Solids a join leaves apart (or touching only along an edge or at a point) stay separate bodies (owner decision 2026-10-22: a join's `touching` is `separate` by default; crates/doc eval.rs, separate_touching): the arm comes out of the combine as a body of its own beside Ring, the tool body consumed as before: two bodies of one shell each",
|
||||
"body_count": 2,
|
||||
"volume_mm3": 8442.875567597792
|
||||
"method": "ring pi*(30^2-24^2)*6 and arm 6*(350+12.5*pi) are 0.01 mm apart (arm tip at x=30.01): the join has nothing to fuse, so V is the sum of the two and the arm stays a separate shell. A combine's join of a tool that meets no target keeps it in the target as a separate lump (crates/doc eval.rs, Operation::Join; engine test join_meeting_nothing_keeps_the_first_body), and the tool body is consumed: one body (Ring) of two shells",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 8442.875567597792,
|
||||
"shells": 2
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
],
|
||||
"what": "a circular pattern (count 5) of a spoke thinner than the ring it joins (faces 0.5 mm inside the rim and hub faces)",
|
||||
"source": "analytic",
|
||||
"method": "ring pi*(66^2-63^2)*3 + hub pi*12^2*3 + 5 spokes. A spoke is the box 53x2x2.5 (x 11..64, z +-1.25, y 0.5..2.5; the issue starts it at x=12, tangent to the hub, which joins the hub by a line only, so it starts at 11 here to overlap it). It overlaps the ring by 5.021 and the hub by 4.891 (width * Int over z in -1.25..1.25 of 64 - sqrt(63^2-z^2) and of sqrt(12^2-z^2) - 11). V = ring + hub + 5*(265 - overlaps), one body. The hub, apart from the ring when it is joined, is joined with touching: one_body, so the ring and hub stay one body for the spokes to join (a join keeps solids that share no volume as separate bodies by default, owner decision 2026-10-22).",
|
||||
"method": "ring pi*(66^2-63^2)*3 + hub pi*12^2*3 + 5 spokes. A spoke is the box 53x2x2.5 (x 11..64, z +-1.25, y 0.5..2.5; the issue starts it at x=12, tangent to the hub, which joins the hub by a line only, so it starts at 11 here to overlap it). It overlaps the ring by 5.021 and the hub by 4.891 (width * Int over z in -1.25..1.25 of 64 - sqrt(63^2-z^2) and of sqrt(12^2-z^2) - 11). V = ring + hub + 5*(265 - overlaps), one body",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 6279.997172312351
|
||||
}
|
||||
|
||||
@@ -45,8 +45,7 @@
|
||||
"operation": "join",
|
||||
"targets": [
|
||||
"rim"
|
||||
],
|
||||
"touching": "one_body"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
],
|
||||
"what": "one combine of 64 separate pegs that do not touch: 36 s on truck, growing 4-9x per doubling; must finish in seconds",
|
||||
"source": "analytic",
|
||||
"method": "64 non-touching 48-gon pegs of circumradius 2, 1 mm tall, 6 mm apart: each area 0.5*48*2^2*sin(2pi/48) = 12.5305, V = 64 * 12.5305 * 1. The join keeps them in one body (several lumps). A hang is a failure: time should grow with the number of bodies. One body with 64 shells. The combine joins with touching: one_body, which keeps the pegs one body (by default a join keeps solids that share no volume as separate bodies, owner decision 2026-10-22).",
|
||||
"method": "64 non-touching 48-gon pegs of circumradius 2, 1 mm tall, 6 mm apart: each area 0.5*48*2^2*sin(2pi/48) = 12.5305, V = 64 * 12.5305 * 1. The join keeps them in one body (several lumps). A hang is a failure: time should grow with the number of bodies. One body with 64 shells",
|
||||
"body_count": 1,
|
||||
"shells": 64,
|
||||
"volume_mm3": 801.9529
|
||||
|
||||
@@ -12889,8 +12889,7 @@
|
||||
"Peg (63)",
|
||||
"Peg (64)"
|
||||
],
|
||||
"operation": "join",
|
||||
"touching": "one_body"
|
||||
"operation": "join"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
],
|
||||
"what": "intersect of a two-lump target (two spheres joined while apart) with a box holding the bottom half of each",
|
||||
"source": "analytic",
|
||||
"method": "box x -10..40, y -10..10, z -10..0 holds the z<=0 half of each r=4 sphere (centres (0,0,0), (30,0,0)): V = 2 * (2/3) pi 4^3 = 268.083. The body must not be removed. One body with two shells. The second sphere is joined with touching: one_body, which makes the two-lump target (a join keeps solids that share no volume as separate bodies by default, owner decision 2026-10-22).",
|
||||
"method": "box x -10..40, y -10..10, z -10..0 holds the z<=0 half of each r=4 sphere (centres (0,0,0), (30,0,0)): V = 2 * (2/3) pi 4^3 = 268.083. The body must not be removed. One body with two shells",
|
||||
"body_count": 1,
|
||||
"shells": 2,
|
||||
"volume_mm3": 268.0826
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
"operation": "join",
|
||||
"targets": [
|
||||
"Balls"
|
||||
],
|
||||
"touching": "one_body"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user