1use super::*;
2
3pub fn offset_revolution_face(
17 solid: &BrepSolid,
18 face_id: u64,
19 distance: f64,
20) -> Result<BrepSolid, String> {
21 if !distance.is_finite() {
22 return Err("offset_revolution_face: distance must be finite".into());
23 }
24 let scale = solid_model_scale(solid);
25 let tolerance = (scale * 1e-7).max(1e-9);
26 let residual_tolerance = (scale * 1e-4).max(1e-6);
27 let fit_tolerance = crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit;
31 let (shell_index, face_index) = find_face(solid, face_id)
32 .ok_or_else(|| format!("offset_revolution_face: no face {face_id}"))?;
33 let face = &solid.shells[shell_index].faces[face_index];
34 let Some(AnalyticSurface::Revolution { sweep, .. }) = face.surface.analytic() else {
35 return Err("offset_revolution_face: the pushed face is not a general revolution".into());
36 };
37
38 let source_structure = crate::revolution_structure(&face.surface).ok_or_else(|| {
39 "offset_revolution_face: source carrier lost its revolution structure".to_string()
40 })?;
41 let [u0, u1] = face.surface.domain_u()?;
42 let [v0, v1] = face.surface.domain_v()?;
43 if (v0.abs() + (v1 - 1.0).abs()) > 1e-9 {
44 return Err(
45 "offset_revolution_face: a non-normalized generatrix domain is deferred".into(),
46 );
47 }
48 let u_probe = u0 + (u1 - u0) * 0.137;
53 let offsets = OffsetEvaluator::new(
58 "push_revolution",
59 &face.surface,
60 OffsetNormal::Face {
61 same_sense: face.same_sense,
62 },
63 );
64 let mut meridian_points = Vec::with_capacity(33);
65 let mut meridian_parameters = Vec::with_capacity(33);
66 for index in 0..=32 {
67 let parameter = index as f64 / 32.0;
68 let v = v0 + (v1 - v0) * parameter;
69 let target_point = offsets.at(u_probe, v, distance)?.point;
70 let relative = target_point.sub(source_structure.frame.origin);
71 let axial = relative.dot(source_structure.frame.axis);
72 let radial = relative
73 .sub(source_structure.frame.axis.scale(axial))
74 .length();
75 meridian_points.push(
76 source_structure
77 .frame
78 .origin
79 .add(source_structure.frame.x_axis.scale(radial))
80 .add(source_structure.frame.axis.scale(axial)),
81 );
82 meridian_parameters.push(parameter);
83 }
84 let offset_generatrix = crate::interpolate_curve(&meridian_points, 3, &meridian_parameters)?;
85 let offset = crate::make_revolution(
86 source_structure.frame.origin,
87 source_structure.frame.axis,
88 &offset_generatrix,
89 *sweep,
90 )?;
91 let structure = crate::revolution_structure(&offset).ok_or_else(|| {
92 "offset_revolution_face: offset carrier lost its revolution structure — refusing"
93 .to_string()
94 })?;
95 if !matches!(offset.analytic(), Some(AnalyticSurface::Revolution { .. })) {
96 return Err(
97 "offset_revolution_face: offset carrier changed analytic kind — refusing".into(),
98 );
99 }
100
101 let target = distance.abs();
104 for iu in 0..13 {
108 for iv in 0..13 {
109 let u = u0 + (u1 - u0) * (iu as f64 + 0.37) / 13.0;
110 let v = v0 + (v1 - v0) * (iv as f64 + 0.41) / 13.0;
111 let source_point = face.surface.evaluate(u, v)?;
112 let offset_point = offset.evaluate(u, v)?;
113 let delta = offset_point.sub(source_point);
114 let normal = offsets
115 .normal(u, v)
116 .map_err(|error| format!("offset_revolution_face: sample normal: {error}"))?;
117 let distance_error = (delta.length() - target).abs();
118 let normal_error = if delta.length() > tolerance {
119 1.0 - delta
120 .normalized()
121 .map_err(|error| format!("offset_revolution_face: sample delta: {error}"))?
122 .dot(normal)
123 .abs()
124 } else {
125 0.0
126 };
127 if distance_error > residual_tolerance || normal_error > 5e-4 {
128 return Err(format!(
129 "offset_revolution_face: offset fit exceeds tolerance \
130 (distance error {distance_error:.3e}, normal error {normal_error:.3e})"
131 ));
132 }
133 }
134 }
135
136 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
137 for shell in &solid.shells {
138 for candidate in &shell.faces {
139 for loop_record in &candidate.loops {
140 for coedge in &loop_record.coedges {
141 faces_of_edge
142 .entry(coedge.edge_id)
143 .or_default()
144 .push(candidate.id);
145 }
146 }
147 }
148 }
149 let edge_by_id: HashMap<u64, &EdgeRecord> =
150 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
151
152 let [g0, g1] = structure.generatrix.domain()?;
154 let mut rim_candidates = Vec::new();
155 for parameter in [g0, g1] {
156 let point = structure.generatrix.evaluate(parameter)?;
157 let relative = point.sub(structure.frame.origin);
158 let axial = relative.dot(structure.frame.axis);
159 let radial = relative.sub(structure.frame.axis.scale(axial));
160 let radius = radial.length();
161 if radius <= tolerance {
162 return Err(format!(
163 "offset_revolution_face: an offset rim collapses onto the axis \
164 (point {point:?}, frame {:?}) — refusing",
165 structure.frame
166 ));
167 }
168 rim_candidates.push(crate::make_arc(
169 structure
170 .frame
171 .origin
172 .add(structure.frame.axis.scale(axial)),
173 structure.frame.x_axis,
174 structure.frame.y_axis,
175 radius,
176 0.0,
177 structure.sweep,
178 )?);
179 }
180
181 let mut new_curves: HashMap<u64, NurbsCurve> = HashMap::default();
182 let mut new_vertices: HashMap<u64, Vec3> = HashMap::default();
183 let mut planar_neighbours: HashSet<u64> = HashSet::default();
184 for loop_record in &face.loops {
185 for coedge in &loop_record.coedges {
186 if new_curves.contains_key(&coedge.edge_id) {
187 continue;
188 }
189 let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
190 format!("offset_revolution_face: missing edge {}", coedge.edge_id)
191 })?;
192 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
193 let mut neighbour_plane = None;
197 if let Some(neighbour) = incident.iter().find(|candidate| **candidate != face_id) {
198 let (ns, nf) = find_face(solid, *neighbour).ok_or_else(|| {
199 format!("offset_revolution_face: missing neighbour {neighbour}")
200 })?;
201 match plane_of_surface(
202 &solid.shells[ns].faces[nf].surface,
203 residual_tolerance,
204 "offset_revolution_face",
205 ) {
206 Err(_) => {
207 return Err(
208 "offset_revolution_face: only planar neighbours are supported \
209 for a general revolution — refusing"
210 .into(),
211 );
212 }
213 Ok(plane) => neighbour_plane = Some(plane),
214 }
215 planar_neighbours.insert(*neighbour);
216 }
217
218 let [q0, q1] = coedge.pcurve.domain()?;
221 let uv0 = coedge.pcurve.evaluate(q0)?;
222 let uv1 = coedge.pcurve.evaluate(q1)?;
223 let uvm = coedge.pcurve.evaluate(0.5 * (q0 + q1))?;
224 let uv_tolerance = 1e-7;
225 let mut general_image = false;
226 let mut curve = if (uv0.y - uv1.y).abs() <= uv_tolerance
227 && (uv0.y - uvm.y).abs() <= uv_tolerance
228 {
229 let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
230 let mut best = rim_candidates[0].clone();
231 let mut best_distance = f64::INFINITY;
232 for candidate in &rim_candidates {
233 let [c0, c1] = candidate.domain()?;
234 let d = candidate.evaluate(0.5 * (c0 + c1))?.sub(old_mid).length();
235 if d < best_distance {
236 best_distance = d;
237 best = candidate.clone();
238 }
239 }
240 best
241 } else if (uv0.x - uv1.x).abs() <= uv_tolerance && (uv0.x - uvm.x).abs() <= uv_tolerance
242 {
243 offset.iso_curve_u(uv0.x)?
244 } else {
245 general_image = true;
252 let image = crate::image_curve(
253 &offset,
254 &coedge.pcurve,
255 fit_tolerance,
256 "offset_revolution_face",
257 )?;
258 if coedge.forward {
259 image.curve
260 } else {
261 image.curve.reversed()?
262 }
263 };
264 if general_image {
277 if let Some(plane) = neighbour_plane {
278 let [g0, g1] = curve.domain()?;
279 let mut worst = 0.0f64;
280 for index in 0..=32 {
281 let t = g0 + (g1 - g0) * index as f64 / 32.0;
282 let point = curve.evaluate(t)?;
283 worst = worst.max(point.sub(plane.origin).dot(plane.normal).abs());
284 }
285 if worst > residual_tolerance {
286 return Err(format!(
287 "offset_revolution_face: the general image of boundary edge {} \
288 leaves its fixed planar neighbour by {worst:.3e} (limit \
289 {residual_tolerance:.3e}) — re-intersecting the offset carrier \
290 with a fixed neighbour is a separate capability; refusing",
291 edge.id
292 ));
293 }
294 }
295 }
296 if !general_image {
297 let [c0, c1] = curve.domain()?;
298 let old_start = edge.curve.evaluate(edge.t0)?;
299 if curve.evaluate(c0)?.sub(old_start).length()
300 > curve.evaluate(c1)?.sub(old_start).length()
301 {
302 curve = curve.reversed()?;
303 }
304 }
305 let [d0, d1] = curve.domain()?;
306 let start = curve.evaluate(d0)?;
307 let end = curve.evaluate(d1)?;
308 new_vertices.insert(edge.start_vertex_id, start);
309 new_vertices.insert(edge.end_vertex_id, end);
310 new_curves.insert(edge.id, curve);
311 }
312 }
313
314 let mut result = solid.clone();
315 result.shells[shell_index].faces[face_index].surface = offset;
316 for edge in &mut result.edges {
317 if let Some(curve) = new_curves.get(&edge.id) {
318 let [d0, d1] = curve.domain()?;
319 edge.curve = curve.clone();
320 edge.t0 = d0;
321 edge.t1 = d1;
322 }
323 }
324 for vertex in &mut result.vertices {
325 if let Some(point) = new_vertices.get(&vertex.id) {
326 vertex.point = *point;
327 }
328 }
329 let result_vertices: HashMap<u64, Vec3> = result
333 .vertices
334 .iter()
335 .map(|vertex| (vertex.id, vertex.point))
336 .collect();
337 let mut cap_seams = HashSet::default();
338 for neighbour in &planar_neighbours {
339 let (ns, nf) = find_face(&result, *neighbour)
340 .ok_or_else(|| format!("offset_revolution_face: missing cap {neighbour}"))?;
341 for loop_record in &result.shells[ns].faces[nf].loops {
342 for coedge in &loop_record.coedges {
343 let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
344 format!(
345 "offset_revolution_face: missing cap edge {}",
346 coedge.edge_id
347 )
348 })?;
349 let touches_moved = new_vertices.contains_key(&edge.start_vertex_id)
350 || new_vertices.contains_key(&edge.end_vertex_id);
351 if touches_moved
352 && !new_curves.contains_key(&edge.id)
353 && !edge.degenerate
354 && edge.curve.degree == 1
355 {
356 cap_seams.insert(edge.id);
357 }
358 }
359 }
360 }
361 for seam_id in cap_seams {
362 let source = edge_by_id[&seam_id];
363 let curve = make_line(
364 result_vertices[&source.start_vertex_id],
365 result_vertices[&source.end_vertex_id],
366 )?;
367 let [d0, d1] = curve.domain()?;
368 if let Some(edge) = result.edges.iter_mut().find(|edge| edge.id == seam_id) {
369 edge.curve = curve;
370 edge.t0 = d0;
371 edge.t1 = d1;
372 }
373 }
374 let final_edges: HashMap<u64, EdgeRecord> = result
375 .edges
376 .iter()
377 .map(|edge| (edge.id, edge.clone()))
378 .collect();
379 for neighbour in planar_neighbours {
380 let (ns, nf) = find_face(&result, neighbour)
381 .ok_or_else(|| format!("offset_revolution_face: missing cap {neighbour}"))?;
382 let plane = plane_of_surface(
383 &result.shells[ns].faces[nf].surface,
384 residual_tolerance,
385 "offset_revolution_face",
386 )?;
387 retrim_planar_face(
388 &mut result.shells[ns].faces[nf],
389 &plane,
390 &final_edges,
391 scale,
392 "offset_revolution_face",
393 )
394 .map_err(|error| format!("offset_revolution_face: cap retrim: {error}"))?;
395 }
396 let issues = result.validate();
397 if !issues.is_empty() {
398 return Err(format!(
399 "offset_revolution_face: pushed solid failed validation: {issues:?}"
400 ));
401 }
402 if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
403 if before * after <= 0.0 {
404 return Err("offset_revolution_face: the push inverts the solid — refusing".into());
405 }
406 }
407 Ok(result)
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 fn curved_barrel() -> BrepSolid {
415 let side = NurbsCurve::new(
416 2,
417 vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
418 vec![
419 crate::Vec4::from_point(Vec3::new(2.0, 0.0, 0.0), 1.0),
420 crate::Vec4::from_point(Vec3::new(3.4, 2.0, 0.0), 1.0),
421 crate::Vec4::from_point(Vec3::new(2.5, 4.0, 0.0), 1.0),
422 ],
423 )
424 .unwrap();
425 let profile = vec![
426 crate::make_line(Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0)).unwrap(),
427 side,
428 crate::make_line(Vec3::new(2.5, 4.0, 0.0), Vec3::new(0.0, 4.0, 0.0)).unwrap(),
429 crate::make_line(Vec3::new(0.0, 4.0, 0.0), Vec3::new(0.0, 0.0, 0.0)).unwrap(),
430 ];
431 crate::revolve_profile_brep(
432 &profile,
433 Vec3::default(),
434 Vec3::new(0.0, 1.0, 0.0),
435 std::f64::consts::TAU,
436 )
437 .unwrap()
438 }
439
440 fn revolution_face(solid: &BrepSolid) -> u64 {
441 solid
442 .shells
443 .iter()
444 .flat_map(|shell| &shell.faces)
445 .find(|face| {
446 matches!(
447 face.surface.analytic(),
448 Some(AnalyticSurface::Revolution { generatrix, .. }) if generatrix.degree == 2
449 )
450 })
451 .expect("general revolution face")
452 .id
453 }
454
455 #[test]
456 fn push_full_general_revolution_retrims_planar_caps() {
457 let barrel = curved_barrel();
458 let before = solid_signed_volume(&barrel).unwrap().abs();
459 for distance in [0.15_f64, -0.15] {
460 let pushed = offset_revolution_face(&barrel, revolution_face(&barrel), distance)
461 .unwrap_or_else(|error| panic!("push revolution by {distance}: {error}"));
462 let issues = pushed.validate();
463 assert!(issues.is_empty(), "validate {distance}: {issues:?}");
464 let after = solid_signed_volume(&pushed).unwrap().abs();
465 if distance > 0.0 {
466 assert!(after > before, "outward push should grow volume");
467 } else {
468 assert!(after < before, "inward push should shrink volume");
469 }
470 }
471 }
472
473 #[test]
474 fn push_partial_general_revolution_retrims_radial_caps() {
475 let side = NurbsCurve::new(
476 2,
477 vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
478 vec![
479 crate::Vec4::from_point(Vec3::new(2.0, 0.0, 0.0), 1.0),
480 crate::Vec4::from_point(Vec3::new(3.0, 2.0, 0.0), 1.0),
481 crate::Vec4::from_point(Vec3::new(2.5, 4.0, 0.0), 1.0),
482 ],
483 )
484 .unwrap();
485 let profile = vec![
486 crate::make_line(Vec3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0)).unwrap(),
487 side,
488 crate::make_line(Vec3::new(2.5, 4.0, 0.0), Vec3::new(0.0, 4.0, 0.0)).unwrap(),
489 crate::make_line(Vec3::new(0.0, 4.0, 0.0), Vec3::new(0.0, 0.0, 0.0)).unwrap(),
490 ];
491 let partial = crate::revolve_profile_brep(
492 &profile,
493 Vec3::default(),
494 Vec3::new(0.0, 1.0, 0.0),
495 std::f64::consts::PI,
496 )
497 .unwrap();
498 let face = revolution_face(&partial);
499 let before = solid_signed_volume(&partial).unwrap().abs();
500 let pushed = offset_revolution_face(&partial, face, 0.1)
501 .unwrap_or_else(|error| panic!("push partial revolution: {error}"));
502 let issues = pushed.validate();
503 assert!(issues.is_empty(), "partial validate: {issues:?}");
504 assert!(solid_signed_volume(&pushed).unwrap().abs() > before);
505 }
506}