1use brepkit_math::tolerance::Tolerance;
6use brepkit_math::vec::{Point3, Vec3};
7use brepkit_topology::Topology;
8use brepkit_topology::solid::SolidId;
9
10use crate::boolean::{FaceSpec, assemble_solid_mixed};
11use crate::dot_normal_point;
12
13#[derive(Debug)]
15pub struct SplitResult {
16 pub positive: SolidId,
18 pub negative: SolidId,
20}
21
22#[allow(clippy::too_many_lines)]
41pub fn split(
42 topo: &mut Topology,
43 solid: SolidId,
44 plane_point: Point3,
45 plane_normal: Vec3,
46) -> Result<SplitResult, crate::OperationsError> {
47 let tol = Tolerance::new();
48 let normal = plane_normal.normalize()?;
49 let d = dot_normal_point(normal, plane_point);
50
51 let solid_data = topo.solid(solid)?;
52 let shell = topo.shell(solid_data.outer_shell())?;
53 let face_ids: Vec<brepkit_topology::face::FaceId> = shell.faces().to_vec();
54
55 let mut positive_specs: Vec<FaceSpec> = Vec::new();
56 let mut negative_specs: Vec<FaceSpec> = Vec::new();
57 let mut cap_points: Vec<Point3> = Vec::new();
58
59 for &fid in &face_ids {
60 let face = topo.face(fid)?;
61 let surface = face.surface().clone();
62 let verts = crate::boolean::face_polygon(topo, fid)?;
63 let dists: Vec<f64> = verts
64 .iter()
65 .map(|v| dot_normal_point(normal, *v) - d)
66 .collect();
67
68 let all_pos = dists.iter().all(|&di| di > -tol.linear);
69 let all_neg = dists.iter().all(|&di| di < tol.linear);
70
71 let make_spec = |v: Vec<Point3>, surf: &brepkit_topology::face::FaceSurface| -> FaceSpec {
73 match surf {
74 brepkit_topology::face::FaceSurface::Plane { normal: fn_, d: fd } => {
75 FaceSpec::Planar {
76 vertices: v,
77 normal: *fn_,
78 d: *fd,
79 inner_wires: vec![],
80 }
81 }
82 other => FaceSpec::Surface {
83 vertices: v,
84 surface: other.clone(),
85 reversed: false,
86 inner_wires: vec![],
87 },
88 }
89 };
90
91 if all_pos && !all_neg {
92 positive_specs.push(make_spec(verts, &surface));
93 } else if all_neg && !all_pos {
94 negative_specs.push(make_spec(verts, &surface));
95 } else if all_pos && all_neg {
96 positive_specs.push(make_spec(verts.clone(), &surface));
97 negative_specs.push(make_spec(verts, &surface));
98 } else {
99 let (pos_verts, neg_verts, crossings) = clip_polygon(&verts, &dists, tol);
105
106 if pos_verts.len() >= 3 {
107 positive_specs.push(make_spec(pos_verts, &surface));
108 }
109 if neg_verts.len() >= 3 {
110 negative_specs.push(make_spec(neg_verts, &surface));
111 }
112
113 cap_points.extend(crossings);
114 }
115 }
116
117 if positive_specs.is_empty() || negative_specs.is_empty() {
118 return Err(crate::OperationsError::InvalidInput {
119 reason: "cutting plane does not split the solid (entirely on one side)".into(),
120 });
121 }
122
123 let cap = build_cap_polygon(&cap_points, normal, d, tol);
124
125 if let Some((cap_verts, cap_normal, cap_d)) = cap {
126 positive_specs.push(FaceSpec::Planar {
127 vertices: cap_verts.clone(),
128 normal: -cap_normal,
129 d: -cap_d,
130 inner_wires: vec![],
131 });
132 negative_specs.push(FaceSpec::Planar {
133 vertices: cap_verts,
134 normal: cap_normal,
135 d: cap_d,
136 inner_wires: vec![],
137 });
138 }
139
140 let pos_solid = assemble_solid_mixed(topo, &positive_specs, tol)?;
141 let neg_solid = assemble_solid_mixed(topo, &negative_specs, tol)?;
142
143 Ok(SplitResult {
144 positive: pos_solid,
145 negative: neg_solid,
146 })
147}
148
149fn clip_polygon(
153 verts: &[Point3],
154 dists: &[f64],
155 tol: Tolerance,
156) -> (Vec<Point3>, Vec<Point3>, Vec<Point3>) {
157 let n = verts.len();
158 let mut pos_verts = Vec::new();
159 let mut neg_verts = Vec::new();
160 let mut crossings = Vec::new();
161
162 for i in 0..n {
163 let j = (i + 1) % n;
164 let di = dists[i];
165 let dj = dists[j];
166
167 if di >= -tol.linear {
168 pos_verts.push(verts[i]);
169 }
170 if di <= tol.linear {
171 neg_verts.push(verts[i]);
172 }
173
174 if (di > tol.linear && dj < -tol.linear) || (di < -tol.linear && dj > tol.linear) {
175 let t = di / (di - dj);
176 let pi = verts[i];
177 let pj = verts[j];
178 let ix = Point3::new(
179 (pj.x() - pi.x()).mul_add(t, pi.x()),
180 (pj.y() - pi.y()).mul_add(t, pi.y()),
181 (pj.z() - pi.z()).mul_add(t, pi.z()),
182 );
183 pos_verts.push(ix);
184 neg_verts.push(ix);
185 crossings.push(ix);
186 }
187 }
188
189 (pos_verts, neg_verts, crossings)
190}
191
192fn build_cap_polygon(
196 points: &[Point3],
197 normal: Vec3,
198 d: f64,
199 tol: Tolerance,
200) -> Option<(Vec<Point3>, Vec3, f64)> {
201 let mut unique = Vec::new();
202 for p in points {
203 if !unique
204 .iter()
205 .any(|q: &Point3| (*p - *q).length_squared() < tol.linear * tol.linear)
206 {
207 unique.push(*p);
208 }
209 }
210
211 if unique.len() < 3 {
212 return None;
213 }
214
215 #[allow(clippy::cast_precision_loss)]
216 let inv_n = 1.0 / unique.len() as f64;
217 let (cx, cy, cz) = unique.iter().fold((0.0, 0.0, 0.0), |(ax, ay, az), p| {
218 (ax + p.x(), ay + p.y(), az + p.z())
219 });
220 let centroid = Point3::new(cx * inv_n, cy * inv_n, cz * inv_n);
221
222 let candidate = if normal.x().abs() < 0.9 {
223 Vec3::new(1.0, 0.0, 0.0)
224 } else {
225 Vec3::new(0.0, 1.0, 0.0)
226 };
227 let u_axis = normal.cross(candidate);
228 let u_len = u_axis.length();
229 if u_len < tol.linear {
230 return None;
231 }
232 let u_axis = Vec3::new(u_axis.x() / u_len, u_axis.y() / u_len, u_axis.z() / u_len);
233 let v_axis = normal.cross(u_axis);
234
235 let mut angles: Vec<(f64, usize)> = unique
236 .iter()
237 .enumerate()
238 .map(|(i, p)| {
239 let offset = *p - centroid;
240 let u = u_axis.dot(offset);
241 let v = v_axis.dot(offset);
242 (v.atan2(u), i)
243 })
244 .collect();
245 angles.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
246
247 let ordered: Vec<Point3> = angles.iter().map(|&(_, i)| unique[i]).collect();
248
249 Some((ordered, normal, d))
250}
251
252#[cfg(test)]
253mod tests {
254 #![allow(clippy::unwrap_used)]
255
256 use brepkit_math::tolerance::Tolerance;
257 use brepkit_math::vec::{Point3, Vec3};
258 use brepkit_topology::Topology;
259 use brepkit_topology::test_utils::make_unit_cube_manifold;
260
261 use super::*;
262
263 #[test]
264 fn split_cube_at_half_height() {
265 let mut topo = Topology::new();
266 let cube = make_unit_cube_manifold(&mut topo);
267
268 let result = split(
269 &mut topo,
270 cube,
271 Point3::new(0.0, 0.0, 0.5),
272 Vec3::new(0.0, 0.0, 1.0),
273 )
274 .unwrap();
275
276 let vol_pos = crate::measure::solid_volume(&topo, result.positive, 0.1).unwrap();
277 let vol_neg = crate::measure::solid_volume(&topo, result.negative, 0.1).unwrap();
278
279 assert!(
280 vol_pos > 0.1,
281 "positive half should have volume, got {vol_pos}"
282 );
283 assert!(
284 vol_neg > 0.1,
285 "negative half should have volume, got {vol_neg}"
286 );
287
288 let tol = Tolerance::loose();
289 let total = vol_pos + vol_neg;
290 assert!(
291 tol.approx_eq(total, 1.0),
292 "halves should sum to ~1.0, got {total} ({vol_pos} + {vol_neg})"
293 );
294 }
295
296 #[test]
297 fn split_box_at_quarter() {
298 let mut topo = Topology::new();
299 let solid = crate::primitives::make_box(&mut topo, 2.0, 2.0, 2.0).unwrap();
300
301 let result = split(
303 &mut topo,
304 solid,
305 Point3::new(0.0, 0.0, 0.5),
306 Vec3::new(0.0, 0.0, 1.0),
307 )
308 .unwrap();
309
310 let vol_pos = crate::measure::solid_volume(&topo, result.positive, 0.1).unwrap();
311 let vol_neg = crate::measure::solid_volume(&topo, result.negative, 0.1).unwrap();
312
313 let total = vol_pos + vol_neg;
315 let tol = Tolerance::loose();
316 assert!(
317 tol.approx_eq(total, 8.0),
318 "halves should sum to ~8.0, got {total}"
319 );
320 }
321
322 #[test]
323 fn split_plane_misses_solid() {
324 let mut topo = Topology::new();
325 let cube = make_unit_cube_manifold(&mut topo);
326
327 let result = split(
329 &mut topo,
330 cube,
331 Point3::new(0.0, 0.0, 5.0),
332 Vec3::new(0.0, 0.0, 1.0),
333 );
334 assert!(result.is_err(), "plane above cube should fail");
335 }
336
337 #[test]
338 fn split_along_x_axis() {
339 let mut topo = Topology::new();
340 let cube = make_unit_cube_manifold(&mut topo);
341
342 let result = split(
343 &mut topo,
344 cube,
345 Point3::new(0.5, 0.0, 0.0),
346 Vec3::new(1.0, 0.0, 0.0),
347 )
348 .unwrap();
349
350 let vol_pos = crate::measure::solid_volume(&topo, result.positive, 0.1).unwrap();
351 let vol_neg = crate::measure::solid_volume(&topo, result.negative, 0.1).unwrap();
352
353 let total = vol_pos + vol_neg;
354 let tol = Tolerance::loose();
355 assert!(
356 tol.approx_eq(total, 1.0),
357 "halves should sum to ~1.0, got {total}"
358 );
359 }
360}