Skip to main content

brepkit_operations/
draft.rs

1//! Draft angle operation for injection molding applications.
2//!
3//! Applies a taper to selected faces of a solid relative to a pull direction.
4
5use std::collections::HashSet;
6
7use brepkit_math::tolerance::Tolerance;
8use brepkit_math::vec::{Point3, Vec3};
9use brepkit_topology::Topology;
10use brepkit_topology::face::{FaceId, FaceSurface};
11use brepkit_topology::solid::SolidId;
12
13use crate::boolean::{FaceSpec, assemble_solid_mixed, face_polygon};
14use crate::dot_normal_point;
15
16/// Apply a draft angle to selected faces of a solid.
17///
18/// Tapers the specified faces by `angle_radians` relative to `pull_direction`.
19/// The neutral plane is defined by `neutral_point` and `pull_direction`:
20/// vertices on the neutral plane stay fixed while vertices above/below are
21/// moved outward/inward.
22///
23/// # Errors
24///
25/// Returns an error if:
26/// - `angle_radians` is zero or negative
27/// - `pull_direction` is zero-length
28/// - Any draft face is NURBS
29/// - The solid is invalid
30#[allow(clippy::too_many_lines)]
31pub fn draft(
32    topo: &mut Topology,
33    solid: SolidId,
34    draft_faces: &[FaceId],
35    pull_direction: Vec3,
36    neutral_point: Point3,
37    angle_radians: f64,
38) -> Result<SolidId, crate::OperationsError> {
39    let tol = Tolerance::new();
40
41    if angle_radians.abs() <= tol.angular {
42        return Err(crate::OperationsError::InvalidInput {
43            reason: "draft angle must be non-zero".into(),
44        });
45    }
46
47    let pull = pull_direction.normalize()?;
48
49    // Neutral plane: passes through neutral_point with normal = pull direction.
50    let neutral_d = dot_normal_point(pull, neutral_point);
51
52    let solid_data = topo.solid(solid)?;
53    let shell = topo.shell(solid_data.outer_shell())?;
54    let all_face_ids: Vec<FaceId> = shell.faces().to_vec();
55
56    let draft_set: HashSet<usize> = draft_faces.iter().map(|f| f.index()).collect();
57
58    let mut result_specs: Vec<FaceSpec> = Vec::new();
59
60    for &fid in &all_face_ids {
61        let face = topo.face(fid)?;
62        let verts = face_polygon(topo, fid)?;
63
64        if !draft_set.contains(&fid.index()) {
65            // Non-draft face: keep as-is (supports any surface type).
66            match face.surface() {
67                FaceSurface::Plane { normal, .. } => {
68                    let d = dot_normal_point(*normal, verts[0]);
69                    result_specs.push(FaceSpec::Planar {
70                        vertices: verts,
71                        normal: *normal,
72                        d,
73                        inner_wires: vec![],
74                    });
75                }
76                other => {
77                    result_specs.push(FaceSpec::Surface {
78                        vertices: verts,
79                        surface: other.clone(),
80                        reversed: false,
81                        inner_wires: vec![],
82                    });
83                }
84            }
85            continue;
86        }
87
88        // Draft target face must be planar (vertex manipulation requires plane).
89        let _face_normal = match face.surface() {
90            FaceSurface::Plane { normal, .. } => *normal,
91            _ => {
92                return Err(crate::OperationsError::InvalidInput {
93                    reason: "draft target faces must be planar".into(),
94                });
95            }
96        };
97
98        // Draft this face: for each vertex, compute its signed height
99        // above the neutral plane, then offset it perpendicular to the
100        // pull direction by height * tan(angle).
101        let tan_angle = angle_radians.tan();
102
103        // Compute the outward direction (perpendicular to pull, in the
104        // plane of the face normal and pull direction).
105        // The offset direction for each vertex is away from the pull axis.
106        let new_verts: Vec<Point3> = verts
107            .iter()
108            .map(|&v| {
109                let height = dot_normal_point(pull, v) - neutral_d;
110                let offset = height * tan_angle;
111
112                // Project the vertex position onto the pull axis through
113                // the neutral point, then compute the radial direction.
114                let v_to_neutral = v - neutral_point;
115                let along_pull = pull * pull.dot(v_to_neutral);
116                let radial = v_to_neutral - along_pull;
117                let radial_len = radial.length();
118
119                if radial_len < tol.linear {
120                    // Vertex is on the pull axis — no radial offset.
121                    v
122                } else {
123                    let radial_dir = Vec3::new(
124                        radial.x() / radial_len,
125                        radial.y() / radial_len,
126                        radial.z() / radial_len,
127                    );
128                    v + radial_dir * offset
129                }
130            })
131            .collect();
132
133        if new_verts.len() >= 3 {
134            let a = new_verts[1] - new_verts[0];
135            let b = new_verts[2] - new_verts[0];
136            let new_normal =
137                a.cross(b)
138                    .normalize()
139                    .map_err(|_| crate::OperationsError::InvalidInput {
140                        reason: "draft produced degenerate face geometry".into(),
141                    })?;
142            let new_d = dot_normal_point(new_normal, new_verts[0]);
143            result_specs.push(FaceSpec::Planar {
144                vertices: new_verts,
145                normal: new_normal,
146                d: new_d,
147                inner_wires: vec![],
148            });
149        }
150    }
151
152    assemble_solid_mixed(topo, &result_specs, tol)
153}
154
155#[cfg(test)]
156mod tests {
157    #![allow(clippy::unwrap_used)]
158
159    use brepkit_math::tolerance::Tolerance;
160    use brepkit_math::vec::{Point3, Vec3};
161    use brepkit_topology::Topology;
162    use brepkit_topology::face::FaceSurface;
163    use brepkit_topology::test_utils::make_unit_cube_manifold;
164
165    use super::*;
166
167    /// Helper: find faces whose normal is approximately equal to `target`.
168    fn find_faces(topo: &Topology, solid: SolidId, target: Vec3) -> Vec<FaceId> {
169        let tol = Tolerance::loose();
170        let s = topo.solid(solid).unwrap();
171        let sh = topo.shell(s.outer_shell()).unwrap();
172        sh.faces()
173            .iter()
174            .filter(|&&fid| {
175                let f = topo.face(fid).unwrap();
176                if let FaceSurface::Plane { normal, .. } = f.surface() {
177                    tol.approx_eq(normal.x(), target.x())
178                        && tol.approx_eq(normal.y(), target.y())
179                        && tol.approx_eq(normal.z(), target.z())
180                } else {
181                    false
182                }
183            })
184            .copied()
185            .collect()
186    }
187
188    #[test]
189    fn draft_single_face() {
190        let mut topo = Topology::new();
191        let cube = make_unit_cube_manifold(&mut topo);
192
193        let right_faces = find_faces(&topo, cube, Vec3::new(1.0, 0.0, 0.0));
194        assert_eq!(right_faces.len(), 1);
195
196        let result = draft(
197            &mut topo,
198            cube,
199            &right_faces,
200            Vec3::new(0.0, 0.0, 1.0),
201            Point3::new(0.0, 0.0, 0.0),
202            5.0_f64.to_radians(),
203        )
204        .unwrap();
205
206        let s = topo.solid(result).unwrap();
207        let sh = topo.shell(s.outer_shell()).unwrap();
208        assert_eq!(
209            sh.faces().len(),
210            6,
211            "drafted solid should still have 6 faces"
212        );
213
214        // Volume should decrease slightly (draft tapers the face inward).
215        let vol = crate::measure::solid_volume(&topo, result, 0.1).unwrap();
216        assert!(
217            vol > 0.5,
218            "drafted solid should have significant volume, got {vol}"
219        );
220    }
221
222    #[test]
223    fn draft_preserves_non_draft_faces() {
224        let mut topo = Topology::new();
225        let cube = make_unit_cube_manifold(&mut topo);
226
227        let right_faces = find_faces(&topo, cube, Vec3::new(1.0, 0.0, 0.0));
228        let result = draft(
229            &mut topo,
230            cube,
231            &right_faces,
232            Vec3::new(0.0, 0.0, 1.0),
233            Point3::new(0.0, 0.0, 0.0),
234            5.0_f64.to_radians(),
235        )
236        .unwrap();
237
238        // The top and bottom faces should still be planar with ±Z normals.
239        let top = find_faces(&topo, result, Vec3::new(0.0, 0.0, 1.0));
240        let bottom = find_faces(&topo, result, Vec3::new(0.0, 0.0, -1.0));
241        assert_eq!(top.len(), 1, "should still have top face");
242        assert_eq!(bottom.len(), 1, "should still have bottom face");
243    }
244
245    #[test]
246    fn draft_zero_angle_error() {
247        let mut topo = Topology::new();
248        let cube = make_unit_cube_manifold(&mut topo);
249        let right = find_faces(&topo, cube, Vec3::new(1.0, 0.0, 0.0));
250
251        assert!(
252            draft(
253                &mut topo,
254                cube,
255                &right,
256                Vec3::new(0.0, 0.0, 1.0),
257                Point3::new(0.0, 0.0, 0.0),
258                0.0,
259            )
260            .is_err()
261        );
262    }
263
264    #[test]
265    fn draft_zero_pull_error() {
266        let mut topo = Topology::new();
267        let cube = make_unit_cube_manifold(&mut topo);
268        let right = find_faces(&topo, cube, Vec3::new(1.0, 0.0, 0.0));
269
270        assert!(
271            draft(
272                &mut topo,
273                cube,
274                &right,
275                Vec3::new(0.0, 0.0, 0.0),
276                Point3::new(0.0, 0.0, 0.0),
277                5.0_f64.to_radians(),
278            )
279            .is_err()
280        );
281    }
282}