1use 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#[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 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 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 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 let tan_angle = angle_radians.tan();
102
103 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 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 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 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 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 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}