1use super::*;
2use crate::{ExtendRefusal, KernelRefusal, KernelStage, OrRefuse};
3
4pub fn offset_revolution_face(
30 solid: &BrepSolid,
31 face_id: u64,
32 distance: f64,
33) -> Result<BrepSolid, KernelRefusal> {
34 if !distance.is_finite() {
35 return Err(KernelRefusal::input(
36 KernelStage::Collect,
37 "distance",
38 "offset_revolution_face: distance must be finite",
39 ));
40 }
41 let scale = solid_model_scale(solid);
42 let tolerance = (scale * 1e-7).max(1e-9);
43 let residual_tolerance = (scale * 1e-4).max(1e-6);
44 let fit_tolerance = crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit;
48 let (shell_index, face_index) = find_face(solid, face_id).ok_or_else(|| {
49 KernelRefusal::input(
50 KernelStage::Collect,
51 "face_id",
52 format!("offset_revolution_face: no face {face_id}"),
53 )
54 })?;
55 let face = &solid.shells[shell_index].faces[face_index];
56 if !matches!(face.surface.analytic(), Some(AnalyticSurface::Revolution { .. })) {
57 return Err(KernelRefusal::input(
58 KernelStage::Collect,
59 "not_revolution",
60 "offset_revolution_face: the pushed face is not a general revolution",
61 ));
62 }
63 if let Some(result) = offset_partial_cylinder_face(
64 solid,
65 face_id,
66 distance,
67 tolerance,
68 residual_tolerance,
69 scale,
70 )? {
71 return Ok(result);
72 }
73 let trace = BoundaryTrace::open(solid, face_id, distance);
74 let outcome = offset_general_revolution_face(
75 solid,
76 face_id,
77 distance,
78 tolerance,
79 residual_tolerance,
80 fit_tolerance,
81 scale,
82 &trace,
83 );
84 trace.outcome(&outcome);
85 outcome
86}
87
88#[allow(clippy::too_many_arguments)]
105fn offset_general_revolution_face(
106 solid: &BrepSolid,
107 face_id: u64,
108 distance: f64,
109 tolerance: f64,
110 residual_tolerance: f64,
111 fit_tolerance: f64,
112 scale: f64,
113 trace: &BoundaryTrace,
114) -> Result<BrepSolid, KernelRefusal> {
115 let (shell_index, face_index) = find_face(solid, face_id).ok_or_else(|| {
116 KernelRefusal::internal(
117 KernelStage::Collect,
118 "face_lookup",
119 format!("offset_revolution_face: no face {face_id}"),
120 )
121 })?;
122 let face = &solid.shells[shell_index].faces[face_index];
123 let Some(AnalyticSurface::Revolution { sweep, .. }) = face.surface.analytic() else {
124 return Err(KernelRefusal::internal(
125 KernelStage::Collect,
126 "carrier_kind",
127 "offset_revolution_face: the pushed face is not a general revolution",
128 ));
129 };
130 let source_structure = crate::revolution_structure(&face.surface).ok_or_else(|| {
131 KernelRefusal::internal(
132 KernelStage::Classify,
133 "revolution_structure",
134 "offset_revolution_face: source carrier lost its revolution structure",
135 )
136 })?;
137 let [u0, u1] = face.surface.domain_u().or_refuse(KernelStage::Classify, "domain_u")?;
138 let [v0, v1] = face.surface.domain_v().or_refuse(KernelStage::Classify, "domain_v")?;
139 if (v0.abs() + (v1 - 1.0).abs()) > 1e-9 {
140 return Err(KernelRefusal::unsupported(
141 KernelStage::Classify,
142 "generatrix_domain",
143 "offset_revolution_face: a non-normalized generatrix domain is deferred",
144 ));
145 }
146 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
147 for shell in &solid.shells {
148 for candidate in &shell.faces {
149 for loop_record in &candidate.loops {
150 for coedge in &loop_record.coedges {
151 faces_of_edge
152 .entry(coedge.edge_id)
153 .or_default()
154 .push(candidate.id);
155 }
156 }
157 }
158 }
159 let edge_by_id: HashMap<u64, &EdgeRecord> =
160 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
161 let u_probe = u0 + (u1 - u0) * 0.137;
166 let span = solve_rim_span(
167 solid,
168 face,
169 face_id,
170 &faces_of_edge,
171 &source_structure.frame,
172 u_probe,
173 distance,
174 residual_tolerance,
175 )?;
176 let source_surface = span.surface.as_ref().unwrap_or(&face.surface);
177 let offsets = OffsetEvaluator::new(
182 "push_revolution",
183 source_surface,
184 OffsetNormal::Face {
185 same_sense: face.same_sense,
186 },
187 );
188 let [s0, s1] = span.new;
191 let source_parameter = |w: f64| s0 + (s1 - s0) * w;
192 let mut meridian_points = Vec::with_capacity(33);
193 let mut meridian_parameters = Vec::with_capacity(33);
194 for index in 0..=32 {
195 let parameter = index as f64 / 32.0;
196 let v = source_parameter(v0 + (v1 - v0) * parameter);
197 let target_point = offsets.at(u_probe, v, distance).or_refuse(KernelStage::Refine, "offset_at")?.point;
198 let relative = target_point.sub(source_structure.frame.origin);
199 let axial = relative.dot(source_structure.frame.axis);
200 let radial = relative
201 .sub(source_structure.frame.axis.scale(axial))
202 .length();
203 meridian_points.push(
204 source_structure
205 .frame
206 .origin
207 .add(source_structure.frame.x_axis.scale(radial))
208 .add(source_structure.frame.axis.scale(axial)),
209 );
210 meridian_parameters.push(parameter);
211 }
212 let offset_generatrix = crate::interpolate_curve(&meridian_points, 3, &meridian_parameters).or_refuse(KernelStage::Refine, "interpolate_curve")?;
213 let offset = crate::make_revolution(
214 source_structure.frame.origin,
215 source_structure.frame.axis,
216 &offset_generatrix,
217 *sweep,
218 ).or_refuse(KernelStage::Refine, "make_revolution")?;
219 let structure = crate::revolution_structure(&offset).ok_or_else(|| {
220 KernelRefusal::internal(
221 KernelStage::Refine,
222 "offset_structure",
223 "offset_revolution_face: offset carrier lost its revolution structure — refusing",
224 )
225 })?;
226 if !matches!(offset.analytic(), Some(AnalyticSurface::Revolution { .. })) {
227 return Err(KernelRefusal::internal(
228 KernelStage::Refine,
229 "offset_kind",
230 "offset_revolution_face: offset carrier changed analytic kind — refusing",
231 ));
232 }
233
234 let target = distance.abs();
237 let (mut worst_distance, mut worst_normal) = (0.0f64, 0.0f64);
238 for iu in 0..13 {
242 for iv in 0..13 {
243 let u = u0 + (u1 - u0) * (iu as f64 + 0.37) / 13.0;
244 let w = v0 + (v1 - v0) * (iv as f64 + 0.41) / 13.0;
245 let v = source_parameter(w);
246 let source_point = source_surface.evaluate(u, v).or_refuse(KernelStage::Refine, "evaluate")?;
247 let offset_point = offset.evaluate(u, w).or_refuse(KernelStage::Refine, "evaluate")?;
248 let delta = offset_point.sub(source_point);
249 let normal = offsets
250 .normal(u, v)
251 .map_err(|error| {
252 KernelRefusal::internal(
253 KernelStage::Refine,
254 "sample_normal",
255 format!("offset_revolution_face: sample normal: {error}"),
256 )
257 })?;
258 let distance_error = (delta.length() - target).abs();
259 let normal_error = if delta.length() > tolerance {
260 1.0 - delta
261 .normalized()
262 .map_err(|error| {
263 KernelRefusal::internal(
264 KernelStage::Refine,
265 "sample_delta",
266 format!("offset_revolution_face: sample delta: {error}"),
267 )
268 })?
269 .dot(normal)
270 .abs()
271 } else {
272 0.0
273 };
274 if distance_error > residual_tolerance || normal_error > 5e-4 {
275 return Err(KernelRefusal::non_convergence(
276 KernelStage::Refine,
277 "offset_fit",
278 format!(
279 "offset_revolution_face: offset fit exceeds tolerance \
280 (distance error {distance_error:.3e}, normal error {normal_error:.3e})"
281 ),
282 ));
283 }
284 worst_distance = worst_distance.max(distance_error);
285 worst_normal = worst_normal.max(normal_error);
286 }
287 }
288 trace.write(format!(
289 "carrier\trims_old={:?}\trims_new={:?}\textended={}\t\
290 fit_distance_error={worst_distance:.3e}\tfit_normal_error={worst_normal:.3e}",
291 span.old,
292 span.new,
293 span.surface.is_some()
294 ));
295
296 let [g0, g1] = structure.generatrix.domain().or_refuse(KernelStage::Refine, "domain")?;
299 let mut rim_candidates = Vec::new();
300 for parameter in [g0, g1] {
301 let point = structure.generatrix.evaluate(parameter).or_refuse(KernelStage::Refine, "evaluate")?;
302 let relative = point.sub(structure.frame.origin);
303 let axial = relative.dot(structure.frame.axis);
304 let radial = relative.sub(structure.frame.axis.scale(axial));
305 let radius = radial.length();
306 if radius <= tolerance {
307 return Err(KernelRefusal::input(
308 KernelStage::Refine,
309 "rim_collapse",
310 format!(
311 "offset_revolution_face: an offset rim collapses onto the axis \
312 (point {point:?}, frame {:?}) — refusing",
313 structure.frame
314 ),
315 ));
316 }
317 rim_candidates.push(crate::make_arc(
318 structure
319 .frame
320 .origin
321 .add(structure.frame.axis.scale(axial)),
322 structure.frame.x_axis,
323 structure.frame.y_axis,
324 radius,
325 0.0,
326 structure.sweep,
327 ).or_refuse(KernelStage::Refine, "make_arc")?);
328 }
329
330 let uv_tolerance = 1e-7;
336 let pcurves = face
337 .loops
338 .iter()
339 .map(|loop_record| {
340 loop_record
341 .coedges
342 .iter()
343 .map(|coedge| match iso_kind(&coedge.pcurve, uv_tolerance)? {
344 Some(_) => span.carry_pcurve(&coedge.pcurve),
345 None => span.transfer_pcurve(&coedge.pcurve),
346 })
347 .collect::<Result<Vec<_>, KernelRefusal>>()
348 })
349 .collect::<Result<Vec<_>, KernelRefusal>>()?;
350
351 let floor = crate::pcurve::PCURVE_REFINEMENT_TOLERANCE;
352 let mut new_curves: HashMap<u64, NurbsCurve> = HashMap::default();
353 let mut native_iso_pcurves: HashMap<u64, NurbsCurve> = HashMap::default();
354 let mut new_vertices: HashMap<u64, Vec3> = HashMap::default();
355 let mut planar_neighbours: HashSet<u64> = HashSet::default();
356 for (loop_record, loop_pcurves) in face.loops.iter().zip(&pcurves) {
357 for (coedge, pcurve) in loop_record.coedges.iter().zip(loop_pcurves) {
358 if new_curves.contains_key(&coedge.edge_id) {
359 continue;
360 }
361 let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
362 KernelRefusal::internal(
363 KernelStage::Classify,
364 "missing_edge",
365 format!("offset_revolution_face: missing edge {}", coedge.edge_id),
366 )
367 })?;
368 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
369 let neighbour = incident.iter().find(|candidate| **candidate != face_id);
370 let mut neighbour_plane = None;
374 if let Some(neighbour) = neighbour {
375 let (ns, nf) = find_face(solid, *neighbour).ok_or_else(|| {
376 KernelRefusal::internal(
377 KernelStage::Classify,
378 "missing_neighbour",
379 format!("offset_revolution_face: missing neighbour {neighbour}"),
380 )
381 })?;
382 match plane_of_surface(
383 &solid.shells[ns].faces[nf].surface,
384 residual_tolerance,
385 "offset_revolution_face",
386 ) {
387 Err(_) => {
388 return Err(KernelRefusal::unsupported(
389 KernelStage::Classify,
390 "curved_neighbour",
391 "offset_revolution_face: only planar neighbours are supported \
392 for a general revolution — refusing",
393 ));
394 }
395 Ok(plane) => neighbour_plane = Some(plane),
396 }
397 planar_neighbours.insert(*neighbour);
398 }
399
400 let mut general_image = false;
403 let mut meridian = false;
404 let mut curve = match iso_kind(&coedge.pcurve, uv_tolerance)? {
405 Some(IsoKind::ConstantV) => {
406 let [q0, _] = pcurve.domain().or_refuse(KernelStage::Refine, "domain")?;
407 let w = pcurve.evaluate(q0).or_refuse(KernelStage::Refine, "evaluate")?.y;
408 if w.abs() <= uv_tolerance {
409 rim_candidates[0].clone()
410 } else if (w - 1.0).abs() <= uv_tolerance {
411 rim_candidates[1].clone()
412 } else {
413 return Err(KernelRefusal::unsupported(
414 KernelStage::Classify,
415 "interior_rim",
416 format!(
417 "offset_revolution_face: constant-v boundary edge {} sits at v = {w} \
418 between the face's two rims — refusing",
419 edge.id
420 ),
421 ));
422 }
423 }
424 Some(IsoKind::ConstantU(u)) => {
425 meridian = true;
426 offset.iso_curve_u(u).or_refuse(KernelStage::Refine, "iso_curve_u")?
427 }
428 None => {
429 general_image = true;
436 let image = crate::image_curve(
437 &offset,
438 pcurve,
439 fit_tolerance,
440 "offset_revolution_face",
441 ).or_refuse(KernelStage::Refine, "image_curve")?;
442 if coedge.forward {
443 image.curve
444 } else {
445 image.curve.reversed().or_refuse(KernelStage::Refine, "reversed")?
446 }
447 }
448 };
449 let kind = if general_image {
450 "general"
451 } else if meridian {
452 "meridian"
453 } else {
454 "rim"
455 };
456 trace.edge(kind, edge, neighbour, neighbour_plane.as_ref(), &curve);
457 if let Some(plane) = &neighbour_plane {
476 let [g0, g1] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
477 let mut worst = 0.0f64;
478 for index in 0..=32 {
479 let t = g0 + (g1 - g0) * index as f64 / 32.0;
480 let point = curve.evaluate(t).or_refuse(KernelStage::Refine, "evaluate")?;
481 worst = worst.max(point.sub(plane.origin).dot(plane.normal).abs());
482 }
483 if !(general_image || meridian) && !(worst <= floor) {
484 return Err(KernelRefusal::unsupported(
485 KernelStage::Refine,
486 "rim_off_cap",
487 format!(
488 "offset_revolution_face: the rebuilt rim of boundary edge {} leaves the \
489 plane of neighbour face {} by {worst:.3e} (pcurve floor {floor:.0e}) — \
490 refusing",
491 edge.id,
492 neighbour.copied().unwrap_or_default()
493 ),
494 ));
495 }
496 if (general_image || meridian) && worst > residual_tolerance {
497 let lane = if meridian {
498 "meridian at its old azimuth"
499 } else {
500 "general image"
501 };
502 return Err(KernelRefusal::unsupported(
503 KernelStage::Refine,
504 "transfer_off_neighbour",
505 format!(
506 "offset_revolution_face: the {lane} of boundary edge {} \
507 leaves its fixed planar neighbour by {worst:.3e} (limit \
508 {residual_tolerance:.3e}) — re-intersecting the offset carrier \
509 with a fixed neighbour is a separate capability; refusing",
510 edge.id
511 ),
512 ));
513 }
514 }
515 let mut reversed_curve = false;
516 if !general_image {
517 let [c0, c1] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
518 let old_start = edge.curve.evaluate(edge.t0).or_refuse(KernelStage::Refine, "evaluate")?;
519 if curve.evaluate(c0).or_refuse(KernelStage::Refine, "evaluate")?.sub(old_start).length()
520 > curve.evaluate(c1).or_refuse(KernelStage::Refine, "evaluate")?.sub(old_start).length()
521 {
522 curve = curve.reversed().or_refuse(KernelStage::Refine, "reversed")?;
523 reversed_curve = true;
524 }
525 }
526 let native = match iso_kind(&coedge.pcurve, uv_tolerance)? {
527 Some(IsoKind::ConstantU(u)) => {
528 let [v0, v1] = offset.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
529 Some(crate::sweep_topology::parameter_line(u, v0, u, v1)
530 .or_refuse(KernelStage::Refine, "parameter_line")?)
531 }
532 Some(IsoKind::ConstantV) => {
533 let [q0, _] = pcurve.domain().or_refuse(KernelStage::Refine, "domain")?;
536 let w = pcurve.evaluate(q0).or_refuse(KernelStage::Refine, "evaluate")?.y;
537 let [v0, v1] = offset.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
538 let v = if w.abs() <= uv_tolerance { v0 } else { v1 };
539 let [u0, u1] = offset.domain_u().or_refuse(KernelStage::Refine, "domain_u")?;
540 Some(crate::sweep_topology::parameter_line(u0, v, u1, v)
541 .or_refuse(KernelStage::Refine, "parameter_line")?)
542 }
543 None => None,
544 };
545 if let Some(native) = native {
546 native_iso_pcurves.insert(edge.id, if reversed_curve {
547 native.reversed().or_refuse(KernelStage::Refine, "reversed")?
548 } else { native });
549 }
550 let [d0, d1] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
555 let ends = [
556 (edge.start_vertex_id, curve.evaluate(d0).or_refuse(KernelStage::Refine, "evaluate")?),
557 (edge.end_vertex_id, curve.evaluate(d1).or_refuse(KernelStage::Refine, "evaluate")?),
558 ];
559 for (vertex_id, point) in ends {
560 if let Some(previous) = new_vertices.insert(vertex_id, point) {
561 let gap = previous.sub(point).length();
562 if !(gap <= floor) {
563 return Err(KernelRefusal::internal(
564 KernelStage::Fragment,
565 "vertex_agreement",
566 format!(
567 "offset_revolution_face: boundary edge {} rebuilds vertex {vertex_id} \
568 {gap:.3e} from where another boundary edge put it (pcurve floor \
569 {floor:.0e}) — refusing",
570 edge.id
571 ),
572 ));
573 }
574 }
575 }
576 new_curves.insert(edge.id, curve);
577 }
578 }
579
580 let mut result = solid.clone();
581 let pushed = &mut result.shells[shell_index].faces[face_index];
582 for (loop_record, loop_pcurves) in pushed.loops.iter_mut().zip(pcurves) {
583 for (coedge, pcurve) in loop_record.coedges.iter_mut().zip(loop_pcurves) {
584 coedge.pcurve = if let Some(native) = native_iso_pcurves.get(&coedge.edge_id) {
585 if coedge.forward { native.clone() }
586 else { native.reversed().or_refuse(KernelStage::Refine, "reversed")? }
587 } else { pcurve };
588 }
589 }
590 pushed.surface = offset;
591 retrim_planar_neighbours(
592 solid,
593 result,
594 &edge_by_id,
595 &new_curves,
596 &new_vertices,
597 planar_neighbours,
598 residual_tolerance,
599 scale,
600 )
601}
602
603struct RimSpan {
605 old: [f64; 2],
608 new: [f64; 2],
611 surface: Option<NurbsSurface>,
614}
615
616impl RimSpan {
617 fn carry_pcurve(&self, pcurve: &NurbsCurve) -> Result<NurbsCurve, KernelRefusal> {
620 Self::map_v(pcurve, self.old)
621 }
622
623 fn transfer_pcurve(&self, pcurve: &NurbsCurve) -> Result<NurbsCurve, KernelRefusal> {
626 Self::map_v(pcurve, self.new)
627 }
628
629 fn map_v(pcurve: &NurbsCurve, [a0, a1]: [f64; 2]) -> Result<NurbsCurve, KernelRefusal> {
632 if [a0, a1] == [0.0, 1.0] {
633 return Ok(pcurve.clone());
634 }
635 let mut carried = pcurve.clone();
636 for control in &mut carried.control_points {
637 let point = control.point().or_refuse(KernelStage::Classify, "point")?;
638 *control = crate::Vec4::from_point(
639 Vec3::new(point.x, (point.y - a0) / (a1 - a0), point.z),
640 control.w,
641 );
642 }
643 Ok(carried)
644 }
645}
646
647#[allow(clippy::too_many_arguments)]
661fn solve_rim_span(
662 solid: &BrepSolid,
663 face: &FaceRecord,
664 face_id: u64,
665 faces_of_edge: &HashMap<u64, Vec<u64>>,
666 frame: &crate::RevolutionFrame,
667 u: f64,
668 distance: f64,
669 residual_tolerance: f64,
670) -> Result<RimSpan, KernelRefusal> {
671 let op = "offset_revolution_face";
672 let uv_tolerance = 1e-7;
673 let mut rims: Vec<(f64, f64, u64)> = Vec::new();
676 let (mut v_min, mut v_max) = (f64::INFINITY, f64::NEG_INFINITY);
677 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
678 let [q0, q1] = coedge.pcurve.domain().or_refuse(KernelStage::Classify, "domain")?;
679 let uv0 = coedge.pcurve.evaluate(q0).or_refuse(KernelStage::Classify, "evaluate")?;
680 let uv1 = coedge.pcurve.evaluate(q1).or_refuse(KernelStage::Classify, "evaluate")?;
681 let uvm = coedge.pcurve.evaluate(0.5 * (q0 + q1)).or_refuse(KernelStage::Classify, "evaluate")?;
682 for uv in [uv0, uv1, uvm] {
683 v_min = v_min.min(uv.y);
684 v_max = v_max.max(uv.y);
685 }
686 if !matches!(iso_kind(&coedge.pcurve, uv_tolerance)?, Some(IsoKind::ConstantV)) {
687 continue;
688 }
689 let incident = faces_of_edge.get(&coedge.edge_id).cloned().unwrap_or_default();
690 let Some(neighbour) = incident.iter().find(|candidate| **candidate != face_id) else {
691 continue;
692 };
693 let (ns, nf) = find_face(solid, *neighbour).ok_or_else(|| {
694 KernelRefusal::internal(
695 KernelStage::Classify,
696 "rim_neighbour",
697 format!("{op}: missing neighbour {neighbour}"),
698 )
699 })?;
700 let Ok(plane) =
702 plane_of_surface(&solid.shells[ns].faces[nf].surface, residual_tolerance, op)
703 else {
704 continue;
705 };
706 let station =
708 plane.origin.sub(frame.origin).dot(plane.normal) / frame.axis.dot(plane.normal);
709 rims.push((uv0.y, station, *neighbour));
710 }
711 if rims.is_empty() {
712 return Ok(RimSpan {
713 old: [0.0, 1.0],
714 new: [0.0, 1.0],
715 surface: None,
716 });
717 }
718 let mut ends: [Option<(f64, f64, u64)>; 2] = [None, None];
721 for rim in &rims {
722 let slot = if (rim.0 - v_min).abs() <= (rim.0 - v_max).abs() { 0 } else { 1 };
723 match ends[slot] {
724 None => ends[slot] = Some(*rim),
725 Some(first) if (first.0 - rim.0).abs() > uv_tolerance => {
726 return Err(KernelRefusal::unsupported(
727 KernelStage::Classify,
728 "two_rims_one_side",
729 format!(
730 "{op}: face {face_id} has constant-v boundaries at v = {} and v = {} on one \
731 side of its trim — refusing",
732 first.0, rim.0
733 ),
734 ));
735 }
736 Some(_) => {}
737 }
738 }
739
740 let same_sense = face.same_sense;
741 let mut surface: Option<NurbsSurface> = None;
742 let mut old = [0.0, 1.0];
743 let mut new = [0.0, 1.0];
744 for (slot, end) in ends.iter().enumerate() {
745 let Some((start, station, neighbour)) = *end else {
746 continue;
747 };
748 old[slot] = start;
749 let axial_miss = |carrier: &NurbsSurface, s: f64| -> Result<f64, KernelRefusal> {
750 let convention = OffsetNormal::Face { same_sense };
751 let point = OffsetEvaluator::new("push_revolution", carrier, convention)
752 .at(u, s, distance).or_refuse(KernelStage::Refine, "offset_at")?
753 .point;
754 Ok(point.sub(frame.origin).dot(frame.axis) - station)
755 };
756 let base = surface.clone().unwrap_or_else(|| face.surface.clone());
757 let drift = axial_miss(&base, start)?;
758 if drift == 0.0 {
759 new[slot] = start;
760 continue;
761 }
762 let rate = face.surface.derivatives(u, start, 1).or_refuse(KernelStage::Refine, "derivatives")?[0][1].dot(frame.axis);
763 let step = -drift / rate;
764 if !step.is_finite() {
765 return Err(KernelRefusal::ill_posed(
769 KernelStage::Refine,
770 "tangent_rim",
771 format!(
772 "{op}: the generatrix of face {face_id} meets the plane of neighbour face \
773 {neighbour} tangentially at its rim, which the push leaves {drift:.3e} off \
774 that plane — refusing"
775 ),
776 ));
777 }
778 let mut bracket = None;
779 let mut reach = 2.0 * step;
780 for _ in 0..4 {
781 if !reach.is_finite() {
782 break;
783 }
784 let far = start + reach;
785 let carrier = extend_to(&base, far)
786 .map_err(|refusal| {
787 let message = {
791 let refusal = refusal.describe();
792 format!(
793 "{op}: the rim of face {face_id} against neighbour face {neighbour} lies \
794 {drift:.3e} off that cap, and the carrier cannot be extended to reach \
795 it: {refusal} — refusing"
796 )
797 };
798 match refusal {
799 ExtendRefusal::Failed(_) => KernelRefusal::internal(
800 KernelStage::Refine,
801 "carrier_extension_failed",
802 message,
803 ),
804 _ => KernelRefusal::unsupported(
805 KernelStage::Refine,
806 "carrier_extension",
807 message,
808 ),
809 }
810 })?;
811 let far_miss = axial_miss(carrier.as_ref().unwrap_or(&base), far)?;
812 if far_miss == 0.0 || far_miss.signum() != drift.signum() {
813 bracket = Some((far, carrier));
814 break;
815 }
816 reach *= 2.0;
817 }
818 let Some((far, carrier)) = bracket else {
819 return Err(KernelRefusal::non_convergence(
820 KernelStage::Refine,
821 "rim_bracket",
822 format!(
823 "{op}: the offset meridian of face {face_id} does not reach the plane of \
824 neighbour face {neighbour} near its rim, which the push leaves {drift:.3e} off \
825 that plane — refusing"
826 ),
827 ));
828 };
829 let carrier_ref = carrier.as_ref().unwrap_or(&base);
830 let (mut near, mut far) = (start, far);
831 for _ in 0..200 {
832 let middle = 0.5 * (near + far);
833 if middle == near || middle == far {
834 break;
835 }
836 if axial_miss(carrier_ref, middle)?.signum() == drift.signum() {
837 near = middle;
838 } else {
839 far = middle;
840 }
841 }
842 new[slot] = 0.5 * (near + far);
843 if carrier.is_some() {
844 surface = carrier;
845 }
846 }
847 if !(new[1] > new[0]) {
848 return Err(KernelRefusal::input(
849 KernelStage::Refine,
850 "rims_crossed",
851 format!(
852 "{op}: pushing face {face_id} by {distance} crosses its two rims over (source \
853 parameters {} and {}) — refusing",
854 new[0], new[1]
855 ),
856 ));
857 }
858 Ok(RimSpan { old, new, surface })
859}
860
861enum IsoKind {
864 ConstantV,
865 ConstantU(f64),
866}
867
868fn iso_kind(pcurve: &NurbsCurve, uv_tolerance: f64) -> Result<Option<IsoKind>, KernelRefusal> {
869 let [q0, q1] = pcurve.domain().or_refuse(KernelStage::Classify, "domain")?;
870 let uv0 = pcurve.evaluate(q0).or_refuse(KernelStage::Classify, "evaluate")?;
871 let uv1 = pcurve.evaluate(q1).or_refuse(KernelStage::Classify, "evaluate")?;
872 let uvm = pcurve.evaluate(0.5 * (q0 + q1)).or_refuse(KernelStage::Classify, "evaluate")?;
873 Ok(
874 if (uv0.y - uv1.y).abs() <= uv_tolerance && (uv0.y - uvm.y).abs() <= uv_tolerance {
875 Some(IsoKind::ConstantV)
876 } else if (uv0.x - uv1.x).abs() <= uv_tolerance && (uv0.x - uvm.x).abs() <= uv_tolerance {
877 Some(IsoKind::ConstantU(uv0.x))
878 } else {
879 None
880 },
881 )
882}
883
884fn extend_to(surface: &NurbsSurface, parameter: f64) -> Result<Option<NurbsSurface>, ExtendRefusal> {
887 let [v0, v1] = surface.domain_v().map_err(ExtendRefusal::Failed)?;
888 let (side, delta) = if parameter < v0 {
889 (crate::SurfaceSide::VMin, v0 - parameter)
890 } else if parameter > v1 {
891 (crate::SurfaceSide::VMax, parameter - v1)
892 } else {
893 return Ok(None);
894 };
895 surface
898 .extend_natural(side, delta.max(2.0 * crate::curve::KNOT_IDENTITY_TOL))
899 .map(Some)
900}
901
902fn meridian_meets_face(
910 solid: &BrepSolid,
911 face_id: u64,
912 point: impl Fn(f64) -> Vec3,
913 tolerance: f64,
914) -> Result<bool, KernelRefusal> {
915 let (shell_index, face_index) = find_face(solid, face_id).ok_or_else(|| {
916 KernelRefusal::internal(
917 KernelStage::Refine,
918 "crossing_neighbour_lookup",
919 format!("offset_revolution_face: missing neighbour face {face_id}"),
920 )
921 })?;
922 let face = &solid.shells[shell_index].faces[face_index];
923 const SAMPLES: usize = 7;
924 for sample in 1..=SAMPLES {
925 let fraction = sample as f64 / (SAMPLES + 1) as f64;
926 let projection = crate::project_point_to_surface(&face.surface, point(fraction))
927 .or_refuse(KernelStage::Refine, "project_point_to_surface")?;
928 if projection.distance > tolerance {
929 continue;
930 }
931 let uv = crate::Vec2 {
932 x: projection.u,
933 y: projection.v,
934 };
935 if crate::parameter_point_in_face(face, uv, 1e-9)
936 .or_refuse(KernelStage::Refine, "parameter_point_in_face")?
937 != crate::PolygonClass::Outside
938 {
939 return Ok(true);
940 }
941 }
942 Ok(false)
943}
944
945struct Meridian<'a> {
947 edge: &'a EdgeRecord,
948 neighbour: u64,
949 azimuth: f64,
951 plane_origin: Vec3,
952 plane_normal: Vec3,
953}
954
955fn offset_partial_cylinder_face(
976 solid: &BrepSolid,
977 face_id: u64,
978 distance: f64,
979 tolerance: f64,
980 residual_tolerance: f64,
981 scale: f64,
982) -> Result<Option<BrepSolid>, KernelRefusal> {
983 use std::f64::consts::TAU;
984 let op = "offset_revolution_face";
985 let (shell_index, face_index) = find_face(solid, face_id).ok_or_else(|| {
986 KernelRefusal::internal(
987 KernelStage::Collect,
988 "partial_face_lookup",
989 format!("{op}: no face {face_id}"),
990 )
991 })?;
992 let face = &solid.shells[shell_index].faces[face_index];
993 let Some(AnalyticSurface::Revolution {
994 frame, generatrix, ..
995 }) = face.surface.analytic()
996 else {
997 return Ok(None);
998 };
999 if generatrix.degree != 1 || generatrix.control_points.len() != 2 {
1000 return Ok(None);
1001 }
1002 let (origin, axis, x_axis, y_axis) = (frame.origin, frame.axis, frame.x_axis, frame.y_axis);
1003 let cylindrical = |point: Vec3| {
1005 let relative = point.sub(origin);
1006 let axial = relative.dot(axis);
1007 (axial, relative.sub(axis.scale(axial)))
1008 };
1009 let azimuth_of = |radial: Vec3| radial.dot(y_axis).atan2(radial.dot(x_axis));
1010 let (generatrix_z0, generatrix_radial0) = cylindrical(generatrix.control_points[0].point().or_refuse(KernelStage::Classify, "point")?);
1011 let (generatrix_z1, generatrix_radial1) = cylindrical(generatrix.control_points[1].point().or_refuse(KernelStage::Classify, "point")?);
1012 if generatrix_radial0.sub(generatrix_radial1).length() > residual_tolerance {
1013 return Ok(None); }
1015 let radius = generatrix_radial0.length();
1016 let span = (generatrix_z1 - generatrix_z0).abs();
1017
1018 if face.loops.len() != 1 || face.loops[0].coedges.len() != 4 {
1019 return Ok(None);
1020 }
1021 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
1022 for shell in &solid.shells {
1023 for candidate in &shell.faces {
1024 for loop_record in &candidate.loops {
1025 for coedge in &loop_record.coedges {
1026 faces_of_edge
1027 .entry(coedge.edge_id)
1028 .or_default()
1029 .push(candidate.id);
1030 }
1031 }
1032 }
1033 }
1034 let edge_by_id: HashMap<u64, &EdgeRecord> =
1035 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
1036
1037 let mut meridians: Vec<Meridian> = Vec::new();
1039 let mut rims: Vec<(&EdgeRecord, f64)> = Vec::new();
1040 let mut planar_neighbours: HashSet<u64> = HashSet::default();
1041 for coedge in &face.loops[0].coedges {
1042 let edge = *edge_by_id
1043 .get(&coedge.edge_id)
1044 .ok_or_else(|| {
1045 KernelRefusal::internal(
1046 KernelStage::Classify,
1047 "partial_missing_edge",
1048 format!("{op}: missing edge {}", coedge.edge_id),
1049 )
1050 })?;
1051 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
1052 if incident.len() != 2 || incident.iter().filter(|f| **f == face_id).count() != 1 {
1053 return Ok(None);
1054 }
1055 let neighbour = *incident.iter().find(|f| **f != face_id).expect("two incidences");
1056 let (ns, nf) = find_face(solid, neighbour).ok_or_else(|| {
1057 KernelRefusal::internal(
1058 KernelStage::Classify,
1059 "partial_missing_neighbour",
1060 format!("{op}: missing neighbour {neighbour}"),
1061 )
1062 })?;
1063 let Ok(plane) =
1064 plane_of_surface(&solid.shells[ns].faces[nf].surface, residual_tolerance, op)
1065 else {
1066 return Ok(None);
1067 };
1068 let (z_start, radial_start) = cylindrical(edge.curve.evaluate(edge.t0).or_refuse(KernelStage::Classify, "evaluate")?);
1069 let (z_mid, radial_mid) = cylindrical(edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).or_refuse(KernelStage::Classify, "evaluate")?);
1070 let (z_end, radial_end) = cylindrical(edge.curve.evaluate(edge.t1).or_refuse(KernelStage::Classify, "evaluate")?);
1071 if radial_start.sub(radial_mid).length() <= residual_tolerance
1072 && radial_start.sub(radial_end).length() <= residual_tolerance
1073 {
1074 if plane.normal.dot(axis).abs() * span > residual_tolerance {
1075 return Ok(None); }
1077 meridians.push(Meridian {
1078 edge,
1079 neighbour,
1080 azimuth: azimuth_of(radial_start),
1081 plane_origin: plane.origin,
1082 plane_normal: plane.normal,
1083 });
1084 } else if (z_start - z_mid).abs() <= residual_tolerance
1085 && (z_start - z_end).abs() <= residual_tolerance
1086 {
1087 if plane.normal.cross(axis).length() * radius > residual_tolerance {
1088 return Ok(None); }
1090 rims.push((edge, z_start));
1091 } else {
1092 return Ok(None);
1093 }
1094 planar_neighbours.insert(neighbour);
1095 }
1096 if meridians.len() != 2 || rims.len() != 2 || (rims[0].1 - rims[1].1).abs() <= residual_tolerance
1097 {
1098 return Ok(None);
1099 }
1100 let mut stations: HashMap<u64, f64> = HashMap::default();
1105 for meridian in &meridians {
1106 for vertex_id in [meridian.edge.start_vertex_id, meridian.edge.end_vertex_id] {
1107 let vertex = solid
1108 .vertices
1109 .iter()
1110 .find(|vertex| vertex.id == vertex_id)
1111 .ok_or_else(|| {
1112 KernelRefusal::internal(
1113 KernelStage::Classify,
1114 "partial_missing_vertex",
1115 format!("{op}: missing vertex {vertex_id}"),
1116 )
1117 })?;
1118 let (axial, _) = cylindrical(vertex.point);
1119 let Some(station) = rims
1120 .iter()
1121 .map(|(_, station)| *station)
1122 .find(|station| (axial - station).abs() <= residual_tolerance)
1123 else {
1124 return Ok(None);
1125 };
1126 stations.insert(vertex_id, station);
1127 }
1128 }
1129 let boundary: HashSet<u64> =
1130 face.loops[0].coedges.iter().map(|coedge| coedge.edge_id).collect();
1131 for edge in &solid.edges {
1132 let at_corner = stations.contains_key(&edge.start_vertex_id)
1133 || stations.contains_key(&edge.end_vertex_id);
1134 if !at_corner {
1135 continue;
1136 }
1137 if boundary.contains(&edge.id) {
1138 if !(stations.contains_key(&edge.start_vertex_id)
1139 && stations.contains_key(&edge.end_vertex_id))
1140 {
1141 return Ok(None);
1142 }
1143 continue;
1144 }
1145 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
1146 if edge.degenerate
1147 || edge.curve.degree != 1
1148 || !incident.iter().all(|f| planar_neighbours.contains(f))
1149 {
1150 return Ok(None);
1151 }
1152 }
1153
1154 let [u0, u1] = face.surface.domain_u().or_refuse(KernelStage::Refine, "domain_u")?;
1156 let [v0, v1] = face.surface.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
1157 let (u_mid, v_mid) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
1158 let offsets = OffsetEvaluator::new(
1159 "push_revolution",
1160 &face.surface,
1161 OffsetNormal::Face {
1162 same_sense: face.same_sense,
1163 },
1164 );
1165 let sample = offsets.at(u_mid, v_mid, distance).or_refuse(KernelStage::Refine, "offset_at")?;
1166 let outward = cylindrical(sample.source).1.normalized().or_refuse(KernelStage::Refine, "normalized")?;
1167 let new_radius = cylindrical(sample.point).1.dot(outward);
1168 if new_radius <= tolerance {
1169 return Err(KernelRefusal::input(
1170 KernelStage::Refine,
1171 "radius_collapse",
1172 format!(
1173 "{op}: pushing partial cylinder face {face_id} by {distance} collapses its radius \
1174 {radius:.6} onto the axis — refusing"
1175 ),
1176 ));
1177 }
1178
1179 let ccw = |from: f64, to: f64| (to - from).rem_euclid(TAU);
1182 let rim_mid = rims[0].0.curve.evaluate(0.5 * (rims[0].0.t0 + rims[0].0.t1)).or_refuse(KernelStage::Refine, "evaluate")?;
1183 let rim_azimuth = azimuth_of(cylindrical(rim_mid).1);
1184 let (start, end) = if ccw(meridians[0].azimuth, rim_azimuth)
1185 < ccw(meridians[0].azimuth, meridians[1].azimuth)
1186 {
1187 (&meridians[0], &meridians[1])
1188 } else {
1189 (&meridians[1], &meridians[0])
1190 };
1191
1192 let solve = |meridian: &Meridian, sense: f64| -> Result<(f64, [f64; 2]), KernelRefusal> {
1196 let normal = meridian.plane_normal;
1197 let (nx, ny) = (normal.dot(x_axis), normal.dot(y_axis));
1198 let reach = nx.hypot(ny);
1199 let phi = ny.atan2(nx);
1200 let offset = meridian.plane_origin.sub(origin).dot(normal);
1201 let lean = (phi - meridian.azimuth).sin();
1202 let side = if radius * reach * lean.abs() > residual_tolerance {
1203 sense * lean
1204 } else {
1205 -offset
1206 };
1207 let shortfall = offset.abs() - new_radius * reach;
1208 if shortfall > tolerance {
1209 return Err(KernelRefusal::unsupported(
1210 KernelStage::Refine,
1211 "partial_separated",
1212 format!(
1213 "{op}: pushing partial cylinder face {face_id} by {distance} separates it from \
1214 neighbour face {} — the radius {new_radius:.6} carrier stops {shortfall:.3e} \
1215 short of that plane (refusing)",
1216 meridian.neighbour
1217 ),
1218 ));
1219 }
1220 let half = (offset / (new_radius * reach)).clamp(-1.0, 1.0).acos();
1221 let azimuth = if sense * side > 0.0 { phi - half } else { phi + half };
1222 Ok((azimuth, [phi - half, phi + half]))
1223 };
1224 let (start_azimuth, start_crossings) = solve(start, 1.0)?;
1225 let (end_azimuth, end_crossings) = solve(end, -1.0)?;
1226 let new_sweep = ccw(start_azimuth, end_azimuth);
1227 let angular_tolerance = tolerance / new_radius;
1228 if new_sweep <= angular_tolerance || new_sweep >= TAU - angular_tolerance {
1229 return Err(KernelRefusal::input(
1230 KernelStage::Refine,
1231 "arc_closed",
1232 format!(
1233 "{op}: pushing partial cylinder face {face_id} by {distance} closes its arc between \
1234 neighbour faces {} and {} — refusing",
1235 start.neighbour, end.neighbour
1236 ),
1237 ));
1238 }
1239 let crossing_margin = residual_tolerance / new_radius;
1252 let (station_low, station_high) = (rims[0].1.min(rims[1].1), rims[0].1.max(rims[1].1));
1253 for (meridian, crossings) in [(start, start_crossings), (end, end_crossings)] {
1254 for crossing in crossings {
1255 let along = ccw(start_azimuth, crossing);
1256 if along <= crossing_margin || along >= new_sweep - crossing_margin {
1257 continue;
1258 }
1259 let radial = x_axis.scale(crossing.cos()).add(y_axis.scale(crossing.sin())).scale(new_radius);
1260 let on_neighbour = meridian_meets_face(
1261 solid,
1262 meridian.neighbour,
1263 |fraction| {
1264 origin
1265 .add(axis.scale(station_low + fraction * (station_high - station_low)))
1266 .add(radial)
1267 },
1268 residual_tolerance,
1269 )?;
1270 if on_neighbour {
1271 return Err(KernelRefusal::unsupported(
1272 KernelStage::Refine,
1273 "arc_through_neighbour",
1274 format!(
1275 "{op}: pushing partial cylinder face {face_id} by {distance} carries its arc \
1276 through the plane of neighbour face {} — refusing",
1277 meridian.neighbour
1278 ),
1279 ));
1280 }
1281 }
1282 }
1283
1284 let direction = |azimuth: f64| x_axis.scale(azimuth.cos()).add(y_axis.scale(azimuth.sin()));
1286 let mut new_vertices: HashMap<u64, Vec3> = HashMap::default();
1287 let mut new_curves: HashMap<u64, NurbsCurve> = HashMap::default();
1288 for (meridian, azimuth) in [(start, start_azimuth), (end, end_azimuth)] {
1289 let edge = meridian.edge;
1290 let mut ends = [Vec3::default(); 2];
1291 for (slot, vertex_id) in [edge.start_vertex_id, edge.end_vertex_id].into_iter().enumerate()
1292 {
1293 ends[slot] = origin
1294 .add(axis.scale(stations[&vertex_id]))
1295 .add(direction(azimuth).scale(new_radius));
1296 new_vertices.insert(vertex_id, ends[slot]);
1297 }
1298 new_curves.insert(edge.id, make_line(ends[0], ends[1]).or_refuse(KernelStage::Fragment, "make_line")?);
1299 }
1300 let new_x = direction(start_azimuth);
1301 let new_y = axis.cross(new_x);
1302 for (edge, station) in &rims {
1303 let (head, tail) = (new_vertices[&edge.start_vertex_id], new_vertices[&edge.end_vertex_id]);
1304 let mut arc = crate::make_arc(
1305 origin.add(axis.scale(*station)),
1306 new_x,
1307 new_y,
1308 new_radius,
1309 0.0,
1310 new_sweep,
1311 ).or_refuse(KernelStage::Fragment, "make_arc")?;
1312 let [a0, _] = arc.domain().or_refuse(KernelStage::Fragment, "domain")?;
1313 let first = arc.evaluate(a0).or_refuse(KernelStage::Fragment, "evaluate")?;
1314 if first.sub(head).length() > first.sub(tail).length() {
1315 arc = arc.reversed().or_refuse(KernelStage::Fragment, "reversed")?;
1316 }
1317 new_curves.insert(edge.id, arc);
1318 }
1319
1320 for edge in &solid.edges {
1323 if new_curves.contains_key(&edge.id)
1324 || !(new_vertices.contains_key(&edge.start_vertex_id)
1325 || new_vertices.contains_key(&edge.end_vertex_id))
1326 {
1327 continue;
1328 }
1329 let position = |vertex_id: u64| {
1330 solid
1331 .vertices
1332 .iter()
1333 .find(|vertex| vertex.id == vertex_id)
1334 .map(|vertex| vertex.point)
1335 .ok_or_else(|| {
1336 KernelRefusal::internal(
1337 KernelStage::Fragment,
1338 "corner_vertex_pos",
1339 format!("{op}: missing vertex {vertex_id}"),
1340 )
1341 })
1342 };
1343 let old_run = position(edge.end_vertex_id)?.sub(position(edge.start_vertex_id)?);
1344 let new_start = match new_vertices.get(&edge.start_vertex_id) {
1345 Some(point) => *point,
1346 None => position(edge.start_vertex_id)?,
1347 };
1348 let new_end = match new_vertices.get(&edge.end_vertex_id) {
1349 Some(point) => *point,
1350 None => position(edge.end_vertex_id)?,
1351 };
1352 let new_run = new_end.sub(new_start);
1353 if new_run.length() <= tolerance || new_run.dot(old_run) <= 0.0 {
1354 return Err(KernelRefusal::unsupported(
1355 KernelStage::Fragment,
1356 "corner_past_edge",
1357 format!(
1358 "{op}: pushing partial cylinder face {face_id} by {distance} runs a corner past \
1359 the far end of edge {} — refusing",
1360 edge.id
1361 ),
1362 ));
1363 }
1364 }
1365
1366 let carrier = make_revolution(
1369 origin,
1370 axis,
1371 &make_line(
1372 origin
1373 .add(axis.scale(generatrix_z0))
1374 .add(new_x.scale(new_radius)),
1375 origin
1376 .add(axis.scale(generatrix_z1))
1377 .add(new_x.scale(new_radius)),
1378 ).or_refuse(KernelStage::Fragment, "make_line")?,
1379 new_sweep,
1380 ).or_refuse(KernelStage::Fragment, "make_revolution")?;
1381 let [c0, c1] = carrier.domain_u().or_refuse(KernelStage::Fragment, "domain_u")?;
1382 let [w0, w1] = carrier.domain_v().or_refuse(KernelStage::Fragment, "domain_v")?;
1383 let old_normal = face.surface.normal(u_mid, v_mid).or_refuse(KernelStage::Fragment, "normal")?;
1384 let old_point = face.surface.evaluate(u_mid, v_mid).or_refuse(KernelStage::Fragment, "evaluate")?;
1385 let new_normal = carrier.normal(0.5 * (c0 + c1), 0.5 * (w0 + w1)).or_refuse(KernelStage::Fragment, "normal")?;
1386 let new_point = carrier.evaluate(0.5 * (c0 + c1), 0.5 * (w0 + w1)).or_refuse(KernelStage::Fragment, "evaluate")?;
1387 if old_normal.dot(cylindrical(old_point).1) * new_normal.dot(cylindrical(new_point).1) <= 0.0 {
1388 return Err(KernelRefusal::internal(
1389 KernelStage::Refine,
1390 "carrier_normal",
1391 format!(
1392 "{op}: the offset carrier of partial cylinder face {face_id} flipped its normal — \
1393 refusing"
1394 ),
1395 ));
1396 }
1397
1398 let mut result = solid.clone();
1399 let pushed = &mut result.shells[shell_index].faces[face_index];
1400 for coedge in &mut pushed.loops[0].coedges {
1401 let mut pcurve = build_pcurve_on_surface(&carrier, &new_curves[&coedge.edge_id]).or_refuse(KernelStage::Sew, "build_pcurve_on_surface")?;
1402 if !coedge.forward {
1403 pcurve = pcurve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
1404 }
1405 coedge.pcurve = pcurve;
1406 }
1407 pushed.surface = carrier;
1408 retrim_planar_neighbours(
1409 solid,
1410 result,
1411 &edge_by_id,
1412 &new_curves,
1413 &new_vertices,
1414 planar_neighbours,
1415 residual_tolerance,
1416 scale,
1417 )
1418 .map(Some)
1419}
1420
1421#[allow(clippy::too_many_arguments)]
1427fn retrim_planar_neighbours(
1428 solid: &BrepSolid,
1429 mut result: BrepSolid,
1430 edge_by_id: &HashMap<u64, &EdgeRecord>,
1431 new_curves: &HashMap<u64, NurbsCurve>,
1432 new_vertices: &HashMap<u64, Vec3>,
1433 planar_neighbours: HashSet<u64>,
1434 residual_tolerance: f64,
1435 scale: f64,
1436) -> Result<BrepSolid, KernelRefusal> {
1437 for edge in &mut result.edges {
1438 if let Some(curve) = new_curves.get(&edge.id) {
1439 let [d0, d1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
1440 edge.curve = curve.clone();
1441 edge.t0 = d0;
1442 edge.t1 = d1;
1443 }
1444 }
1445 for vertex in &mut result.vertices {
1446 if let Some(point) = new_vertices.get(&vertex.id) {
1447 vertex.point = *point;
1448 }
1449 }
1450 let result_vertices: HashMap<u64, Vec3> = result
1454 .vertices
1455 .iter()
1456 .map(|vertex| (vertex.id, vertex.point))
1457 .collect();
1458 let mut cap_seams = HashSet::default();
1459 for neighbour in &planar_neighbours {
1460 let (ns, nf) = find_face(&result, *neighbour).ok_or_else(|| {
1461 KernelRefusal::internal(
1462 KernelStage::Sew,
1463 "cap_lookup",
1464 format!("offset_revolution_face: missing cap {neighbour}"),
1465 )
1466 })?;
1467 for loop_record in &result.shells[ns].faces[nf].loops {
1468 for coedge in &loop_record.coedges {
1469 let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
1470 KernelRefusal::internal(
1471 KernelStage::Sew,
1472 "missing_cap_edge",
1473 format!(
1474 "offset_revolution_face: missing cap edge {}",
1475 coedge.edge_id
1476 ),
1477 )
1478 })?;
1479 let touches_moved = new_vertices.contains_key(&edge.start_vertex_id)
1480 || new_vertices.contains_key(&edge.end_vertex_id);
1481 if touches_moved
1482 && !new_curves.contains_key(&edge.id)
1483 && !edge.degenerate
1484 && edge.curve.degree == 1
1485 {
1486 cap_seams.insert(edge.id);
1487 }
1488 }
1489 }
1490 }
1491 for seam_id in cap_seams {
1492 let source = edge_by_id[&seam_id];
1493 let curve = make_line(
1494 result_vertices[&source.start_vertex_id],
1495 result_vertices[&source.end_vertex_id],
1496 ).or_refuse(KernelStage::Sew, "make_line")?;
1497 let [d0, d1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
1498 if let Some(edge) = result.edges.iter_mut().find(|edge| edge.id == seam_id) {
1499 edge.curve = curve;
1500 edge.t0 = d0;
1501 edge.t1 = d1;
1502 }
1503 }
1504 let final_edges: HashMap<u64, EdgeRecord> = result
1505 .edges
1506 .iter()
1507 .map(|edge| (edge.id, edge.clone()))
1508 .collect();
1509 for neighbour in planar_neighbours {
1510 let (ns, nf) = find_face(&result, neighbour).ok_or_else(|| {
1511 KernelRefusal::internal(
1512 KernelStage::Sew,
1513 "retrim_cap",
1514 format!("offset_revolution_face: missing cap {neighbour}"),
1515 )
1516 })?;
1517 let plane = plane_of_surface(
1518 &result.shells[ns].faces[nf].surface,
1519 residual_tolerance,
1520 "offset_revolution_face",
1521 )?;
1522 retrim_planar_face(
1523 &mut result.shells[ns].faces[nf],
1524 &plane,
1525 &final_edges,
1526 scale,
1527 "offset_revolution_face",
1528 )
1529 .map_err(|error| {
1530 KernelRefusal::internal(
1531 KernelStage::Sew,
1532 "cap_retrim",
1533 format!("offset_revolution_face: cap retrim: {error}"),
1534 )
1535 })?;
1536 }
1537 let issues = result.validate();
1538 if !issues.is_empty() {
1539 return Err(KernelRefusal::internal(
1540 KernelStage::Validate,
1541 "validate",
1542 format!(
1543 "offset_revolution_face: pushed solid failed validation: {issues:?}"
1544 ),
1545 ));
1546 }
1547 if let (Ok(before), Ok(after)) =
1552 (crate::signed_volume_against(solid, 0.0), crate::signed_volume_against(&result, 0.0))
1553 {
1554 if before * after <= 0.0 {
1555 return Err(KernelRefusal::internal(
1556 KernelStage::Validate,
1557 INVERTED,
1558 "offset_revolution_face: the push inverts the solid — refusing",
1559 ));
1560 }
1561 }
1562 Ok(result)
1563}
1564
1565struct BoundaryTrace {
1572 path: Option<std::path::PathBuf>,
1573}
1574
1575impl BoundaryTrace {
1576 fn open(solid: &BrepSolid, face_id: u64, distance: f64) -> Self {
1577 let path = std::env::var_os("BREP_OFFSET_REVOLUTION_TRACE")
1578 .filter(|value| !value.is_empty() && value != "0")
1579 .map(std::path::PathBuf::from);
1580 let trace = Self { path };
1581 if trace.path.is_some() {
1582 let volume = solid_signed_volume(solid).map_or(f64::NAN, f64::abs);
1583 trace.write(format!(
1584 "call\tface={face_id}\tdistance={distance}\tscale={}\tvolume_before={volume:.15}",
1585 solid_model_scale(solid)
1586 ));
1587 }
1588 trace
1589 }
1590
1591 fn write(&self, line: String) {
1595 use std::io::Write;
1596 let Some(path) = &self.path else { return };
1597 let thread = std::thread::current();
1598 let record = format!("{}\t{line}\n", thread.name().unwrap_or("?"));
1599 if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
1600 let _ = file.write_all(record.as_bytes());
1601 }
1602 }
1603
1604 fn off_plane(
1607 curve: &NurbsCurve,
1608 t0: f64,
1609 t1: f64,
1610 plane: &crate::offset_retrim::Plane,
1611 ) -> (f64, f64) {
1612 let distance = |t: f64| {
1613 curve
1614 .evaluate(t)
1615 .map_or(f64::NAN, |point| point.sub(plane.origin).dot(plane.normal).abs())
1616 };
1617 let ends = distance(t0).max(distance(t1));
1618 let samples = (0..=32)
1619 .map(|index| distance(t0 + (t1 - t0) * index as f64 / 32.0))
1620 .fold(0.0f64, f64::max);
1621 (ends, samples)
1622 }
1623
1624 fn edge(
1625 &self,
1626 kind: &str,
1627 edge: &EdgeRecord,
1628 neighbour: Option<&u64>,
1629 plane: Option<&crate::offset_retrim::Plane>,
1630 rebuilt: &NurbsCurve,
1631 ) {
1632 if self.path.is_none() {
1633 return;
1634 }
1635 let Some(plane) = plane else {
1636 self.write(format!("edge\t{}\t{kind}\tneighbour=none", edge.id));
1637 return;
1638 };
1639 let (before_ends, before_samples) = Self::off_plane(&edge.curve, edge.t0, edge.t1, plane);
1640 let (after_ends, after_samples) = match rebuilt.domain() {
1641 Ok([r0, r1]) => Self::off_plane(rebuilt, r0, r1, plane),
1642 Err(_) => (f64::NAN, f64::NAN),
1643 };
1644 self.write(format!(
1645 "edge\t{}\t{kind}\tneighbour={}\tbefore_vertices={before_ends:.3e}\t\
1646 before_samples={before_samples:.3e}\tafter_vertices={after_ends:.3e}\t\
1647 after_samples={after_samples:.3e}",
1648 edge.id,
1649 neighbour.map_or("none".to_string(), u64::to_string)
1650 ));
1651 }
1652
1653 fn outcome(&self, outcome: &Result<BrepSolid, KernelRefusal>) {
1654 if self.path.is_none() {
1655 return;
1656 }
1657 match outcome {
1658 Ok(result) => {
1659 let volume = solid_signed_volume(result).map_or(f64::NAN, f64::abs);
1660 self.write(format!("ok\tvolume_after={volume:.15}"));
1661 }
1662 Err(error) => self.write(format!("refused\t{error}")),
1663 }
1664 }
1665}
1666