use brepkit_math::tolerance::Tolerance;
use brepkit_math::vec::{Point3, Vec3};
use brepkit_topology::Topology;
use brepkit_topology::solid::SolidId;
use crate::boolean::{FaceSpec, assemble_solid_mixed};
use crate::dot_normal_point;
#[derive(Debug)]
pub struct SplitResult {
pub positive: SolidId,
pub negative: SolidId,
}
#[allow(clippy::too_many_lines)]
pub fn split(
topo: &mut Topology,
solid: SolidId,
plane_point: Point3,
plane_normal: Vec3,
) -> Result<SplitResult, crate::OperationsError> {
let tol = Tolerance::new();
let normal = plane_normal.normalize()?;
let d = dot_normal_point(normal, plane_point);
let solid_data = topo.solid(solid)?;
let shell = topo.shell(solid_data.outer_shell())?;
let face_ids: Vec<brepkit_topology::face::FaceId> = shell.faces().to_vec();
let mut positive_specs: Vec<FaceSpec> = Vec::new();
let mut negative_specs: Vec<FaceSpec> = Vec::new();
let mut cap_points: Vec<Point3> = Vec::new();
for &fid in &face_ids {
let face = topo.face(fid)?;
let surface = face.surface().clone();
let verts = crate::boolean::face_polygon(topo, fid)?;
let dists: Vec<f64> = verts
.iter()
.map(|v| dot_normal_point(normal, *v) - d)
.collect();
let all_pos = dists.iter().all(|&di| di > -tol.linear);
let all_neg = dists.iter().all(|&di| di < tol.linear);
let make_spec = |v: Vec<Point3>, surf: &brepkit_topology::face::FaceSurface| -> FaceSpec {
match surf {
brepkit_topology::face::FaceSurface::Plane { normal: fn_, d: fd } => {
FaceSpec::Planar {
vertices: v,
normal: *fn_,
d: *fd,
inner_wires: vec![],
}
}
other => FaceSpec::Surface {
vertices: v,
surface: other.clone(),
reversed: false,
inner_wires: vec![],
},
}
};
if all_pos && !all_neg {
positive_specs.push(make_spec(verts, &surface));
} else if all_neg && !all_pos {
negative_specs.push(make_spec(verts, &surface));
} else if all_pos && all_neg {
positive_specs.push(make_spec(verts.clone(), &surface));
negative_specs.push(make_spec(verts, &surface));
} else {
let (pos_verts, neg_verts, crossings) = clip_polygon(&verts, &dists, tol);
if pos_verts.len() >= 3 {
positive_specs.push(make_spec(pos_verts, &surface));
}
if neg_verts.len() >= 3 {
negative_specs.push(make_spec(neg_verts, &surface));
}
cap_points.extend(crossings);
}
}
if positive_specs.is_empty() || negative_specs.is_empty() {
return Err(crate::OperationsError::InvalidInput {
reason: "cutting plane does not split the solid (entirely on one side)".into(),
});
}
let cap = build_cap_polygon(&cap_points, normal, d, tol);
if let Some((cap_verts, cap_normal, cap_d)) = cap {
positive_specs.push(FaceSpec::Planar {
vertices: cap_verts.clone(),
normal: -cap_normal,
d: -cap_d,
inner_wires: vec![],
});
negative_specs.push(FaceSpec::Planar {
vertices: cap_verts,
normal: cap_normal,
d: cap_d,
inner_wires: vec![],
});
}
let pos_solid = assemble_solid_mixed(topo, &positive_specs, tol)?;
let neg_solid = assemble_solid_mixed(topo, &negative_specs, tol)?;
Ok(SplitResult {
positive: pos_solid,
negative: neg_solid,
})
}
fn clip_polygon(
verts: &[Point3],
dists: &[f64],
tol: Tolerance,
) -> (Vec<Point3>, Vec<Point3>, Vec<Point3>) {
let n = verts.len();
let mut pos_verts = Vec::new();
let mut neg_verts = Vec::new();
let mut crossings = Vec::new();
for i in 0..n {
let j = (i + 1) % n;
let di = dists[i];
let dj = dists[j];
if di >= -tol.linear {
pos_verts.push(verts[i]);
}
if di <= tol.linear {
neg_verts.push(verts[i]);
}
if (di > tol.linear && dj < -tol.linear) || (di < -tol.linear && dj > tol.linear) {
let t = di / (di - dj);
let pi = verts[i];
let pj = verts[j];
let ix = Point3::new(
(pj.x() - pi.x()).mul_add(t, pi.x()),
(pj.y() - pi.y()).mul_add(t, pi.y()),
(pj.z() - pi.z()).mul_add(t, pi.z()),
);
pos_verts.push(ix);
neg_verts.push(ix);
crossings.push(ix);
}
}
(pos_verts, neg_verts, crossings)
}
fn build_cap_polygon(
points: &[Point3],
normal: Vec3,
d: f64,
tol: Tolerance,
) -> Option<(Vec<Point3>, Vec3, f64)> {
let mut unique = Vec::new();
for p in points {
if !unique
.iter()
.any(|q: &Point3| (*p - *q).length_squared() < tol.linear * tol.linear)
{
unique.push(*p);
}
}
if unique.len() < 3 {
return None;
}
#[allow(clippy::cast_precision_loss)]
let inv_n = 1.0 / unique.len() as f64;
let (cx, cy, cz) = unique.iter().fold((0.0, 0.0, 0.0), |(ax, ay, az), p| {
(ax + p.x(), ay + p.y(), az + p.z())
});
let centroid = Point3::new(cx * inv_n, cy * inv_n, cz * inv_n);
let candidate = if normal.x().abs() < 0.9 {
Vec3::new(1.0, 0.0, 0.0)
} else {
Vec3::new(0.0, 1.0, 0.0)
};
let u_axis = normal.cross(candidate);
let u_len = u_axis.length();
if u_len < tol.linear {
return None;
}
let u_axis = Vec3::new(u_axis.x() / u_len, u_axis.y() / u_len, u_axis.z() / u_len);
let v_axis = normal.cross(u_axis);
let mut angles: Vec<(f64, usize)> = unique
.iter()
.enumerate()
.map(|(i, p)| {
let offset = *p - centroid;
let u = u_axis.dot(offset);
let v = v_axis.dot(offset);
(v.atan2(u), i)
})
.collect();
angles.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let ordered: Vec<Point3> = angles.iter().map(|&(_, i)| unique[i]).collect();
Some((ordered, normal, d))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use brepkit_math::tolerance::Tolerance;
use brepkit_math::vec::{Point3, Vec3};
use brepkit_topology::Topology;
use brepkit_topology::test_utils::make_unit_cube_manifold;
use super::*;
#[test]
fn split_cube_at_half_height() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let result = split(
&mut topo,
cube,
Point3::new(0.0, 0.0, 0.5),
Vec3::new(0.0, 0.0, 1.0),
)
.unwrap();
let vol_pos = crate::measure::solid_volume(&topo, result.positive, 0.1).unwrap();
let vol_neg = crate::measure::solid_volume(&topo, result.negative, 0.1).unwrap();
assert!(
vol_pos > 0.1,
"positive half should have volume, got {vol_pos}"
);
assert!(
vol_neg > 0.1,
"negative half should have volume, got {vol_neg}"
);
let tol = Tolerance::loose();
let total = vol_pos + vol_neg;
assert!(
tol.approx_eq(total, 1.0),
"halves should sum to ~1.0, got {total} ({vol_pos} + {vol_neg})"
);
}
#[test]
fn split_box_at_quarter() {
let mut topo = Topology::new();
let solid = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
let result = split(
&mut topo,
solid,
Point3::new(0.0, 0.0, 0.5),
Vec3::new(0.0, 0.0, 1.0),
)
.unwrap();
let vol_pos = crate::measure::solid_volume(&topo, result.positive, 0.1).unwrap();
let vol_neg = crate::measure::solid_volume(&topo, result.negative, 0.1).unwrap();
let total = vol_pos + vol_neg;
let tol = Tolerance::loose();
assert!(
tol.approx_eq(total, 8.0),
"halves should sum to ~8.0, got {total}"
);
}
#[test]
fn split_plane_misses_solid() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let result = split(
&mut topo,
cube,
Point3::new(0.0, 0.0, 5.0),
Vec3::new(0.0, 0.0, 1.0),
);
assert!(result.is_err(), "plane above cube should fail");
}
#[test]
fn split_along_x_axis() {
let mut topo = Topology::new();
let cube = make_unit_cube_manifold(&mut topo);
let result = split(
&mut topo,
cube,
Point3::new(0.5, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
)
.unwrap();
let vol_pos = crate::measure::solid_volume(&topo, result.positive, 0.1).unwrap();
let vol_neg = crate::measure::solid_volume(&topo, result.negative, 0.1).unwrap();
let total = vol_pos + vol_neg;
let tol = Tolerance::loose();
assert!(
tol.approx_eq(total, 1.0),
"halves should sum to ~1.0, got {total}"
);
}
}