brep_kernel/edit/direct_edit/
face_offset_torus.rs1use super::*;
2
3pub fn offset_torus_face(
18 solid: &BrepSolid,
19 face_id: u64,
20 distance: f64,
21) -> Result<BrepSolid, String> {
22 if !distance.is_finite() {
23 return Err("offset_torus_face: distance must be finite".into());
24 }
25 let scale = solid_model_scale(solid);
26 let tolerance = (scale * 1e-7).max(1e-9);
27 let fit_tolerance = crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit;
30 let (shell_index, face_index) =
31 find_face(solid, face_id).ok_or_else(|| format!("offset_torus_face: no face {face_id}"))?;
32 let face = &solid.shells[shell_index].faces[face_index];
33 let Some(AnalyticSurface::Torus {
34 frame,
35 major_radius,
36 minor_radius,
37 }) = face.surface.analytic()
38 else {
39 return Err("offset_torus_face: the pushed face is not a torus".into());
40 };
41
42 let [u0, u1] = face.surface.domain_u()?;
46 let [v0, v1] = face.surface.domain_v()?;
47 let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
48 let point = face.surface.evaluate(um, vm)?;
49 let mut normal = face.surface.normal(um, vm)?;
50 if !face.same_sense {
51 normal = normal.scale(-1.0);
52 }
53 let relative = point.sub(frame.origin);
54 let axial = relative.dot(frame.axis);
55 let radial_direction = relative.sub(frame.axis.scale(axial)).normalized()?;
56 let tube_center = frame
57 .origin
58 .add(frame.axis.scale(axial))
59 .add(radial_direction.scale(*major_radius));
60 let tube_radial = point.sub(tube_center);
61 let outward_sign = if normal.dot(tube_radial) >= 0.0 {
62 1.0
63 } else {
64 -1.0
65 };
66 let minor_new = *minor_radius + distance * outward_sign;
67 if minor_new <= tolerance {
68 return Err("offset_torus_face: the push collapses the tube radius — refusing".into());
69 }
70 if minor_new >= *major_radius - tolerance {
71 return Err("offset_torus_face: the push creates a horn/spindle torus — refusing".into());
72 }
73
74 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
77 for shell in &solid.shells {
78 for candidate in &shell.faces {
79 for loop_record in &candidate.loops {
80 for coedge in &loop_record.coedges {
81 faces_of_edge
82 .entry(coedge.edge_id)
83 .or_default()
84 .push(candidate.id);
85 }
86 }
87 }
88 }
89 for loop_record in &face.loops {
90 for coedge in &loop_record.coedges {
91 let incident = faces_of_edge
92 .get(&coedge.edge_id)
93 .cloned()
94 .unwrap_or_default();
95 if incident.iter().any(|candidate| *candidate != face_id) {
96 return Err(
97 "offset_torus_face: a neighbour-trimmed torus is deferred; only the \
98 full torus is supported in this slice"
99 .into(),
100 );
101 }
102 }
103 }
104
105 let offset = crate::offset_surface(face, -distance, 0.0)?;
109 match offset.analytic() {
110 Some(AnalyticSurface::Torus {
111 frame: out_frame,
112 major_radius: out_major,
113 minor_radius: out_minor,
114 }) if out_frame.origin.sub(frame.origin).length() <= tolerance
115 && out_frame.axis.dot(frame.axis).abs() >= 1.0 - 1e-9
116 && (*out_major - *major_radius).abs() <= tolerance
117 && (*out_minor - minor_new).abs() <= tolerance => {}
118 other => {
119 return Err(format!(
120 "offset_torus_face: offset carrier was not the expected exact torus: {other:?}"
121 ))
122 }
123 }
124
125 let edge_by_id: HashMap<u64, &EdgeRecord> =
126 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
127 let mut rebuilt_edges: HashMap<u64, (NurbsCurve, f64, f64)> = HashMap::default();
128 let mut rebuilt_vertices: HashMap<u64, Vec3> = HashMap::default();
129 for loop_record in &face.loops {
130 for coedge in &loop_record.coedges {
131 if rebuilt_edges.contains_key(&coedge.edge_id) {
132 continue;
133 }
134 let edge = *edge_by_id
135 .get(&coedge.edge_id)
136 .ok_or_else(|| format!("offset_torus_face: missing edge {}", coedge.edge_id))?;
137 let [q0, q1] = coedge.pcurve.domain()?;
138 let a = coedge.pcurve.evaluate(q0)?;
139 let b = coedge.pcurve.evaluate(q1)?;
140 let m = coedge.pcurve.evaluate(0.5 * (q0 + q1))?;
141 let uv_tolerance = 1e-8;
142 let iso = if (a.x - b.x).abs() <= uv_tolerance && (a.x - m.x).abs() <= uv_tolerance {
146 Some((offset.iso_curve_u(a.x)?, a.y, b.y))
147 } else if (a.y - b.y).abs() <= uv_tolerance && (a.y - m.y).abs() <= uv_tolerance {
148 Some((offset.iso_curve_v(a.y)?, a.x, b.x))
149 } else {
150 None
151 };
152 let (base, mut start, mut end) = match iso {
156 Some(triple) => triple,
157 None => {
158 let image = crate::image_curve(
159 &offset,
160 &coedge.pcurve,
161 fit_tolerance,
162 "offset_torus_face",
163 )?;
164 (image.curve, image.t0, image.t1)
165 }
166 };
167 if !coedge.forward {
168 std::mem::swap(&mut start, &mut end);
169 }
170 let [d0, d1] = base.domain()?;
171 let (curve, t0, t1) = if start <= end {
172 (base, start, end)
173 } else {
174 (base.reversed()?, d0 + d1 - start, d0 + d1 - end)
175 };
176 let start_point = curve.evaluate(t0)?;
177 let end_point = curve.evaluate(t1)?;
178 for (vertex_id, vertex_point) in [
179 (edge.start_vertex_id, start_point),
180 (edge.end_vertex_id, end_point),
181 ] {
182 if let Some(known) = rebuilt_vertices.get(&vertex_id) {
183 if known.sub(vertex_point).length() > tolerance {
184 return Err(format!(
185 "offset_torus_face: periodic seams disagree at vertex {vertex_id}"
186 ));
187 }
188 } else {
189 rebuilt_vertices.insert(vertex_id, vertex_point);
190 }
191 }
192 rebuilt_edges.insert(edge.id, (curve, t0, t1));
193 }
194 }
195
196 let mut result = solid.clone();
197 result.shells[shell_index].faces[face_index].surface = offset;
198 for edge in &mut result.edges {
199 if let Some((curve, t0, t1)) = rebuilt_edges.get(&edge.id) {
200 edge.curve = curve.clone();
201 edge.t0 = *t0;
202 edge.t1 = *t1;
203 }
204 }
205 for vertex in &mut result.vertices {
206 if let Some(point) = rebuilt_vertices.get(&vertex.id) {
207 vertex.point = *point;
208 }
209 }
210 let issues = result.validate();
211 if !issues.is_empty() {
212 return Err(format!(
213 "offset_torus_face: pushed solid failed validation: {issues:?}"
214 ));
215 }
216 if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
217 if before * after <= 0.0 {
218 return Err("offset_torus_face: the push inverts the solid — refusing".into());
219 }
220 }
221 Ok(result)
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 fn torus_face(solid: &BrepSolid) -> u64 {
229 solid
230 .shells
231 .iter()
232 .flat_map(|shell| &shell.faces)
233 .find(|face| matches!(face.surface.analytic(), Some(AnalyticSurface::Torus { .. })))
234 .expect("torus face")
235 .id
236 }
237
238 fn torus_radii(solid: &BrepSolid) -> (f64, f64) {
239 solid
240 .shells
241 .iter()
242 .flat_map(|shell| &shell.faces)
243 .find_map(|face| match face.surface.analytic() {
244 Some(AnalyticSurface::Torus {
245 major_radius,
246 minor_radius,
247 ..
248 }) => Some((*major_radius, *minor_radius)),
249 _ => None,
250 })
251 .expect("torus radii")
252 }
253
254 #[test]
255 fn push_full_torus_changes_minor_radius_exactly() {
256 for (distance, expected_minor) in [(0.75_f64, 2.75_f64), (-0.75, 1.25)] {
257 let torus = crate::make_torus_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 6.0, 2.0)
258 .unwrap();
259 let pushed = offset_torus_face(&torus, torus_face(&torus), distance)
260 .unwrap_or_else(|error| panic!("push torus by {distance}: {error}"));
261 assert!(pushed.validate().is_empty());
262 let (major, minor) = torus_radii(&pushed);
263 assert!((major - 6.0).abs() < 1e-8);
264 assert!((minor - expected_minor).abs() < 1e-8);
265 let expected_volume =
266 2.0 * std::f64::consts::PI.powi(2) * major * expected_minor.powi(2);
267 let volume = solid_signed_volume(&pushed).unwrap().abs();
268 assert!((volume - expected_volume).abs() < 1e-2);
269 }
270 }
271
272 #[test]
273 fn push_full_torus_collapse_and_spindle_refuse() {
274 let torus =
275 crate::make_torus_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 6.0, 2.0).unwrap();
276 let face = torus_face(&torus);
277 let collapse = offset_torus_face(&torus, face, -2.0).expect_err("collapse must refuse");
278 assert!(collapse.contains("collapse"));
279 let spindle = offset_torus_face(&torus, face, 4.0).expect_err("spindle must refuse");
280 assert!(spindle.contains("horn/spindle"));
281 }
282
283 #[test]
284 fn push_toroidal_cavity_positive_shrinks_void() {
285 let block = crate::make_box_brep(Vec3::new(-10.0, -10.0, -5.0), 20.0, 20.0, 10.0).unwrap();
286 let tool =
287 crate::make_torus_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 4.0, 1.5).unwrap();
288 let cavity = crate::boolean_operation(
289 &block,
290 &tool,
291 crate::BooleanOperation::Subtract,
292 &crate::BooleanOptions::default(),
293 )
294 .unwrap();
295 let before = solid_signed_volume(&cavity).unwrap().abs();
296 let pushed = offset_torus_face(&cavity, torus_face(&cavity), 0.25)
297 .unwrap_or_else(|error| panic!("push torus cavity: {error}"));
298 assert!(pushed.validate().is_empty());
299 let (_, minor) = torus_radii(&pushed);
300 assert!((minor - 1.25).abs() < 1e-8, "cavity minor radius {minor}");
301 assert!(solid_signed_volume(&pushed).unwrap().abs() > before);
302 }
303}