1use super::*;
2
3pub fn offset_revolution_face(
29 solid: &BrepSolid,
30 face_id: u64,
31 distance: f64,
32) -> Result<BrepSolid, String> {
33 if !distance.is_finite() {
34 return Err("offset_revolution_face: distance must be finite".into());
35 }
36 let scale = solid_model_scale(solid);
37 let tolerance = (scale * 1e-7).max(1e-9);
38 let residual_tolerance = (scale * 1e-4).max(1e-6);
39 let fit_tolerance = crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit;
43 let (shell_index, face_index) = find_face(solid, face_id)
44 .ok_or_else(|| format!("offset_revolution_face: no face {face_id}"))?;
45 let face = &solid.shells[shell_index].faces[face_index];
46 if !matches!(face.surface.analytic(), Some(AnalyticSurface::Revolution { .. })) {
47 return Err("offset_revolution_face: the pushed face is not a general revolution".into());
48 }
49 if let Some(result) = offset_partial_cylinder_face(
50 solid,
51 face_id,
52 distance,
53 tolerance,
54 residual_tolerance,
55 scale,
56 )? {
57 return Ok(result);
58 }
59 let trace = BoundaryTrace::open(solid, face_id, distance);
60 let outcome = offset_general_revolution_face(
61 solid,
62 face_id,
63 distance,
64 tolerance,
65 residual_tolerance,
66 fit_tolerance,
67 scale,
68 &trace,
69 );
70 trace.outcome(&outcome);
71 outcome
72}
73
74#[allow(clippy::too_many_arguments)]
91fn offset_general_revolution_face(
92 solid: &BrepSolid,
93 face_id: u64,
94 distance: f64,
95 tolerance: f64,
96 residual_tolerance: f64,
97 fit_tolerance: f64,
98 scale: f64,
99 trace: &BoundaryTrace,
100) -> Result<BrepSolid, String> {
101 let (shell_index, face_index) = find_face(solid, face_id)
102 .ok_or_else(|| format!("offset_revolution_face: no face {face_id}"))?;
103 let face = &solid.shells[shell_index].faces[face_index];
104 let Some(AnalyticSurface::Revolution { sweep, .. }) = face.surface.analytic() else {
105 return Err("offset_revolution_face: the pushed face is not a general revolution".into());
106 };
107 let source_structure = crate::revolution_structure(&face.surface).ok_or_else(|| {
108 "offset_revolution_face: source carrier lost its revolution structure".to_string()
109 })?;
110 let [u0, u1] = face.surface.domain_u()?;
111 let [v0, v1] = face.surface.domain_v()?;
112 if (v0.abs() + (v1 - 1.0).abs()) > 1e-9 {
113 return Err(
114 "offset_revolution_face: a non-normalized generatrix domain is deferred".into(),
115 );
116 }
117 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
118 for shell in &solid.shells {
119 for candidate in &shell.faces {
120 for loop_record in &candidate.loops {
121 for coedge in &loop_record.coedges {
122 faces_of_edge
123 .entry(coedge.edge_id)
124 .or_default()
125 .push(candidate.id);
126 }
127 }
128 }
129 }
130 let edge_by_id: HashMap<u64, &EdgeRecord> =
131 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
132 let u_probe = u0 + (u1 - u0) * 0.137;
137 let span = solve_rim_span(
138 solid,
139 face,
140 face_id,
141 &faces_of_edge,
142 &source_structure.frame,
143 u_probe,
144 distance,
145 residual_tolerance,
146 )?;
147 let source_surface = span.surface.as_ref().unwrap_or(&face.surface);
148 let offsets = OffsetEvaluator::new(
153 "push_revolution",
154 source_surface,
155 OffsetNormal::Face {
156 same_sense: face.same_sense,
157 },
158 );
159 let [s0, s1] = span.new;
162 let source_parameter = |w: f64| s0 + (s1 - s0) * w;
163 let mut meridian_points = Vec::with_capacity(33);
164 let mut meridian_parameters = Vec::with_capacity(33);
165 for index in 0..=32 {
166 let parameter = index as f64 / 32.0;
167 let v = source_parameter(v0 + (v1 - v0) * parameter);
168 let target_point = offsets.at(u_probe, v, distance)?.point;
169 let relative = target_point.sub(source_structure.frame.origin);
170 let axial = relative.dot(source_structure.frame.axis);
171 let radial = relative
172 .sub(source_structure.frame.axis.scale(axial))
173 .length();
174 meridian_points.push(
175 source_structure
176 .frame
177 .origin
178 .add(source_structure.frame.x_axis.scale(radial))
179 .add(source_structure.frame.axis.scale(axial)),
180 );
181 meridian_parameters.push(parameter);
182 }
183 let offset_generatrix = crate::interpolate_curve(&meridian_points, 3, &meridian_parameters)?;
184 let offset = crate::make_revolution(
185 source_structure.frame.origin,
186 source_structure.frame.axis,
187 &offset_generatrix,
188 *sweep,
189 )?;
190 let structure = crate::revolution_structure(&offset).ok_or_else(|| {
191 "offset_revolution_face: offset carrier lost its revolution structure — refusing"
192 .to_string()
193 })?;
194 if !matches!(offset.analytic(), Some(AnalyticSurface::Revolution { .. })) {
195 return Err(
196 "offset_revolution_face: offset carrier changed analytic kind — refusing".into(),
197 );
198 }
199
200 let target = distance.abs();
203 let (mut worst_distance, mut worst_normal) = (0.0f64, 0.0f64);
204 for iu in 0..13 {
208 for iv in 0..13 {
209 let u = u0 + (u1 - u0) * (iu as f64 + 0.37) / 13.0;
210 let w = v0 + (v1 - v0) * (iv as f64 + 0.41) / 13.0;
211 let v = source_parameter(w);
212 let source_point = source_surface.evaluate(u, v)?;
213 let offset_point = offset.evaluate(u, w)?;
214 let delta = offset_point.sub(source_point);
215 let normal = offsets
216 .normal(u, v)
217 .map_err(|error| format!("offset_revolution_face: sample normal: {error}"))?;
218 let distance_error = (delta.length() - target).abs();
219 let normal_error = if delta.length() > tolerance {
220 1.0 - delta
221 .normalized()
222 .map_err(|error| format!("offset_revolution_face: sample delta: {error}"))?
223 .dot(normal)
224 .abs()
225 } else {
226 0.0
227 };
228 if distance_error > residual_tolerance || normal_error > 5e-4 {
229 return Err(format!(
230 "offset_revolution_face: offset fit exceeds tolerance \
231 (distance error {distance_error:.3e}, normal error {normal_error:.3e})"
232 ));
233 }
234 worst_distance = worst_distance.max(distance_error);
235 worst_normal = worst_normal.max(normal_error);
236 }
237 }
238 trace.write(format!(
239 "carrier\trims_old={:?}\trims_new={:?}\textended={}\t\
240 fit_distance_error={worst_distance:.3e}\tfit_normal_error={worst_normal:.3e}",
241 span.old,
242 span.new,
243 span.surface.is_some()
244 ));
245
246 let [g0, g1] = structure.generatrix.domain()?;
249 let mut rim_candidates = Vec::new();
250 for parameter in [g0, g1] {
251 let point = structure.generatrix.evaluate(parameter)?;
252 let relative = point.sub(structure.frame.origin);
253 let axial = relative.dot(structure.frame.axis);
254 let radial = relative.sub(structure.frame.axis.scale(axial));
255 let radius = radial.length();
256 if radius <= tolerance {
257 return Err(format!(
258 "offset_revolution_face: an offset rim collapses onto the axis \
259 (point {point:?}, frame {:?}) — refusing",
260 structure.frame
261 ));
262 }
263 rim_candidates.push(crate::make_arc(
264 structure
265 .frame
266 .origin
267 .add(structure.frame.axis.scale(axial)),
268 structure.frame.x_axis,
269 structure.frame.y_axis,
270 radius,
271 0.0,
272 structure.sweep,
273 )?);
274 }
275
276 let uv_tolerance = 1e-7;
282 let pcurves = face
283 .loops
284 .iter()
285 .map(|loop_record| {
286 loop_record
287 .coedges
288 .iter()
289 .map(|coedge| match iso_kind(&coedge.pcurve, uv_tolerance)? {
290 Some(_) => span.carry_pcurve(&coedge.pcurve),
291 None => span.transfer_pcurve(&coedge.pcurve),
292 })
293 .collect::<Result<Vec<_>, String>>()
294 })
295 .collect::<Result<Vec<_>, String>>()?;
296
297 let floor = crate::pcurve::PCURVE_REFINEMENT_TOLERANCE;
298 let mut new_curves: HashMap<u64, NurbsCurve> = HashMap::default();
299 let mut new_vertices: HashMap<u64, Vec3> = HashMap::default();
300 let mut planar_neighbours: HashSet<u64> = HashSet::default();
301 for (loop_record, loop_pcurves) in face.loops.iter().zip(&pcurves) {
302 for (coedge, pcurve) in loop_record.coedges.iter().zip(loop_pcurves) {
303 if new_curves.contains_key(&coedge.edge_id) {
304 continue;
305 }
306 let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
307 format!("offset_revolution_face: missing edge {}", coedge.edge_id)
308 })?;
309 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
310 let neighbour = incident.iter().find(|candidate| **candidate != face_id);
311 let mut neighbour_plane = None;
315 if let Some(neighbour) = neighbour {
316 let (ns, nf) = find_face(solid, *neighbour).ok_or_else(|| {
317 format!("offset_revolution_face: missing neighbour {neighbour}")
318 })?;
319 match plane_of_surface(
320 &solid.shells[ns].faces[nf].surface,
321 residual_tolerance,
322 "offset_revolution_face",
323 ) {
324 Err(_) => {
325 return Err(
326 "offset_revolution_face: only planar neighbours are supported \
327 for a general revolution — refusing"
328 .into(),
329 );
330 }
331 Ok(plane) => neighbour_plane = Some(plane),
332 }
333 planar_neighbours.insert(*neighbour);
334 }
335
336 let mut general_image = false;
339 let mut meridian = false;
340 let mut curve = match iso_kind(&coedge.pcurve, uv_tolerance)? {
341 Some(IsoKind::ConstantV) => {
342 let [q0, _] = pcurve.domain()?;
343 let w = pcurve.evaluate(q0)?.y;
344 if w.abs() <= uv_tolerance {
345 rim_candidates[0].clone()
346 } else if (w - 1.0).abs() <= uv_tolerance {
347 rim_candidates[1].clone()
348 } else {
349 return Err(format!(
350 "offset_revolution_face: constant-v boundary edge {} sits at v = {w} \
351 between the face's two rims — refusing",
352 edge.id
353 ));
354 }
355 }
356 Some(IsoKind::ConstantU(u)) => {
357 meridian = true;
358 offset.iso_curve_u(u)?
359 }
360 None => {
361 general_image = true;
368 let image = crate::image_curve(
369 &offset,
370 pcurve,
371 fit_tolerance,
372 "offset_revolution_face",
373 )?;
374 if coedge.forward {
375 image.curve
376 } else {
377 image.curve.reversed()?
378 }
379 }
380 };
381 let kind = if general_image {
382 "general"
383 } else if meridian {
384 "meridian"
385 } else {
386 "rim"
387 };
388 trace.edge(kind, edge, neighbour, neighbour_plane.as_ref(), &curve);
389 if let Some(plane) = &neighbour_plane {
408 let [g0, g1] = curve.domain()?;
409 let mut worst = 0.0f64;
410 for index in 0..=32 {
411 let t = g0 + (g1 - g0) * index as f64 / 32.0;
412 let point = curve.evaluate(t)?;
413 worst = worst.max(point.sub(plane.origin).dot(plane.normal).abs());
414 }
415 if !(general_image || meridian) && !(worst <= floor) {
416 return Err(format!(
417 "offset_revolution_face: the rebuilt rim of boundary edge {} leaves the \
418 plane of neighbour face {} by {worst:.3e} (pcurve floor {floor:.0e}) — \
419 refusing",
420 edge.id,
421 neighbour.copied().unwrap_or_default()
422 ));
423 }
424 if (general_image || meridian) && worst > residual_tolerance {
425 let lane = if meridian {
426 "meridian at its old azimuth"
427 } else {
428 "general image"
429 };
430 return Err(format!(
431 "offset_revolution_face: the {lane} of boundary edge {} \
432 leaves its fixed planar neighbour by {worst:.3e} (limit \
433 {residual_tolerance:.3e}) — re-intersecting the offset carrier \
434 with a fixed neighbour is a separate capability; refusing",
435 edge.id
436 ));
437 }
438 }
439 if !general_image {
440 let [c0, c1] = curve.domain()?;
441 let old_start = edge.curve.evaluate(edge.t0)?;
442 if curve.evaluate(c0)?.sub(old_start).length()
443 > curve.evaluate(c1)?.sub(old_start).length()
444 {
445 curve = curve.reversed()?;
446 }
447 }
448 let [d0, d1] = curve.domain()?;
453 let ends = [
454 (edge.start_vertex_id, curve.evaluate(d0)?),
455 (edge.end_vertex_id, curve.evaluate(d1)?),
456 ];
457 for (vertex_id, point) in ends {
458 if let Some(previous) = new_vertices.insert(vertex_id, point) {
459 let gap = previous.sub(point).length();
460 if !(gap <= floor) {
461 return Err(format!(
462 "offset_revolution_face: boundary edge {} rebuilds vertex {vertex_id} \
463 {gap:.3e} from where another boundary edge put it (pcurve floor \
464 {floor:.0e}) — refusing",
465 edge.id
466 ));
467 }
468 }
469 }
470 new_curves.insert(edge.id, curve);
471 }
472 }
473
474 let mut result = solid.clone();
475 let pushed = &mut result.shells[shell_index].faces[face_index];
476 if span.old != [0.0, 1.0] || span.new != [0.0, 1.0] {
477 for (loop_record, loop_pcurves) in pushed.loops.iter_mut().zip(pcurves) {
478 for (coedge, pcurve) in loop_record.coedges.iter_mut().zip(loop_pcurves) {
479 coedge.pcurve = pcurve;
480 }
481 }
482 }
483 pushed.surface = offset;
484 retrim_planar_neighbours(
485 solid,
486 result,
487 &edge_by_id,
488 &new_curves,
489 &new_vertices,
490 planar_neighbours,
491 residual_tolerance,
492 scale,
493 )
494}
495
496struct RimSpan {
498 old: [f64; 2],
501 new: [f64; 2],
504 surface: Option<NurbsSurface>,
507}
508
509impl RimSpan {
510 fn carry_pcurve(&self, pcurve: &NurbsCurve) -> Result<NurbsCurve, String> {
513 Self::map_v(pcurve, self.old)
514 }
515
516 fn transfer_pcurve(&self, pcurve: &NurbsCurve) -> Result<NurbsCurve, String> {
519 Self::map_v(pcurve, self.new)
520 }
521
522 fn map_v(pcurve: &NurbsCurve, [a0, a1]: [f64; 2]) -> Result<NurbsCurve, String> {
525 if [a0, a1] == [0.0, 1.0] {
526 return Ok(pcurve.clone());
527 }
528 let mut carried = pcurve.clone();
529 for control in &mut carried.control_points {
530 let point = control.point()?;
531 *control = crate::Vec4::from_point(
532 Vec3::new(point.x, (point.y - a0) / (a1 - a0), point.z),
533 control.w,
534 );
535 }
536 Ok(carried)
537 }
538}
539
540#[allow(clippy::too_many_arguments)]
554fn solve_rim_span(
555 solid: &BrepSolid,
556 face: &FaceRecord,
557 face_id: u64,
558 faces_of_edge: &HashMap<u64, Vec<u64>>,
559 frame: &crate::RevolutionFrame,
560 u: f64,
561 distance: f64,
562 residual_tolerance: f64,
563) -> Result<RimSpan, String> {
564 let op = "offset_revolution_face";
565 let uv_tolerance = 1e-7;
566 let mut rims: Vec<(f64, f64, u64)> = Vec::new();
569 let (mut v_min, mut v_max) = (f64::INFINITY, f64::NEG_INFINITY);
570 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
571 let [q0, q1] = coedge.pcurve.domain()?;
572 let uv0 = coedge.pcurve.evaluate(q0)?;
573 let uv1 = coedge.pcurve.evaluate(q1)?;
574 let uvm = coedge.pcurve.evaluate(0.5 * (q0 + q1))?;
575 for uv in [uv0, uv1, uvm] {
576 v_min = v_min.min(uv.y);
577 v_max = v_max.max(uv.y);
578 }
579 if !matches!(iso_kind(&coedge.pcurve, uv_tolerance)?, Some(IsoKind::ConstantV)) {
580 continue;
581 }
582 let incident = faces_of_edge.get(&coedge.edge_id).cloned().unwrap_or_default();
583 let Some(neighbour) = incident.iter().find(|candidate| **candidate != face_id) else {
584 continue;
585 };
586 let (ns, nf) = find_face(solid, *neighbour)
587 .ok_or_else(|| format!("{op}: missing neighbour {neighbour}"))?;
588 let Ok(plane) =
590 plane_of_surface(&solid.shells[ns].faces[nf].surface, residual_tolerance, op)
591 else {
592 continue;
593 };
594 let station =
596 plane.origin.sub(frame.origin).dot(plane.normal) / frame.axis.dot(plane.normal);
597 rims.push((uv0.y, station, *neighbour));
598 }
599 if rims.is_empty() {
600 return Ok(RimSpan {
601 old: [0.0, 1.0],
602 new: [0.0, 1.0],
603 surface: None,
604 });
605 }
606 let mut ends: [Option<(f64, f64, u64)>; 2] = [None, None];
609 for rim in &rims {
610 let slot = if (rim.0 - v_min).abs() <= (rim.0 - v_max).abs() { 0 } else { 1 };
611 match ends[slot] {
612 None => ends[slot] = Some(*rim),
613 Some(first) if (first.0 - rim.0).abs() > uv_tolerance => {
614 return Err(format!(
615 "{op}: face {face_id} has constant-v boundaries at v = {} and v = {} on one \
616 side of its trim — refusing",
617 first.0, rim.0
618 ));
619 }
620 Some(_) => {}
621 }
622 }
623
624 let same_sense = face.same_sense;
625 let mut surface: Option<NurbsSurface> = None;
626 let mut old = [0.0, 1.0];
627 let mut new = [0.0, 1.0];
628 for (slot, end) in ends.iter().enumerate() {
629 let Some((start, station, neighbour)) = *end else {
630 continue;
631 };
632 old[slot] = start;
633 let axial_miss = |carrier: &NurbsSurface, s: f64| -> Result<f64, String> {
634 let convention = OffsetNormal::Face { same_sense };
635 let point = OffsetEvaluator::new("push_revolution", carrier, convention)
636 .at(u, s, distance)?
637 .point;
638 Ok(point.sub(frame.origin).dot(frame.axis) - station)
639 };
640 let base = surface.clone().unwrap_or_else(|| face.surface.clone());
641 let drift = axial_miss(&base, start)?;
642 if drift == 0.0 {
643 new[slot] = start;
644 continue;
645 }
646 let rate = face.surface.derivatives(u, start, 1)?[0][1].dot(frame.axis);
647 let step = -drift / rate;
648 if !step.is_finite() {
649 return Err(format!(
653 "{op}: the generatrix of face {face_id} meets the plane of neighbour face \
654 {neighbour} tangentially at its rim, which the push leaves {drift:.3e} off \
655 that plane — refusing"
656 ));
657 }
658 let mut bracket = None;
659 let mut reach = 2.0 * step;
660 for _ in 0..4 {
661 if !reach.is_finite() {
662 break;
663 }
664 let far = start + reach;
665 let carrier = extend_to(&base, far)
666 .map_err(|refusal| {
667 format!(
668 "{op}: the rim of face {face_id} against neighbour face {neighbour} lies \
669 {drift:.3e} off that cap, and the carrier cannot be extended to reach \
670 it: {refusal} — refusing"
671 )
672 })?;
673 let far_miss = axial_miss(carrier.as_ref().unwrap_or(&base), far)?;
674 if far_miss == 0.0 || far_miss.signum() != drift.signum() {
675 bracket = Some((far, carrier));
676 break;
677 }
678 reach *= 2.0;
679 }
680 let Some((far, carrier)) = bracket else {
681 return Err(format!(
682 "{op}: the offset meridian of face {face_id} does not reach the plane of \
683 neighbour face {neighbour} near its rim, which the push leaves {drift:.3e} off \
684 that plane — refusing"
685 ));
686 };
687 let carrier_ref = carrier.as_ref().unwrap_or(&base);
688 let (mut near, mut far) = (start, far);
689 for _ in 0..200 {
690 let middle = 0.5 * (near + far);
691 if middle == near || middle == far {
692 break;
693 }
694 if axial_miss(carrier_ref, middle)?.signum() == drift.signum() {
695 near = middle;
696 } else {
697 far = middle;
698 }
699 }
700 new[slot] = 0.5 * (near + far);
701 if carrier.is_some() {
702 surface = carrier;
703 }
704 }
705 if !(new[1] > new[0]) {
706 return Err(format!(
707 "{op}: pushing face {face_id} by {distance} crosses its two rims over (source \
708 parameters {} and {}) — refusing",
709 new[0], new[1]
710 ));
711 }
712 Ok(RimSpan { old, new, surface })
713}
714
715enum IsoKind {
718 ConstantV,
719 ConstantU(f64),
720}
721
722fn iso_kind(pcurve: &NurbsCurve, uv_tolerance: f64) -> Result<Option<IsoKind>, String> {
723 let [q0, q1] = pcurve.domain()?;
724 let uv0 = pcurve.evaluate(q0)?;
725 let uv1 = pcurve.evaluate(q1)?;
726 let uvm = pcurve.evaluate(0.5 * (q0 + q1))?;
727 Ok(
728 if (uv0.y - uv1.y).abs() <= uv_tolerance && (uv0.y - uvm.y).abs() <= uv_tolerance {
729 Some(IsoKind::ConstantV)
730 } else if (uv0.x - uv1.x).abs() <= uv_tolerance && (uv0.x - uvm.x).abs() <= uv_tolerance {
731 Some(IsoKind::ConstantU(uv0.x))
732 } else {
733 None
734 },
735 )
736}
737
738fn extend_to(surface: &NurbsSurface, parameter: f64) -> Result<Option<NurbsSurface>, String> {
741 let [v0, v1] = surface.domain_v()?;
742 let (side, delta) = if parameter < v0 {
743 (crate::SurfaceSide::VMin, v0 - parameter)
744 } else if parameter > v1 {
745 (crate::SurfaceSide::VMax, parameter - v1)
746 } else {
747 return Ok(None);
748 };
749 surface
752 .extend_natural(side, delta.max(2.0 * crate::curve::KNOT_IDENTITY_TOL))
753 .map(Some)
754 .map_err(|refusal| refusal.describe())
755}
756
757struct Meridian<'a> {
759 edge: &'a EdgeRecord,
760 neighbour: u64,
761 azimuth: f64,
763 plane_origin: Vec3,
764 plane_normal: Vec3,
765}
766
767fn offset_partial_cylinder_face(
788 solid: &BrepSolid,
789 face_id: u64,
790 distance: f64,
791 tolerance: f64,
792 residual_tolerance: f64,
793 scale: f64,
794) -> Result<Option<BrepSolid>, String> {
795 use std::f64::consts::TAU;
796 let op = "offset_revolution_face";
797 let (shell_index, face_index) =
798 find_face(solid, face_id).ok_or_else(|| format!("{op}: no face {face_id}"))?;
799 let face = &solid.shells[shell_index].faces[face_index];
800 let Some(AnalyticSurface::Revolution {
801 frame, generatrix, ..
802 }) = face.surface.analytic()
803 else {
804 return Ok(None);
805 };
806 if generatrix.degree != 1 || generatrix.control_points.len() != 2 {
807 return Ok(None);
808 }
809 let (origin, axis, x_axis, y_axis) = (frame.origin, frame.axis, frame.x_axis, frame.y_axis);
810 let cylindrical = |point: Vec3| {
812 let relative = point.sub(origin);
813 let axial = relative.dot(axis);
814 (axial, relative.sub(axis.scale(axial)))
815 };
816 let azimuth_of = |radial: Vec3| radial.dot(y_axis).atan2(radial.dot(x_axis));
817 let (generatrix_z0, generatrix_radial0) = cylindrical(generatrix.control_points[0].point()?);
818 let (generatrix_z1, generatrix_radial1) = cylindrical(generatrix.control_points[1].point()?);
819 if generatrix_radial0.sub(generatrix_radial1).length() > residual_tolerance {
820 return Ok(None); }
822 let radius = generatrix_radial0.length();
823 let span = (generatrix_z1 - generatrix_z0).abs();
824
825 if face.loops.len() != 1 || face.loops[0].coedges.len() != 4 {
826 return Ok(None);
827 }
828 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
829 for shell in &solid.shells {
830 for candidate in &shell.faces {
831 for loop_record in &candidate.loops {
832 for coedge in &loop_record.coedges {
833 faces_of_edge
834 .entry(coedge.edge_id)
835 .or_default()
836 .push(candidate.id);
837 }
838 }
839 }
840 }
841 let edge_by_id: HashMap<u64, &EdgeRecord> =
842 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
843
844 let mut meridians: Vec<Meridian> = Vec::new();
846 let mut rims: Vec<(&EdgeRecord, f64)> = Vec::new();
847 let mut planar_neighbours: HashSet<u64> = HashSet::default();
848 for coedge in &face.loops[0].coedges {
849 let edge = *edge_by_id
850 .get(&coedge.edge_id)
851 .ok_or_else(|| format!("{op}: missing edge {}", coedge.edge_id))?;
852 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
853 if incident.len() != 2 || incident.iter().filter(|f| **f == face_id).count() != 1 {
854 return Ok(None);
855 }
856 let neighbour = *incident.iter().find(|f| **f != face_id).expect("two incidences");
857 let (ns, nf) =
858 find_face(solid, neighbour).ok_or_else(|| format!("{op}: missing neighbour {neighbour}"))?;
859 let Ok(plane) =
860 plane_of_surface(&solid.shells[ns].faces[nf].surface, residual_tolerance, op)
861 else {
862 return Ok(None);
863 };
864 let (z_start, radial_start) = cylindrical(edge.curve.evaluate(edge.t0)?);
865 let (z_mid, radial_mid) = cylindrical(edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?);
866 let (z_end, radial_end) = cylindrical(edge.curve.evaluate(edge.t1)?);
867 if radial_start.sub(radial_mid).length() <= residual_tolerance
868 && radial_start.sub(radial_end).length() <= residual_tolerance
869 {
870 if plane.normal.dot(axis).abs() * span > residual_tolerance {
871 return Ok(None); }
873 meridians.push(Meridian {
874 edge,
875 neighbour,
876 azimuth: azimuth_of(radial_start),
877 plane_origin: plane.origin,
878 plane_normal: plane.normal,
879 });
880 } else if (z_start - z_mid).abs() <= residual_tolerance
881 && (z_start - z_end).abs() <= residual_tolerance
882 {
883 if plane.normal.cross(axis).length() * radius > residual_tolerance {
884 return Ok(None); }
886 rims.push((edge, z_start));
887 } else {
888 return Ok(None);
889 }
890 planar_neighbours.insert(neighbour);
891 }
892 if meridians.len() != 2 || rims.len() != 2 || (rims[0].1 - rims[1].1).abs() <= residual_tolerance
893 {
894 return Ok(None);
895 }
896 let mut stations: HashMap<u64, f64> = HashMap::default();
901 for meridian in &meridians {
902 for vertex_id in [meridian.edge.start_vertex_id, meridian.edge.end_vertex_id] {
903 let vertex = solid
904 .vertices
905 .iter()
906 .find(|vertex| vertex.id == vertex_id)
907 .ok_or_else(|| format!("{op}: missing vertex {vertex_id}"))?;
908 let (axial, _) = cylindrical(vertex.point);
909 let Some(station) = rims
910 .iter()
911 .map(|(_, station)| *station)
912 .find(|station| (axial - station).abs() <= residual_tolerance)
913 else {
914 return Ok(None);
915 };
916 stations.insert(vertex_id, station);
917 }
918 }
919 let boundary: HashSet<u64> =
920 face.loops[0].coedges.iter().map(|coedge| coedge.edge_id).collect();
921 for edge in &solid.edges {
922 let at_corner = stations.contains_key(&edge.start_vertex_id)
923 || stations.contains_key(&edge.end_vertex_id);
924 if !at_corner {
925 continue;
926 }
927 if boundary.contains(&edge.id) {
928 if !(stations.contains_key(&edge.start_vertex_id)
929 && stations.contains_key(&edge.end_vertex_id))
930 {
931 return Ok(None);
932 }
933 continue;
934 }
935 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
936 if edge.degenerate
937 || edge.curve.degree != 1
938 || !incident.iter().all(|f| planar_neighbours.contains(f))
939 {
940 return Ok(None);
941 }
942 }
943
944 let [u0, u1] = face.surface.domain_u()?;
946 let [v0, v1] = face.surface.domain_v()?;
947 let (u_mid, v_mid) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
948 let offsets = OffsetEvaluator::new(
949 "push_revolution",
950 &face.surface,
951 OffsetNormal::Face {
952 same_sense: face.same_sense,
953 },
954 );
955 let sample = offsets.at(u_mid, v_mid, distance)?;
956 let outward = cylindrical(sample.source).1.normalized()?;
957 let new_radius = cylindrical(sample.point).1.dot(outward);
958 if new_radius <= tolerance {
959 return Err(format!(
960 "{op}: pushing partial cylinder face {face_id} by {distance} collapses its radius \
961 {radius:.6} onto the axis — refusing"
962 ));
963 }
964
965 let ccw = |from: f64, to: f64| (to - from).rem_euclid(TAU);
968 let rim_mid = rims[0].0.curve.evaluate(0.5 * (rims[0].0.t0 + rims[0].0.t1))?;
969 let rim_azimuth = azimuth_of(cylindrical(rim_mid).1);
970 let (start, end) = if ccw(meridians[0].azimuth, rim_azimuth)
971 < ccw(meridians[0].azimuth, meridians[1].azimuth)
972 {
973 (&meridians[0], &meridians[1])
974 } else {
975 (&meridians[1], &meridians[0])
976 };
977
978 let solve = |meridian: &Meridian, sense: f64| -> Result<(f64, [f64; 2]), String> {
982 let normal = meridian.plane_normal;
983 let (nx, ny) = (normal.dot(x_axis), normal.dot(y_axis));
984 let reach = nx.hypot(ny);
985 let phi = ny.atan2(nx);
986 let offset = meridian.plane_origin.sub(origin).dot(normal);
987 let lean = (phi - meridian.azimuth).sin();
988 let side = if radius * reach * lean.abs() > residual_tolerance {
989 sense * lean
990 } else {
991 -offset
992 };
993 let shortfall = offset.abs() - new_radius * reach;
994 if shortfall > tolerance {
995 return Err(format!(
996 "{op}: pushing partial cylinder face {face_id} by {distance} separates it from \
997 neighbour face {} — the radius {new_radius:.6} carrier stops {shortfall:.3e} \
998 short of that plane (refusing)",
999 meridian.neighbour
1000 ));
1001 }
1002 let half = (offset / (new_radius * reach)).clamp(-1.0, 1.0).acos();
1003 let azimuth = if sense * side > 0.0 { phi - half } else { phi + half };
1004 Ok((azimuth, [phi - half, phi + half]))
1005 };
1006 let (start_azimuth, start_crossings) = solve(start, 1.0)?;
1007 let (end_azimuth, end_crossings) = solve(end, -1.0)?;
1008 let new_sweep = ccw(start_azimuth, end_azimuth);
1009 let angular_tolerance = tolerance / new_radius;
1010 if new_sweep <= angular_tolerance || new_sweep >= TAU - angular_tolerance {
1011 return Err(format!(
1012 "{op}: pushing partial cylinder face {face_id} by {distance} closes its arc between \
1013 neighbour faces {} and {} — refusing",
1014 start.neighbour, end.neighbour
1015 ));
1016 }
1017 let crossing_margin = residual_tolerance / new_radius;
1024 for (meridian, crossings) in [(start, start_crossings), (end, end_crossings)] {
1025 for crossing in crossings {
1026 let along = ccw(start_azimuth, crossing);
1027 if along > crossing_margin && along < new_sweep - crossing_margin {
1028 return Err(format!(
1029 "{op}: pushing partial cylinder face {face_id} by {distance} carries its arc \
1030 through the plane of neighbour face {} — refusing",
1031 meridian.neighbour
1032 ));
1033 }
1034 }
1035 }
1036
1037 let direction = |azimuth: f64| x_axis.scale(azimuth.cos()).add(y_axis.scale(azimuth.sin()));
1039 let mut new_vertices: HashMap<u64, Vec3> = HashMap::default();
1040 let mut new_curves: HashMap<u64, NurbsCurve> = HashMap::default();
1041 for (meridian, azimuth) in [(start, start_azimuth), (end, end_azimuth)] {
1042 let edge = meridian.edge;
1043 let mut ends = [Vec3::default(); 2];
1044 for (slot, vertex_id) in [edge.start_vertex_id, edge.end_vertex_id].into_iter().enumerate()
1045 {
1046 ends[slot] = origin
1047 .add(axis.scale(stations[&vertex_id]))
1048 .add(direction(azimuth).scale(new_radius));
1049 new_vertices.insert(vertex_id, ends[slot]);
1050 }
1051 new_curves.insert(edge.id, make_line(ends[0], ends[1])?);
1052 }
1053 let new_x = direction(start_azimuth);
1054 let new_y = axis.cross(new_x);
1055 for (edge, station) in &rims {
1056 let (head, tail) = (new_vertices[&edge.start_vertex_id], new_vertices[&edge.end_vertex_id]);
1057 let mut arc = crate::make_arc(
1058 origin.add(axis.scale(*station)),
1059 new_x,
1060 new_y,
1061 new_radius,
1062 0.0,
1063 new_sweep,
1064 )?;
1065 let [a0, _] = arc.domain()?;
1066 let first = arc.evaluate(a0)?;
1067 if first.sub(head).length() > first.sub(tail).length() {
1068 arc = arc.reversed()?;
1069 }
1070 new_curves.insert(edge.id, arc);
1071 }
1072
1073 for edge in &solid.edges {
1076 if new_curves.contains_key(&edge.id)
1077 || !(new_vertices.contains_key(&edge.start_vertex_id)
1078 || new_vertices.contains_key(&edge.end_vertex_id))
1079 {
1080 continue;
1081 }
1082 let position = |vertex_id: u64| {
1083 solid
1084 .vertices
1085 .iter()
1086 .find(|vertex| vertex.id == vertex_id)
1087 .map(|vertex| vertex.point)
1088 .ok_or_else(|| format!("{op}: missing vertex {vertex_id}"))
1089 };
1090 let old_run = position(edge.end_vertex_id)?.sub(position(edge.start_vertex_id)?);
1091 let new_start = match new_vertices.get(&edge.start_vertex_id) {
1092 Some(point) => *point,
1093 None => position(edge.start_vertex_id)?,
1094 };
1095 let new_end = match new_vertices.get(&edge.end_vertex_id) {
1096 Some(point) => *point,
1097 None => position(edge.end_vertex_id)?,
1098 };
1099 let new_run = new_end.sub(new_start);
1100 if new_run.length() <= tolerance || new_run.dot(old_run) <= 0.0 {
1101 return Err(format!(
1102 "{op}: pushing partial cylinder face {face_id} by {distance} runs a corner past \
1103 the far end of edge {} — refusing",
1104 edge.id
1105 ));
1106 }
1107 }
1108
1109 let carrier = make_revolution(
1112 origin,
1113 axis,
1114 &make_line(
1115 origin
1116 .add(axis.scale(generatrix_z0))
1117 .add(new_x.scale(new_radius)),
1118 origin
1119 .add(axis.scale(generatrix_z1))
1120 .add(new_x.scale(new_radius)),
1121 )?,
1122 new_sweep,
1123 )?;
1124 let [c0, c1] = carrier.domain_u()?;
1125 let [w0, w1] = carrier.domain_v()?;
1126 let old_normal = face.surface.normal(u_mid, v_mid)?;
1127 let old_point = face.surface.evaluate(u_mid, v_mid)?;
1128 let new_normal = carrier.normal(0.5 * (c0 + c1), 0.5 * (w0 + w1))?;
1129 let new_point = carrier.evaluate(0.5 * (c0 + c1), 0.5 * (w0 + w1))?;
1130 if old_normal.dot(cylindrical(old_point).1) * new_normal.dot(cylindrical(new_point).1) <= 0.0 {
1131 return Err(format!(
1132 "{op}: the offset carrier of partial cylinder face {face_id} flipped its normal — \
1133 refusing"
1134 ));
1135 }
1136
1137 let mut result = solid.clone();
1138 let pushed = &mut result.shells[shell_index].faces[face_index];
1139 for coedge in &mut pushed.loops[0].coedges {
1140 let mut pcurve = build_pcurve_on_surface(&carrier, &new_curves[&coedge.edge_id])?;
1141 if !coedge.forward {
1142 pcurve = pcurve.reversed()?;
1143 }
1144 coedge.pcurve = pcurve;
1145 }
1146 pushed.surface = carrier;
1147 retrim_planar_neighbours(
1148 solid,
1149 result,
1150 &edge_by_id,
1151 &new_curves,
1152 &new_vertices,
1153 planar_neighbours,
1154 residual_tolerance,
1155 scale,
1156 )
1157 .map(Some)
1158}
1159
1160#[allow(clippy::too_many_arguments)]
1166fn retrim_planar_neighbours(
1167 solid: &BrepSolid,
1168 mut result: BrepSolid,
1169 edge_by_id: &HashMap<u64, &EdgeRecord>,
1170 new_curves: &HashMap<u64, NurbsCurve>,
1171 new_vertices: &HashMap<u64, Vec3>,
1172 planar_neighbours: HashSet<u64>,
1173 residual_tolerance: f64,
1174 scale: f64,
1175) -> Result<BrepSolid, String> {
1176 for edge in &mut result.edges {
1177 if let Some(curve) = new_curves.get(&edge.id) {
1178 let [d0, d1] = curve.domain()?;
1179 edge.curve = curve.clone();
1180 edge.t0 = d0;
1181 edge.t1 = d1;
1182 }
1183 }
1184 for vertex in &mut result.vertices {
1185 if let Some(point) = new_vertices.get(&vertex.id) {
1186 vertex.point = *point;
1187 }
1188 }
1189 let result_vertices: HashMap<u64, Vec3> = result
1193 .vertices
1194 .iter()
1195 .map(|vertex| (vertex.id, vertex.point))
1196 .collect();
1197 let mut cap_seams = HashSet::default();
1198 for neighbour in &planar_neighbours {
1199 let (ns, nf) = find_face(&result, *neighbour)
1200 .ok_or_else(|| format!("offset_revolution_face: missing cap {neighbour}"))?;
1201 for loop_record in &result.shells[ns].faces[nf].loops {
1202 for coedge in &loop_record.coedges {
1203 let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
1204 format!(
1205 "offset_revolution_face: missing cap edge {}",
1206 coedge.edge_id
1207 )
1208 })?;
1209 let touches_moved = new_vertices.contains_key(&edge.start_vertex_id)
1210 || new_vertices.contains_key(&edge.end_vertex_id);
1211 if touches_moved
1212 && !new_curves.contains_key(&edge.id)
1213 && !edge.degenerate
1214 && edge.curve.degree == 1
1215 {
1216 cap_seams.insert(edge.id);
1217 }
1218 }
1219 }
1220 }
1221 for seam_id in cap_seams {
1222 let source = edge_by_id[&seam_id];
1223 let curve = make_line(
1224 result_vertices[&source.start_vertex_id],
1225 result_vertices[&source.end_vertex_id],
1226 )?;
1227 let [d0, d1] = curve.domain()?;
1228 if let Some(edge) = result.edges.iter_mut().find(|edge| edge.id == seam_id) {
1229 edge.curve = curve;
1230 edge.t0 = d0;
1231 edge.t1 = d1;
1232 }
1233 }
1234 let final_edges: HashMap<u64, EdgeRecord> = result
1235 .edges
1236 .iter()
1237 .map(|edge| (edge.id, edge.clone()))
1238 .collect();
1239 for neighbour in planar_neighbours {
1240 let (ns, nf) = find_face(&result, neighbour)
1241 .ok_or_else(|| format!("offset_revolution_face: missing cap {neighbour}"))?;
1242 let plane = plane_of_surface(
1243 &result.shells[ns].faces[nf].surface,
1244 residual_tolerance,
1245 "offset_revolution_face",
1246 )?;
1247 retrim_planar_face(
1248 &mut result.shells[ns].faces[nf],
1249 &plane,
1250 &final_edges,
1251 scale,
1252 "offset_revolution_face",
1253 )
1254 .map_err(|error| format!("offset_revolution_face: cap retrim: {error}"))?;
1255 }
1256 let issues = result.validate();
1257 if !issues.is_empty() {
1258 return Err(format!(
1259 "offset_revolution_face: pushed solid failed validation: {issues:?}"
1260 ));
1261 }
1262 if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
1263 if before * after <= 0.0 {
1264 return Err("offset_revolution_face: the push inverts the solid — refusing".into());
1265 }
1266 }
1267 Ok(result)
1268}
1269
1270struct BoundaryTrace {
1277 path: Option<std::path::PathBuf>,
1278}
1279
1280impl BoundaryTrace {
1281 fn open(solid: &BrepSolid, face_id: u64, distance: f64) -> Self {
1282 let path = std::env::var_os("BREP_OFFSET_REVOLUTION_TRACE")
1283 .filter(|value| !value.is_empty() && value != "0")
1284 .map(std::path::PathBuf::from);
1285 let trace = Self { path };
1286 if trace.path.is_some() {
1287 let volume = solid_signed_volume(solid).map_or(f64::NAN, f64::abs);
1288 trace.write(format!(
1289 "call\tface={face_id}\tdistance={distance}\tscale={}\tvolume_before={volume:.15}",
1290 solid_model_scale(solid)
1291 ));
1292 }
1293 trace
1294 }
1295
1296 fn write(&self, line: String) {
1300 use std::io::Write;
1301 let Some(path) = &self.path else { return };
1302 let thread = std::thread::current();
1303 let record = format!("{}\t{line}\n", thread.name().unwrap_or("?"));
1304 if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
1305 let _ = file.write_all(record.as_bytes());
1306 }
1307 }
1308
1309 fn off_plane(
1312 curve: &NurbsCurve,
1313 t0: f64,
1314 t1: f64,
1315 plane: &crate::offset_retrim::Plane,
1316 ) -> (f64, f64) {
1317 let distance = |t: f64| {
1318 curve
1319 .evaluate(t)
1320 .map_or(f64::NAN, |point| point.sub(plane.origin).dot(plane.normal).abs())
1321 };
1322 let ends = distance(t0).max(distance(t1));
1323 let samples = (0..=32)
1324 .map(|index| distance(t0 + (t1 - t0) * index as f64 / 32.0))
1325 .fold(0.0f64, f64::max);
1326 (ends, samples)
1327 }
1328
1329 fn edge(
1330 &self,
1331 kind: &str,
1332 edge: &EdgeRecord,
1333 neighbour: Option<&u64>,
1334 plane: Option<&crate::offset_retrim::Plane>,
1335 rebuilt: &NurbsCurve,
1336 ) {
1337 if self.path.is_none() {
1338 return;
1339 }
1340 let Some(plane) = plane else {
1341 self.write(format!("edge\t{}\t{kind}\tneighbour=none", edge.id));
1342 return;
1343 };
1344 let (before_ends, before_samples) = Self::off_plane(&edge.curve, edge.t0, edge.t1, plane);
1345 let (after_ends, after_samples) = match rebuilt.domain() {
1346 Ok([r0, r1]) => Self::off_plane(rebuilt, r0, r1, plane),
1347 Err(_) => (f64::NAN, f64::NAN),
1348 };
1349 self.write(format!(
1350 "edge\t{}\t{kind}\tneighbour={}\tbefore_vertices={before_ends:.3e}\t\
1351 before_samples={before_samples:.3e}\tafter_vertices={after_ends:.3e}\t\
1352 after_samples={after_samples:.3e}",
1353 edge.id,
1354 neighbour.map_or("none".to_string(), u64::to_string)
1355 ));
1356 }
1357
1358 fn outcome(&self, outcome: &Result<BrepSolid, String>) {
1359 if self.path.is_none() {
1360 return;
1361 }
1362 match outcome {
1363 Ok(result) => {
1364 let volume = solid_signed_volume(result).map_or(f64::NAN, f64::abs);
1365 self.write(format!("ok\tvolume_after={volume:.15}"));
1366 }
1367 Err(error) => self.write(format!("refused\t{error}")),
1368 }
1369 }
1370}
1371