forked from 1b-agent/fieldcraft-upstream
geometry: cuts stay on their plane; risky cuts are named (#384)
A partition intersects the body with a box on each side of the plane as large as the body itself. SolidCraft's split_by_plane uses boxes several times larger, and its boolean then put the cut up to 1.8 um off the plane on the enclosure's air, so the cut faces were not recognized and the cells did not close. A plane along a face of a body it cuts, or a cell whose boundary does not tessellate into one a mesher takes (a cut through a hole's axis), is a warning naming the partition, the body and where. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
09323ddb45
commit
c7d0a82079
4 files changed
+139
-12
No files matched your search
@@ -41,6 +41,7 @@ is committed and listed here with its exports.
|
||||
| crates/ui-egui/thumbnails/heated-bracket.png | FieldCraft contributors | Original: the heated-bracket sample as docs/media/examples/heated-bracket.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the heated-bracket sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/thumbnails/rubber-seal.png | FieldCraft contributors | Original: the rubber-seal sample as docs/media/examples/rubber-seal.png shows it, rendered by FieldCraft (`ui.render`, light theme), cropped to the model and scaled to 320 px wide (ImageMagick, 128-colour palette), the rubber-seal sample's thumbnail on the Start card | MIT OR Apache-2.0 |
|
||||
| crates/decks/tests/golden/nastran/*.bdf | FieldCraft contributors | Written by our Nastran writer from our own test models (crates/decks/tests/nastran.rs) | MIT OR Apache-2.0 |
|
||||
| crates/engine/tests/designs/pi5-enclosure.json | FieldCraft contributors | Original SolidCraft recipe (solid.* commands) of an enclosure for a single-board computer, for thermal analysis: base, lid, heat sink, board, chips, connectors and the air inside | MIT OR Apache-2.0 |
|
||||
| crates/geometry/tests/designs/hollow-box.solidcraft, crates/geometry/tests/designs/hollow-box-post.solidcraft, crates/geometry/tests/designs/open-tray-post.solidcraft | FieldCraft contributors | Built with `solidcraft-cli run` from the recipes beside them (crates/geometry/tests/designs/*.json) | MIT OR Apache-2.0 |
|
||||
| assets/icons/assembly.instance.svg, assets/icons/assembly.instance.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/assembly.instance.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
| assets/icons/deck.read.svg, assets/icons/deck.read.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/deck.read.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -94,7 +94,7 @@ pub const DATUM_DELETE: CommandSpec = CommandSpec {
|
||||
pub const PARTITION: CommandSpec = CommandSpec {
|
||||
id: "geometry.partition",
|
||||
label: "Partition",
|
||||
params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition:<name>:a` on the cells on the negative side, `partition:<name>:b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`<face>#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named; a body it cuts loses its mesh (`replaced`, as a mesher reports it; remesh the cells); a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#,
|
||||
params: r#"{"name": "Half", "datum": "Mid", "bodies"?: ["Body1"] (default: every body the plane divides)} — cuts bodies of the open design into cells by a datum plane, in memory (the design file is not changed); the model keeps the partition and applies it again whenever the design is opened, after the partitions before it. A cut body `B` becomes the cells `B/a` (on the plane's negative side) and `B/b` (several pieces on a side: `B/a1`, `B/a2`, …). The two faces of the cut are paired by name: `partition:<name>:a` on the cells on the negative side, `partition:<name>:b` on the positive side (`…#0`, `…#1` where a cell has several); every other face keeps the persistent name of the face it is a piece of (`<face>#k` for several pieces on one cell), so sets by face name still select it. Reports the cells made and the cut faces named, and `warnings` when the plane lies on a face of a body it cuts or a cell's boundary does not tessellate into one a mesher takes (a cut through existing edges or vertices, such as a hole's axis), naming the plane, the cell and where; a body it cuts loses its mesh (`replaced`, as a mesher reports it; remesh the cells); a plane that divides no body, a listed body it does not divide, or a body the kernel fails to cut (its message) is an error"#,
|
||||
form: Some(super::forms::PARTITION),
|
||||
kind: CommandKind::Model,
|
||||
requires: &[Requirement::Design, Requirement::DatumPlane],
|
||||
@@ -506,21 +506,44 @@ fn partition(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let cells: Vec<String> = design.bodies.iter().map(|b| b.name.clone()).filter(|b| split.iter().any(|x| b.starts_with(&format!("{x}/")))).collect();
|
||||
let (faces, bodies): (usize, Vec<String>) = (report.map_or(0, |r| r.faces), design.bodies.iter().map(|b| b.name.clone()).collect());
|
||||
let mut warnings: Vec<String> = report.map(|r| r.warnings.clone()).unwrap_or_default();
|
||||
for cell in &cells {
|
||||
if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape).err()) {
|
||||
warnings.push(format!(
|
||||
"partition `{name}`: cell `{cell}` has a boundary no mesher takes ({why}): the plane passes within the kernel's tolerance of a face, edge or vertex it cut (along a face, through the axis of a hole); move the plane off it (by a fraction of the element size)"
|
||||
));
|
||||
}
|
||||
}
|
||||
// The cut shows as edges on the cells, so the plane steps out of the way (view state, #295):
|
||||
// selecting it in the browser or its eye shows it again.
|
||||
if s.view.hidden.len() < MAX_HIDDEN {
|
||||
s.view.hidden.insert(Item { kind: ItemKind::Datum, name: datum });
|
||||
}
|
||||
let reply = json!({
|
||||
let mut reply = json!({
|
||||
"name": name,
|
||||
"split": split,
|
||||
"cells": cells,
|
||||
"cut_faces": faces,
|
||||
"bodies": bodies,
|
||||
});
|
||||
if !warnings.is_empty()
|
||||
&& let Some(o) = reply.as_object_mut()
|
||||
{
|
||||
o.insert("warnings".into(), json!(warnings));
|
||||
}
|
||||
Ok(cleared.report_if_any(s, reply))
|
||||
}
|
||||
|
||||
/// Whether a cell's boundary, tessellated as the meshers take it by default, bounds a volume mesh:
|
||||
/// the defects (open edges, edges on three triangles) when it does not.
|
||||
fn unmeshable(shape: &fieldcraft_geometry::Shape) -> Result<()> {
|
||||
let input = super::hex::shape_input(shape, None)?;
|
||||
let options = fieldcraft_mesh::surface::SurfaceOptions { weld_distance: input.mesh.weld_distance, ..Default::default() };
|
||||
fieldcraft_mesh::surface::BoundarySurface::new(&input.mesh.positions, &input.mesh.triangles, &input.mesh.tri_face, &options)
|
||||
.map(|_| ())
|
||||
.map_err(|e| EngineError::Params(e.to_string()))
|
||||
}
|
||||
|
||||
fn datum_suppress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = super::name(p)?;
|
||||
let suppressed = super::opt(p, "suppressed")?.unwrap_or(true);
|
||||
@@ -913,4 +936,50 @@ pub(crate) mod tests {
|
||||
assert!(s.execute("mesh.clear", &json!({"bodies": ["Body1"]})).is_err(), "nothing to clear");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The Raspberry Pi 5 enclosure analysis design (base, lid, heat sink, board, chips and the
|
||||
/// air inside), its SolidCraft recipe.
|
||||
pub(crate) fn pi5() -> (crate::tests::TempDir, Session) {
|
||||
scripted(serde_json::from_str(include_str!("../../tests/designs/pi5-enclosure.json")).unwrap())
|
||||
}
|
||||
|
||||
/// #384: cuts stay on their plane (the kernel's own split put them 1.8 µm off it on the air
|
||||
/// of the enclosure, so the cut faces were not even recognized), and a plane along an
|
||||
/// existing face or through a hole's axis is a warning naming the plane, the body and where.
|
||||
#[test]
|
||||
fn cuts_stay_on_the_plane_and_risky_ones_are_named() {
|
||||
let (_dir, mut s) = pi5();
|
||||
let plane = |s: &mut Session, name: &str, x: f64| {
|
||||
s.execute("geometry.datum", &json!({"name": name, "by": {"type": "offset", "plane": "yz", "offset": x}})).unwrap();
|
||||
s.execute("geometry.partition", &json!({"name": format!("P{name}"), "datum": name, "bodies": ["Air"]})).unwrap()
|
||||
};
|
||||
// Beside the board's mounting holes: clean cells, cut faces on the plane.
|
||||
let r = plane(&mut s, "X", 16.5);
|
||||
assert!(r.get("warnings").is_none(), "{r}");
|
||||
let design = s.design.clone().unwrap();
|
||||
for cell in ["Air/a", "Air/b"] {
|
||||
let shape = design.body(cell).unwrap();
|
||||
let cut: Vec<f64> = shape
|
||||
.faces()
|
||||
.iter()
|
||||
.zip(shape.face_surfaces().unwrap())
|
||||
.filter(|(f, _)| f.name.starts_with("partition:PX:"))
|
||||
.filter_map(|(_, srf)| match srf {
|
||||
Some(fieldcraft_geometry::FaceSurface::Plane { point, .. }) => Some(point[0]),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!cut.is_empty() && cut.iter().all(|x| (x - 16.5).abs() < 1e-9), "{cell}: {cut:?}");
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Along the side of the heat sink's pedestal: named.
|
||||
let r = plane(&mut s, "Side", 34.0);
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("partition `PSide`: the plane lies on face `F45:-x` of `Air`"), "{r}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// Through a standoff's hole: the cell whose boundary breaks at the hole's edge is named.
|
||||
let r = plane(&mut s, "Axis", 74.5);
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("partition `PAxis`: cell `Air/") && warnings.contains("boundary no mesher takes"), "{r}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[{"command": "solid.box", "params": {"length": 112, "width": 82, "height": 36, "corner": [0, 0, 0], "body_name": "Base"}}, {"command": "solid.fillet", "params": {"edges": [[0, 0, 18.0], [112, 0, 18.0], [0, 82, 18.0], [112, 82, 18.0]], "radius": 5}}, {"command": "solid.shell", "params": {"faces": [[56.0, 41.0, 36]], "thickness": 3, "direction": "inside", "body": "Base"}}, {"command": "solid.cylinder", "params": {"diameter": 13, "height": 36, "base": [-1, -1, 0], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 13, "height": 36, "base": [113, -1, 0], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 13, "height": 36, "base": [-1, 83, 0], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 13, "height": 36, "base": [113, 83, 0], "operation": "join", "targets": ["Base"]}}, {"command": "solid.hole", "params": {"position": [-1, -1, 36], "diameter": 3.3, "depth": 20}}, {"command": "solid.hole", "params": {"position": [113, -1, 36], "diameter": 3.3, "depth": 20}}, {"command": "solid.hole", "params": {"position": [-1, 83, 36], "diameter": 3.3, "depth": 20}}, {"command": "solid.hole", "params": {"position": [113, 83, 36], "diameter": 3.3, "depth": 20}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 6, "base": [16.5, 16.5, 3], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 6, "base": [74.5, 16.5, 3], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 6, "base": [16.5, 65.5, 3], "operation": "join", "targets": ["Base"]}}, {"command": "solid.cylinder", "params": {"diameter": 6, "height": 6, "base": [74.5, 65.5, 3], "operation": "join", "targets": ["Base"]}}, {"command": "sketch.create", "params": {"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Nut1"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [34, 17], "radius": 11, "sides": 6, "angle": 0}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Nut1", "profiles": "all", "distance": 6, "operation": "new", "body_name": "Gland1"}}, {"command": "solid.cylinder", "params": {"diameter": 17, "height": 8, "base": [34, -6, 17], "axis": [0, -1, 0], "operation": "join", "targets": ["Gland1"]}}, {"command": "solid.cylinder", "params": {"diameter": 14, "height": 6, "base": [34, -14, 17], "axis": [0, -1, 0], "operation": "join", "targets": ["Gland1"]}}, {"command": "solid.fillet", "params": {"edges": [[34, -20, 24]], "radius": 2.5, "body": "Gland1"}}, {"command": "solid.cylinder", "params": {"diameter": 6.5, "height": 30, "base": [34, -20, 17], "axis": [0, -1, 0], "operation": "new", "body_name": "Cable1"}}, {"command": "sketch.create", "params": {"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Nut2"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [66, 17], "radius": 11, "sides": 6, "angle": 0}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Nut2", "profiles": "all", "distance": 6, "operation": "new", "body_name": "Gland2"}}, {"command": "solid.cylinder", "params": {"diameter": 17, "height": 8, "base": [66, -6, 17], "axis": [0, -1, 0], "operation": "join", "targets": ["Gland2"]}}, {"command": "solid.cylinder", "params": {"diameter": 14, "height": 6, "base": [66, -14, 17], "axis": [0, -1, 0], "operation": "join", "targets": ["Gland2"]}}, {"command": "solid.fillet", "params": {"edges": [[66, -20, 24]], "radius": 2.5, "body": "Gland2"}}, {"command": "solid.cylinder", "params": {"diameter": 6.5, "height": 30, "base": [66, -20, 17], "axis": [0, -1, 0], "operation": "new", "body_name": "Cable2"}}, {"command": "sketch.create", "params": {"plane": {"origin": [112, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "Nut3"}}, {"command": "sketch.polygon.inscribed", "params": {"center": [41.0, 18], "radius": 14, "sides": 6, "angle": 0}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"sketch": "Nut3", "profiles": "all", "distance": 5, "operation": "new", "body_name": "Gland3"}}, {"command": "solid.cylinder", "params": {"diameter": 24, "height": 17, "base": [117, 41.0, 18], "axis": [1, 0, 0], "operation": "join", "targets": ["Gland3"]}}, {"command": "solid.hole", "params": {"position": [134, 41.0, 18], "diameter": 14, "depth": 15}}, {"command": "solid.fillet", "params": {"edges": [[134, 41.0, 30]], "radius": 2, "body": "Gland3"}}, {"command": "solid.box", "params": {"length": 110.8, "width": 80.8, "height": 1.6, "corner": [0.6, 0.6, 36], "operation": "new", "body_name": "ORing"}}, {"command": "solid.box", "params": {"length": 107.2, "width": 77.2, "height": 1.6, "corner": [2.4, 2.4, 36], "operation": "cut", "targets": ["ORing"]}}, {"command": "solid.box", "params": {"length": 128, "widthLine truncated
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Partitions: a design's bodies cut into cells by planes, for meshing cell by cell.
|
||||
//!
|
||||
//! A partition cuts bodies of the open design by a plane with SolidCraft's kernel
|
||||
//! (`split_by_plane`, the operation behind SolidCraft's split body), in memory: the design file is
|
||||
//! never changed. The partitions live in the FieldCraft model and are applied again, in order,
|
||||
//! A partition cuts bodies of the open design by a plane with SolidCraft's kernel (the body's
|
||||
//! intersections with a box on each side of the plane, as SolidCraft's split body does), in
|
||||
//! memory: the design file is never changed. The partitions live in the FieldCraft model and are applied again, in order,
|
||||
//! whenever the design is opened, so an edit of the design followed by a re-open replays them.
|
||||
//!
|
||||
//! Names are FieldCraft's own, so they do not depend on the order of the design's timeline:
|
||||
@@ -52,6 +52,8 @@ pub struct CutReport {
|
||||
pub split: Vec<String>,
|
||||
/// How many cut faces it named.
|
||||
pub faces: usize,
|
||||
/// What may make its cells hard to mesh: the plane lying on a face of a body it cut.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn vec3(p: [f64; 3]) -> Vec3 {
|
||||
@@ -107,7 +109,7 @@ impl Design {
|
||||
errors.push(FeatureError { feature: feature.clone(), message: format!("no body `{b}` to cut"), bodies: vec![b.clone()] });
|
||||
}
|
||||
}
|
||||
let mut report = CutReport { name: cut.name.clone(), split: Vec::new(), faces: 0 };
|
||||
let mut report = CutReport { name: cut.name.clone(), split: Vec::new(), faces: 0, warnings: Vec::new() };
|
||||
let mut next = Vec::with_capacity(bodies.len() + 1);
|
||||
for body in bodies {
|
||||
let listed = cut.bodies.iter().any(|b| b == &body.name);
|
||||
@@ -116,9 +118,10 @@ impl Design {
|
||||
continue;
|
||||
}
|
||||
match split(&body, plane, *n, cut)? {
|
||||
Split::Cells(cells, faces) => {
|
||||
Split::Cells(cells, faces, warnings) => {
|
||||
report.split.push(body.name.clone());
|
||||
report.faces += faces;
|
||||
report.warnings.extend(warnings);
|
||||
next.extend(cells);
|
||||
}
|
||||
Split::Misses => {
|
||||
@@ -153,10 +156,63 @@ impl Design {
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts of `body` on either side of `plane` (only the non-empty ones): its intersections
|
||||
/// with two boxes, one on each side, whose faces in the plane are squares as wide as the body's
|
||||
/// bounding-box diagonal and that reach that far from it. SolidCraft's `split_by_plane` uses boxes
|
||||
/// several times the body's size, and its boolean then puts the cut up to a few micrometres off
|
||||
/// the plane (1.8 µm on a 140 mm body), off the faces it should meet (#384); boxes of the body's own
|
||||
/// size keep the cut on the plane.
|
||||
fn halves(body: &Shape, plane: &Plane) -> solidcraft_kernel::Result<Vec<solidcraft_kernel::Body>> {
|
||||
let bbox = solidcraft_kernel::measure(&body.body)?.bbox;
|
||||
let reach = bbox.diagonal().max(LINEAR_REACH);
|
||||
let c = plane.to_local(bbox.center());
|
||||
let square = solidcraft_geom::Region2 {
|
||||
outer: solidcraft_geom::Loop2::polygon(&[
|
||||
solidcraft_geom::Vec2::new(c.x - reach, c.y - reach),
|
||||
solidcraft_geom::Vec2::new(c.x + reach, c.y - reach),
|
||||
solidcraft_geom::Vec2::new(c.x + reach, c.y + reach),
|
||||
solidcraft_geom::Vec2::new(c.x - reach, c.y + reach),
|
||||
]),
|
||||
holes: vec![],
|
||||
};
|
||||
let mut out = Vec::with_capacity(2);
|
||||
for (lo, hi) in [(0.0, reach), (-reach, 0.0)] {
|
||||
let Some(side) = solidcraft_kernel::extrude(plane, std::slice::from_ref(&square), lo, hi)?.pop() else { continue };
|
||||
out.extend(solidcraft_kernel::boolean(&body.body, &side, solidcraft_kernel::BoolOp::Intersect)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Planar faces of `body` the cut's plane lies on (within [`COINCIDENT`] of the body's size),
|
||||
/// as warnings: the kernel may leave micrometre slivers where a cut runs along a face (#384).
|
||||
fn coincident(body: &Shape, parent: &Faces, cut: &Cut, n: [f64; 3], size: f64) -> Vec<String> {
|
||||
let reach = COINCIDENT * size.max(1.0);
|
||||
let mut out = Vec::new();
|
||||
for (i, surface) in parent.surfaces.iter().enumerate() {
|
||||
let Some(FaceSurface::Plane { normal, point }) = surface else { continue };
|
||||
let along = dot(*normal, n).abs();
|
||||
let distance = dot(sub(*point, cut.point), n).abs();
|
||||
if (along - 1.0).abs() < 1e-9 && distance <= reach {
|
||||
let name = parent.names.get(i).map_or_else(|| i.to_string(), |f| f.name.clone());
|
||||
out.push(format!(
|
||||
"partition `{}`: the plane lies on face `{name}` of `{}` ({distance:.2e} mm from it); a cut along an existing face can leave micrometre slivers that no mesher takes: move the plane off the face (by a fraction of the element size)",
|
||||
cut.name, body.name
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A plane within this fraction of a body's size of one of its faces lies on it.
|
||||
const COINCIDENT: f64 = 1e-5;
|
||||
|
||||
/// The least reach of the boxes [`halves`] cuts with (mm), for bodies with no extent.
|
||||
const LINEAR_REACH: f64 = 1.0;
|
||||
|
||||
/// What cutting one body gave.
|
||||
enum Split {
|
||||
/// Its cells, named, and how many cut faces they have.
|
||||
Cells(Vec<Shape>, usize),
|
||||
/// Its cells, named, how many cut faces they have, and warnings.
|
||||
Cells(Vec<Shape>, usize, Vec<String>),
|
||||
/// The plane does not divide it.
|
||||
Misses,
|
||||
/// The kernel failed to cut it: its message.
|
||||
@@ -165,7 +221,7 @@ enum Split {
|
||||
|
||||
/// Cut one body.
|
||||
fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result<Split> {
|
||||
let sides = match guard("partition", || solidcraft_kernel::split_by_plane(&body.body, plane)) {
|
||||
let sides = match guard("partition", || halves(body, plane)) {
|
||||
Ok(p) if p.len() >= 2 => p,
|
||||
Ok(_) => return Ok(Split::Misses),
|
||||
Err(e) => return Ok(Split::Failed(e.to_string())),
|
||||
@@ -209,7 +265,7 @@ fn split(body: &Shape, plane: &Plane, n: [f64; 3], cut: &Cut) -> Result<Split> {
|
||||
faces += name_faces(&mut shape, &parent, cut, n, size, tol)?;
|
||||
out.push(shape);
|
||||
}
|
||||
Ok(Split::Cells(out, faces))
|
||||
Ok(Split::Cells(out, faces, coincident(body, &parent, cut, n, size)))
|
||||
}
|
||||
|
||||
/// A body's faces as matching needs them: each face's analytic surface, name and triangles.
|
||||
@@ -437,7 +493,7 @@ mod tests {
|
||||
assert_eq!(p.bodies.len(), 2, "{:?}", p.bodies.iter().map(|b| &b.name).collect::<Vec<_>>());
|
||||
let volume: f64 = p.bodies.iter().map(|b| b.measure().unwrap().volume).sum();
|
||||
assert!((volume - whole).abs() < 1e-6 * whole, "{volume} vs {whole}");
|
||||
assert_eq!(p.cuts, vec![CutReport { name: "Mid".into(), split: vec![d.bodies[0].name.clone()], faces: 4 }]);
|
||||
assert_eq!(p.cuts, vec![CutReport { name: "Mid".into(), split: vec![d.bodies[0].name.clone()], faces: 4, warnings: Vec::new() }]);
|
||||
let mut sides = Vec::new();
|
||||
for b in &p.bodies {
|
||||
let m = b.measure().unwrap();
|
||||
|
||||
Reference in new issue
Block a user