brep_kernel/edit/split.rs
1//! Cut / split a body by a plane (Golovanov §6.4).
2//!
3//! The split reuses the ROBUST boolean rather than a bespoke classifier: the
4//! plane is realised as two very large half-space TOOL boxes (one covering each
5//! side of the plane), and each output piece is `Intersect(solid, tool)`. The
6//! boolean machinery imprints the cut plane onto the solid and re-closes the
7//! shell, so both pieces inherit the kernel's validated, watertight topology.
8
9use crate::boolean::{boolean_operation, BooleanOperation, BooleanOptions};
10use crate::spatial::Aabb;
11use crate::topology::{make_box_brep, make_cylinder_brep, BrepSolid};
12use crate::transform_topology::{transform_brep, AffineTransform};
13use crate::{
14 make_cone_brep, make_sphere_brep, make_torus_brep, AnalyticSurface, NurbsSurface, Vec3,
15};
16use serde::Deserialize;
17
18/// Axis-aligned bounding box of a solid: its vertices, and the control points
19/// of every face's surface and every edge's curve.
20///
21/// The vertices alone do not bound a body whose faces bulge past them: a torus
22/// has one vertex, so the box read as degenerate and the split refused; a
23/// sphere's are its two poles, a segment on the axis. A rational patch or curve
24/// with positive weights lies in the hull of its control points, so they bound
25/// it (loosely, which a tool three diagonals wide does not mind).
26fn solid_aabb(solid: &BrepSolid) -> Aabb {
27 let mut bounds = Aabb::empty();
28 for vertex in &solid.vertices {
29 bounds.include_point(vertex.point);
30 }
31 let surfaces = solid.shells.iter().flat_map(|shell| &shell.faces).flat_map(|face| face.surface.control_points.iter().flatten());
32 let curves = solid.edges.iter().flat_map(|edge| edge.curve.control_points.iter());
33 for control in surfaces.chain(curves) {
34 if let Ok(point) = control.point() {
35 bounds.include_point(point);
36 }
37 }
38 bounds
39}
40
41/// A boolean result counts as an empty piece when it carries no face geometry —
42/// i.e. the half-space tool did not overlap the solid on that side.
43fn is_empty_piece(solid: &BrepSolid) -> bool {
44 solid.shells.is_empty() || solid.shells.iter().all(|shell| shell.faces.is_empty())
45}
46
47/// Build the affine placing a local, origin-centred cube so its local +Z axis
48/// maps to `n`, +X to `u`, +Y to `v`, and its centre lands at `center`. The
49/// columns of the rotation are `[u v n]` (a right-handed, det = +1 frame), so
50/// the map is a proper rigid motion (no orientation reversal needed).
51fn frame_transform(u: Vec3, v: Vec3, n: Vec3, center: Vec3) -> Result<AffineTransform, String> {
52 AffineTransform::new([
53 u.x, v.x, n.x, center.x, //
54 u.y, v.y, n.y, center.y, //
55 u.z, v.z, n.z, center.z, //
56 0.0, 0.0, 0.0, 1.0,
57 ])
58}
59
60/// Split `solid` into two pieces by the plane through `plane_point` with normal
61/// `plane_normal`. Returns `(below, above)` where `below` is the piece on the
62/// −n side of the plane and `above` the piece on the +n side.
63///
64/// Contract: when the plane does not actually divide the solid into two
65/// non-degenerate pieces (it misses the body, or is tangent so one side is
66/// empty), this returns `Err("split_solid_by_plane: plane does not intersect
67/// the solid")` rather than a degenerate/empty piece.
68pub fn split_solid_by_plane(
69 solid: &BrepSolid,
70 plane_point: Vec3,
71 plane_normal: Vec3,
72) -> Result<(BrepSolid, BrepSolid), String> {
73 let n = plane_normal.normalized()?;
74 // Orthonormal frame (n, u, v): u ⟂ n (unit), v = n × u (unit); [u v n] is
75 // right-handed so the tool placement is a proper rotation.
76 let u = n.perpendicular()?;
77 let v = n.cross(u);
78
79 let bounds = solid_aabb(solid);
80 if !bounds.minimum.x.is_finite() {
81 return Err("split_solid_by_plane: solid has no geometry".into());
82 }
83 let diagonal = bounds.diagonal();
84 if diagonal <= 0.0 {
85 return Err("split_solid_by_plane: solid is degenerate".into());
86 }
87 // A tool box 3× the solid diagonal on every side easily covers the body in
88 // the plane's tangent directions.
89 let length = 3.0 * diagonal;
90 let half = 0.5 * length;
91
92 // Centre the tool in the tangent (u, v) plane on the projection of the AABB
93 // centre onto the cut plane, so the box brackets the whole solid regardless
94 // of where `plane_point` sits within it. Along n it is offset by ±half so
95 // the tool's cut face lands exactly on the plane.
96 let center = bounds.minimum.add(bounds.maximum).scale(0.5);
97 let center_on_plane = center.sub(n.scale(center.sub(plane_point).dot(n)));
98
99 // Local cube centred at the local origin, spanning [-half, half]³.
100 let cube = make_box_brep(Vec3::new(-half, -half, -half), length, length, length)?;
101
102 // BELOW: tool centred at plane − n·half, so its +n face lies on the plane
103 // and it extends distance `length` along −n, covering the −n side.
104 let below_center = center_on_plane.sub(n.scale(half));
105 let tool_below = transform_brep(&cube, frame_transform(u, v, n, below_center)?, false)?;
106
107 // ABOVE: mirror to the +n side (centre at plane + n·half).
108 let above_center = center_on_plane.add(n.scale(half));
109 let tool_above = transform_brep(&cube, frame_transform(u, v, n, above_center)?, false)?;
110
111 let options = BooleanOptions::default();
112 let below = boolean_operation(solid, &tool_below, BooleanOperation::Intersect, &options);
113 let above = boolean_operation(solid, &tool_above, BooleanOperation::Intersect, &options);
114
115 match (below, above) {
116 (Ok(below), Ok(above)) if !is_empty_piece(&below) && !is_empty_piece(&above) => {
117 Ok((below, above))
118 }
119 _ => Err("split_solid_by_plane: plane does not intersect the solid".into()),
120 }
121}
122
123// ---------------------------------------------------------------------------
124// Generalized split by an analytic surface (Golovanov §6.4).
125//
126// The plane case above splits a body against two large half-space TOOL boxes.
127// The analytic cases generalize that idea: an unbounded/bounded analytic
128// surface (cylinder, cone, sphere, torus) is realised as ONE CLOSED SOLID
129// region big enough to span the body wherever it matters, and the two output
130// pieces are the boolean `Intersect` (inside the tool region) and `Subtract`
131// (outside it) of the body against that region. Because every cut runs
132// through the validated boolean machinery, each piece inherits watertight
133// topology, the cut surface is imprinted onto the body, and the two volumes
134// sum to the original.
135// ---------------------------------------------------------------------------
136
137/// A closed analytic tool region used to cut a body. Each variant is realised
138/// as a closed solid (unbounded carriers are capped well beyond the body) whose
139/// interior is one side of the analytic surface.
140#[derive(Clone, Copy, Debug, Deserialize)]
141#[serde(tag = "type", rename_all = "lowercase")]
142pub enum SplitSurface {
143 /// Infinite plane through `point` with `normal`; delegates to the plane path.
144 Plane { point: Vec3, normal: Vec3 },
145 /// Infinite cylinder about the axis line through `axis_point` along
146 /// `axis_dir`, of the given `radius`. Interior = inside the cylinder.
147 Cylinder {
148 axis_point: Vec3,
149 axis_dir: Vec3,
150 radius: f64,
151 },
152 /// Sphere centred at `center`. Interior = inside the ball.
153 Sphere { center: Vec3, radius: f64 },
154 /// Single-nappe cone with its apex at `apex`, opening along `+axis_dir`,
155 /// with the given `half_angle` (radians, apex half-angle). Interior =
156 /// inside the cone.
157 Cone {
158 apex: Vec3,
159 axis_dir: Vec3,
160 half_angle: f64,
161 },
162 /// Torus centred at `center` about `axis_dir`. Interior = inside the tube.
163 Torus {
164 center: Vec3,
165 axis_dir: Vec3,
166 major_radius: f64,
167 minor_radius: f64,
168 },
169}
170
171/// Bounds + a sane margin/diagonal for a body, erroring on empty/degenerate
172/// geometry the same way the plane path does.
173fn solid_extent(solid: &BrepSolid) -> Result<(Aabb, f64), String> {
174 let bounds = solid_aabb(solid);
175 if !bounds.minimum.x.is_finite() {
176 return Err("split_solid_by_surface: solid has no geometry".into());
177 }
178 let diagonal = bounds.diagonal();
179 if diagonal <= 0.0 {
180 return Err("split_solid_by_surface: solid is degenerate".into());
181 }
182 Ok((bounds, diagonal))
183}
184
185/// Build the closed tool solid for an analytic tool, sized to fully span the
186/// body wherever the analytic surface passes through it.
187fn build_tool_solid(solid: &BrepSolid, tool: &SplitSurface) -> Result<BrepSolid, String> {
188 let (_, diagonal) = solid_extent(solid)?;
189 let margin = diagonal.max(1.0);
190 match *tool {
191 SplitSurface::Plane { .. } => {
192 Err("build_tool_solid: plane is handled by the plane path".into())
193 }
194 SplitSurface::Cylinder {
195 axis_point,
196 axis_dir,
197 radius,
198 } => {
199 if radius <= 0.0 {
200 return Err("split_solid_by_surface: cylinder radius must be positive".into());
201 }
202 let axis = axis_dir.normalized()?;
203 // Extend the capped cylinder a full margin beyond the body's span
204 // along the axis so its caps never cut the body.
205 let (t_min, t_max) = axis_span(solid, axis_point, axis);
206 let base = axis_point.add(axis.scale(t_min - margin));
207 let height = (t_max - t_min) + 2.0 * margin;
208 make_cylinder_brep(base, axis, radius, height)
209 }
210 SplitSurface::Sphere { center, radius } => {
211 if radius <= 0.0 {
212 return Err("split_solid_by_surface: sphere radius must be positive".into());
213 }
214 // A sphere is already a closed, bounded region.
215 make_sphere_brep(center, radius, Vec3::new(0.0, 0.0, 1.0))
216 }
217 SplitSurface::Cone {
218 apex,
219 axis_dir,
220 half_angle,
221 } => {
222 if !(half_angle > 0.0 && half_angle < std::f64::consts::FRAC_PI_2) {
223 return Err("split_solid_by_surface: cone half-angle must be in (0, pi/2)".into());
224 }
225 let axis = axis_dir.normalized()?;
226 // Distance of the farthest body point along +axis from the apex.
227 let (_, d_max) = axis_span(solid, apex, axis);
228 if d_max <= 0.0 {
229 return Err(
230 "split_solid_by_surface: cone does not reach the solid (body is behind the apex)"
231 .into(),
232 );
233 }
234 let big_h = d_max + margin;
235 // Realise the nappe as a cone whose apex sits at `apex` and whose
236 // base cap lands `big_h` past it along +axis: base at apex+axis·H,
237 // built with axis pointing back to the apex so top(0-radius)=apex.
238 let base = apex.add(axis.scale(big_h));
239 let base_radius = big_h * half_angle.tan();
240 make_cone_brep(base, axis.scale(-1.0), base_radius, 0.0, big_h)
241 }
242 SplitSurface::Torus {
243 center,
244 axis_dir,
245 major_radius,
246 minor_radius,
247 } => {
248 if minor_radius <= 0.0 || major_radius <= 0.0 {
249 return Err("split_solid_by_surface: torus radii must be positive".into());
250 }
251 // A torus is already a closed, bounded region.
252 make_torus_brep(center, axis_dir, major_radius, minor_radius)
253 }
254 }
255}
256
257/// Signed span `[min, max]` of the body's vertices projected onto the axis line
258/// through `origin` along the unit direction `axis`.
259fn axis_span(solid: &BrepSolid, origin: Vec3, axis: Vec3) -> (f64, f64) {
260 let mut t_min = f64::INFINITY;
261 let mut t_max = f64::NEG_INFINITY;
262 for vertex in &solid.vertices {
263 let t = vertex.point.sub(origin).dot(axis);
264 t_min = t_min.min(t);
265 t_max = t_max.max(t);
266 }
267 (t_min, t_max)
268}
269
270/// Split `solid` into pieces by an analytic tool surface (Golovanov §6.4).
271///
272/// For the `Plane` tool this is exactly `split_solid_by_plane`, returned as
273/// `[below, above]`. For a closed analytic tool region (cylinder / sphere /
274/// cone / torus) the two pieces are `[inside, outside]` where `inside =
275/// solid ∩ tool` and `outside = solid − tool`. Both pieces are guaranteed
276/// non-empty and valid; their volumes sum to the original.
277///
278/// Contract: when the tool does not actually divide the body into two
279/// non-degenerate pieces (it misses the body, or wholly contains / is wholly
280/// contained so one side is empty), this returns a clear `Err` rather than a
281/// degenerate/empty piece.
282pub fn split_solid_by_surface(
283 solid: &BrepSolid,
284 tool: &SplitSurface,
285) -> Result<Vec<BrepSolid>, String> {
286 if let SplitSurface::Plane { point, normal } = *tool {
287 let (below, above) = split_solid_by_plane(solid, point, normal)?;
288 return Ok(vec![below, above]);
289 }
290
291 let tool_solid = build_tool_solid(solid, tool)?;
292 let options = BooleanOptions::default();
293 let inside = boolean_operation(solid, &tool_solid, BooleanOperation::Intersect, &options);
294 let outside = boolean_operation(solid, &tool_solid, BooleanOperation::Subtract, &options);
295
296 match (inside, outside) {
297 (Ok(inside), Ok(outside))
298 if !is_empty_piece(&inside)
299 && !is_empty_piece(&outside)
300 && inside.validate().is_empty()
301 && outside.validate().is_empty() =>
302 {
303 Ok(vec![inside, outside])
304 }
305 _ => Err("split_solid_by_surface: tool surface does not divide the solid".into()),
306 }
307}
308
309/// Map a face's exact analytic carrier to the closed tool region that splits a
310/// body by that surface. Reuses the kernel's own analytic recognition so the
311/// caller only has to hand over the selected face's surface (no host-side
312/// geometry extraction). Unrecognized / general-revolution carriers are
313/// reported as unsupported (deferred), never approximated.
314fn recognized_split_surface(surface: &NurbsSurface) -> Result<SplitSurface, String> {
315 let analytic = surface
316 .analytic()
317 .ok_or("split_solid_by_face_surface: selected face is not an analytic surface")?;
318 match analytic {
319 AnalyticSurface::Plane {
320 origin,
321 u_dir,
322 v_dir,
323 ..
324 } => {
325 let normal = u_dir.cross(*v_dir).normalized()?;
326 Ok(SplitSurface::Plane {
327 point: *origin,
328 normal,
329 })
330 }
331 AnalyticSurface::RuledRevolution {
332 frame,
333 rho0,
334 rho1,
335 height,
336 } => {
337 // Cylinder when the two radii coincide, otherwise a cone/frustum.
338 let scale = rho0.abs().max(rho1.abs()).max(1.0);
339 if (rho0 - rho1).abs() <= 1e-9 * scale {
340 Ok(SplitSurface::Cylinder {
341 axis_point: frame.origin,
342 axis_dir: frame.axis,
343 radius: 0.5 * (rho0 + rho1),
344 })
345 } else {
346 // radius(axial) = rho0 + slope·axial, apex where radius = 0.
347 let slope = (rho1 - rho0) / height;
348 let axial_apex = -rho0 / slope;
349 let apex = frame.origin.add(frame.axis.scale(axial_apex));
350 // The nappe opens in the direction of increasing radius.
351 let axis_dir = if slope >= 0.0 {
352 frame.axis
353 } else {
354 frame.axis.scale(-1.0)
355 };
356 Ok(SplitSurface::Cone {
357 apex,
358 axis_dir,
359 half_angle: slope.abs().atan(),
360 })
361 }
362 }
363 AnalyticSurface::Sphere { frame, radius } => Ok(SplitSurface::Sphere {
364 center: frame.origin,
365 radius: *radius,
366 }),
367 AnalyticSurface::Torus {
368 frame,
369 major_radius,
370 minor_radius,
371 } => Ok(SplitSurface::Torus {
372 center: frame.origin,
373 axis_dir: frame.axis,
374 major_radius: *major_radius,
375 minor_radius: *minor_radius,
376 }),
377 AnalyticSurface::Revolution { .. } => Err(
378 "split_solid_by_face_surface: general revolved surfaces are not supported as a cut tool"
379 .into(),
380 ),
381 }
382}
383
384/// Split `solid` by the analytic carrier of a selected face `surface`
385/// (Golovanov §6.4). The face may be a plane, cylinder, cone, or sphere; the
386/// carrier is extended to fully span the body. Returns the two pieces
387/// (`[below, above]` for a plane, `[inside, outside]` otherwise). Errors on
388/// non-analytic / general-revolution faces, or when the carrier does not
389/// cleanly divide the body.
390pub fn split_solid_by_face_surface(
391 solid: &BrepSolid,
392 surface: &NurbsSurface,
393) -> Result<Vec<BrepSolid>, String> {
394 let tool = recognized_split_surface(surface)?;
395 split_solid_by_surface(solid, &tool)
396}
397