brepkit_offset/lib.rs
1//! # brepkit-offset
2//!
3//! Solid offset engine for brepkit.
4//!
5//! This is layer L2, depending on `brepkit-math`, `brepkit-topology`,
6//! and `brepkit-geometry`.
7//!
8//! # Pipeline
9//!
10//! The offset algorithm follows a 9-phase pipeline:
11//!
12//! 1. **Analyse** — classify edges as convex/concave/tangent, derive vertex
13//! classes.
14//! 2. **Offset** — construct the offset surface for each face (translate
15//! planes, adjust cylinder radii, etc.).
16//! 3. **Intersect 3D** — intersect adjacent offset faces in 3D to find new
17//! edge curves.
18//! 4. **Intersect 2D** — intersect offset PCurves in parameter space to find
19//! edge split points.
20//! 5. **Split edges** — split original edges at intersection parameters.
21//! 6. **Arc joints** — optionally insert rolling-ball arc fillets at convex
22//! edges.
23//! 7. **Build loops** — assemble trimmed edges into closed wire loops for each
24//! offset face.
25//! 8. **Assemble** — build the final shell and solid from offset faces and
26//! wire loops.
27//! 9. **Self-intersection removal** — detect and excise global
28//! self-intersections if enabled.
29
30pub(crate) mod analyse;
31pub(crate) mod arc_joint;
32pub(crate) mod assemble;
33pub(crate) mod data;
34pub mod error;
35pub(crate) mod image;
36pub(crate) mod inter2d;
37pub(crate) mod inter3d;
38pub(crate) mod loops;
39pub(crate) mod offset;
40pub(crate) mod self_int;
41
42pub use data::{JointType, OffsetOptions};
43pub use error::OffsetError;
44
45use brepkit_topology::Topology;
46use brepkit_topology::face::FaceId;
47use brepkit_topology::solid::SolidId;
48
49use crate::data::OffsetData;
50
51/// Offset all faces of a solid by the given signed distance.
52///
53/// Positive distance offsets outward (enlarges), negative inward (shrinks).
54///
55/// # Errors
56///
57/// Returns [`OffsetError`] if the offset collapses the solid, any
58/// intersection fails, or the result cannot be assembled into a valid solid.
59pub fn offset_solid(
60 topo: &mut Topology,
61 solid: SolidId,
62 distance: f64,
63 options: OffsetOptions,
64) -> Result<SolidId, OffsetError> {
65 if !distance.is_finite() || distance.abs() < options.tolerance.linear {
66 return Err(OffsetError::InvalidInput {
67 reason: "offset distance must be non-zero and finite".into(),
68 });
69 }
70 if options.joint == JointType::Intersection
71 && let Some(result) = image::offset_solid(topo, solid, distance, options.tolerance.linear)?
72 {
73 return Ok(result);
74 }
75 offset_by_intersection(topo, solid, distance, &[], options)
76}
77
78/// Offset a solid while excluding specific faces, producing a thick
79/// (hollowed) solid.
80///
81/// Excluded faces are left at their original positions, and side walls
82/// connect them to the offset faces. With no excluded face the wall closes
83/// round a cavity, which only the exact path ([`thick_solid_by_image`])
84/// builds.
85///
86/// # Errors
87///
88/// Returns [`OffsetError`] if the offset collapses the solid, any
89/// intersection fails, the result cannot be assembled into a valid solid, or
90/// no face is excluded and the exact path does not apply.
91pub fn thick_solid(
92 topo: &mut Topology,
93 solid: SolidId,
94 distance: f64,
95 exclude: &[FaceId],
96 options: OffsetOptions,
97) -> Result<SolidId, OffsetError> {
98 if !distance.is_finite() || distance.abs() < options.tolerance.linear {
99 return Err(OffsetError::InvalidInput {
100 reason: "offset distance must be non-zero and finite".into(),
101 });
102 }
103 if options.joint == JointType::Intersection
104 && let Some(result) =
105 image::thick_solid(topo, solid, distance, exclude, options.tolerance.linear)?
106 {
107 return Ok(result);
108 }
109 if exclude.is_empty() {
110 return Err(OffsetError::InvalidInput {
111 reason: "a wall with no opening is built only where it keeps the solid's topology"
112 .into(),
113 });
114 }
115 offset_by_intersection(topo, solid, distance, exclude, options)
116}
117
118/// The thick solid of [`thick_solid`] built exact, as images of the input's
119/// faces with sharp joints, or `None` when that does not apply. With no
120/// excluded face the wall closes round a cavity.
121///
122/// It does not apply when the wall would not keep the input's topology (a
123/// face collapses, an edge turns round, loops meet), or to NURBS faces,
124/// edges other than lines and circles, open faces that are curved, holed,
125/// or touch each other, and solids with cavities.
126///
127/// # Errors
128///
129/// Returns [`OffsetError`] if `distance` is zero or not finite, or a
130/// topology lookup fails.
131pub fn thick_solid_by_image(
132 topo: &mut Topology,
133 solid: SolidId,
134 distance: f64,
135 exclude: &[FaceId],
136 tolerance: f64,
137) -> Result<Option<SolidId>, OffsetError> {
138 if !distance.is_finite() || distance.abs() < tolerance {
139 return Err(OffsetError::InvalidInput {
140 reason: "offset distance must be non-zero and finite".into(),
141 });
142 }
143 image::thick_solid(topo, solid, distance, exclude, tolerance)
144}
145
146/// The phased pipeline: offset faces intersected pairwise and rebuilt into
147/// loops.
148fn offset_by_intersection(
149 topo: &mut Topology,
150 solid: SolidId,
151 distance: f64,
152 exclude: &[FaceId],
153 options: OffsetOptions,
154) -> Result<SolidId, OffsetError> {
155 let mut data = OffsetData::new(distance, options, exclude.to_vec());
156
157 analyse::analyse_edges(topo, solid, &mut data)?;
158
159 offset::build_offset_faces(topo, solid, &mut data)?;
160
161 inter3d::intersect_faces_3d(topo, solid, &mut data)?;
162
163 inter2d::intersect_pcurves_2d(topo, solid, &mut data)?;
164
165 // Edge splitting (phase 5) is integrated into inter2d for now.
166
167 if data.options.joint == JointType::Arc {
168 arc_joint::build_arc_joints(topo, &mut data)?;
169 }
170
171 loops::build_wire_loops(topo, &mut data)?;
172
173 let result = assemble::assemble_solid(topo, &data)?;
174
175 if data.options.remove_self_intersections {
176 return self_int::remove_self_intersections(topo, result);
177 }
178
179 Ok(result)
180}