1use super::*;
2
3fn offset_ruled_carrier(
38 surface: &NurbsSurface,
39 signed_distance: f64,
40 tolerance: f64,
41) -> Result<NurbsSurface, String> {
42 let Some(AnalyticSurface::RuledRevolution {
43 frame,
44 rho0,
45 rho1,
46 height,
47 }) = surface.analytic()
48 else {
49 return Err("offset_ruled_carrier: face carrier is not a ruled revolution".into());
50 };
51 let (frame, rho0, rho1, height) = (frame.clone(), *rho0, *rho1, *height);
52 let slope = (rho1 - rho0) / height;
53 let grow = signed_distance * (1.0 + slope * slope).sqrt();
54 let rho0_new = rho0 + grow;
55 let rho1_new = rho1 + grow;
56 if rho0_new <= tolerance || rho1_new <= tolerance {
57 return Err(
58 "offset (push): the ruled carrier collapses to or past its axis — refusing".into(),
59 );
60 }
61 let start = frame.origin.add(frame.x_axis.scale(rho0_new));
63 let end = frame
64 .origin
65 .add(frame.axis.scale(height))
66 .add(frame.x_axis.scale(rho1_new));
67 let generatrix = make_line(start, end)?;
68 make_revolution(frame.origin, frame.axis, &generatrix, std::f64::consts::TAU)
69}
70
71fn outward_normal_mid(face: &FaceRecord) -> Result<(Vec3, Vec3), String> {
74 let [u0, u1] = face.surface.domain_u()?;
75 let [v0, v1] = face.surface.domain_v()?;
76 let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
77 let point = face.surface.evaluate(um, vm)?;
78 let mut normal = face.surface.normal(um, vm)?;
79 if !face.same_sense {
80 normal = normal.scale(-1.0);
81 }
82 Ok((point, normal))
83}
84
85pub fn offset_ruled_face(
96 solid: &BrepSolid,
97 face_id: u64,
98 distance: f64,
99) -> Result<BrepSolid, String> {
100 if !distance.is_finite() {
101 return Err("offset_ruled_face: distance must be finite".into());
102 }
103 let scale = solid_model_scale(solid);
104 let tolerance = (scale * 1e-7).max(1e-9);
105 let plane_tolerance = (scale * 1e-6).max(1e-7);
106
107 let (pshell, pface) =
108 find_face(solid, face_id).ok_or_else(|| format!("offset_ruled_face: no face {face_id}"))?;
109 let pushed = &solid.shells[pshell].faces[pface];
110 let Some(AnalyticSurface::RuledRevolution { frame, .. }) = pushed.surface.analytic() else {
111 return Err("offset_ruled_face: the pushed face is not a cylinder or cone".into());
112 };
113 let (frame_origin, frame_axis) = (frame.origin, frame.axis);
114
115 let (mid_point, mid_normal) = outward_normal_mid(pushed)?;
119 let radial = {
120 let d = mid_point.sub(frame_origin);
121 d.sub(frame_axis.scale(d.dot(frame_axis)))
122 };
123 if radial.length() <= tolerance {
124 return Err("offset_ruled_face: degenerate radial direction (face on the axis)".into());
125 }
126 let outward_sign = if mid_normal.dot(radial) >= 0.0 { 1.0 } else { -1.0 };
127 let signed_distance = distance * outward_sign;
128
129 let s_prime = offset_ruled_carrier(&pushed.surface, signed_distance, tolerance)?;
130
131 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
135 for shell in &solid.shells {
136 for face in &shell.faces {
137 for loop_record in &face.loops {
138 for coedge in &loop_record.coedges {
139 faces_of_edge
140 .entry(coedge.edge_id)
141 .or_default()
142 .push(face.id);
143 }
144 }
145 }
146 }
147 let edge_by_id: HashMap<u64, &EdgeRecord> =
148 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
149
150 let mut new_curve: HashMap<u64, NurbsCurve> = HashMap::default();
151 let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
152 let mut cap_faces: HashSet<u64> = HashSet::default();
153 let mut ruled_faces: HashSet<u64> = HashSet::default();
157 let mut curved_faces: HashSet<u64> = HashSet::default();
167 let mut seam_edges: Vec<u64> = Vec::new();
168 let vertex_pos: HashMap<u64, Vec3> =
171 solid.vertices.iter().map(|v| (v.id, v.point)).collect();
172
173 let mut open_rims: Vec<OpenRim> = Vec::new();
177 let mut rim_edges: Vec<u64> = Vec::new();
181
182 let mut hole_edges: HashSet<u64> = HashSet::default();
194 for loop_record in &pushed.loops {
195 let Some(hole) = single_neighbour_hole(
196 loop_record,
197 face_id,
198 solid,
199 &faces_of_edge,
200 &edge_by_id,
201 frame_origin,
202 frame_axis,
203 scale,
204 )?
205 else {
206 continue;
207 };
208 rebuild_single_neighbour_hole(
209 solid,
210 &s_prime,
211 &hole,
212 &edge_by_id,
213 &vertex_pos,
214 tolerance,
215 scale,
216 &mut new_curve,
217 &mut new_vertex,
218 )?;
219 for edge_id in &hole.edge_ids {
220 hole_edges.insert(*edge_id);
221 if !rim_edges.contains(edge_id) {
222 rim_edges.push(*edge_id);
223 }
224 }
225 curved_faces.insert(hole.neighbour);
226 }
227
228 for loop_record in &pushed.loops {
229 let coedge_count = loop_record.coedges.len();
230 for coedge_index in 0..coedge_count {
231 let coedge = &loop_record.coedges[coedge_index];
232 let edge = *edge_by_id
233 .get(&coedge.edge_id)
234 .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
235 if hole_edges.contains(&edge.id) {
236 continue; }
238 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
239 let is_seam = incident.iter().all(|f| *f == face_id);
240 if is_seam {
241 if !seam_edges.contains(&edge.id) {
242 seam_edges.push(edge.id);
243 }
244 continue;
245 }
246 let neighbour = *incident
267 .iter()
268 .find(|f| **f != face_id)
269 .ok_or_else(|| format!("offset_ruled_face: edge {} has no neighbour", edge.id))?;
270 let (nshell, nface) = find_face(solid, neighbour)
271 .ok_or_else(|| format!("offset_ruled_face: missing neighbour {neighbour}"))?;
272 let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
273 let is_plane =
274 matches!(neighbour_surface.analytic(), Some(AnalyticSurface::Plane { .. }));
275 let is_coaxial_ruled =
276 ruled_neighbour_is_coaxial(neighbour_surface, frame_origin, frame_axis, scale);
277 let is_curved = !is_plane && !is_coaxial_ruled;
278 let separated = || {
279 "offset_ruled_face: the pushed carrier no longer meets a neighbour \
280 (the push separated them, or the rim left the neighbour's domain) — refusing"
281 .to_string()
282 };
283 let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
286 let mut marched = false;
287 let curves = if is_curved {
288 if edge.start_vertex_id != edge.end_vertex_id {
289 return Err(format!(
290 "offset_ruled_face: the rim against curved neighbour {neighbour} is an \
291 OPEN arc (edge {}); an arc's corner is a triple point solved against a \
292 PLANE only — deferred (refusing)",
293 edge.id
294 ));
295 }
296 let policy = MarchPolicy {
297 tolerance,
298 residual_tolerance: (scale * 5e-4).max(5e-6),
304 seeds: edge_seeds(edge, 9)?,
307 };
308 match reintersect_carriers(&s_prime, neighbour_surface, &policy) {
309 Ok(found) => {
310 marched = found.lane == RimLane::Marched;
311 if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
312 eprintln!(
313 "RIM neighbour {neighbour}: lane {:?}, {} branch(es), \
314 residual {:.3e} (gate {:.3e})",
315 found.lane,
316 found.sections.len(),
317 found.residual,
318 policy.residual_tolerance
319 );
320 }
321 found.curves()
322 }
323 Err(ReintersectRefusal::Separated) => return Err(separated()),
324 Err(other) => {
325 return Err(format!("offset_ruled_face: {}", other.describe()));
326 }
327 }
328 } else {
329 intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
333 .filter(|curves| !curves.is_empty())
334 .ok_or_else(separated)?
335 };
336 let rim = nearest_curve(&curves, old_mid)?;
337 if !rim_edges.contains(&edge.id) {
338 rim_edges.push(edge.id);
339 }
340 if edge.start_vertex_id == edge.end_vertex_id {
341 let rim = if marched {
344 match_marched_rim_direction(rim, edge)?
349 } else {
350 match_closed_rim_direction(rim, edge)?
351 };
352 let seam_point = if marched {
362 let [d0, _] = rim.domain()?;
363 rim.evaluate(d0)?
364 } else {
365 rim.evaluate(0.0)?
366 };
367 new_vertex.insert(edge.start_vertex_id, seam_point);
368 new_vertex.insert(edge.end_vertex_id, seam_point);
369 new_curve.insert(edge.id, rim);
370 } else {
371 let previous =
379 &loop_record.coedges[(coedge_index + coedge_count - 1) % coedge_count];
380 let next = &loop_record.coedges[(coedge_index + 1) % coedge_count];
381 let (at_start, at_end) = if coedge.forward {
382 (previous, next)
383 } else {
384 (next, previous)
385 };
386 let mut resolve = |adjacent_coedge: &CoedgeRecord,
387 vertex_id: u64|
388 -> Result<RimEnd, String> {
389 let adjacent = *edge_by_id.get(&adjacent_coedge.edge_id).ok_or_else(|| {
390 format!(
391 "offset_ruled_face: missing edge {}",
392 adjacent_coedge.edge_id
393 )
394 })?;
395 let old_point = vertex_pos
396 .get(&vertex_id)
397 .copied()
398 .ok_or_else(|| format!("offset_ruled_face: missing vertex {vertex_id}"))?;
399 let (end, point) = resolve_open_rim_end(
400 solid,
401 face_id,
402 &faces_of_edge,
403 &rim,
404 adjacent,
405 old_point,
406 plane_tolerance,
407 )?;
408 match new_vertex.get(&vertex_id).copied() {
412 Some(existing) if existing.sub(point).length() > plane_tolerance => {
413 return Err(
414 "offset_ruled_face: the two rims meeting at a multi-loop corner \
415 disagree on its new position — refusing"
416 .into(),
417 )
418 }
419 Some(_) => {}
420 None => {
421 new_vertex.insert(vertex_id, point);
422 }
423 }
424 Ok(end)
425 };
426 let start = resolve(at_start, edge.start_vertex_id)?;
427 let end = resolve(at_end, edge.end_vertex_id)?;
428 open_rims.push(OpenRim {
429 edge_id: edge.id,
430 conic: rim,
431 start,
432 end,
433 old_mid,
434 start_vertex_id: edge.start_vertex_id,
435 end_vertex_id: edge.end_vertex_id,
436 });
437 }
438 if is_plane {
439 cap_faces.insert(neighbour);
440 } else if is_coaxial_ruled {
441 ruled_faces.insert(neighbour);
442 } else {
443 curved_faces.insert(neighbour);
444 }
445 }
446 }
447
448 for rim in &open_rims {
452 let [d0, d1] = rim.conic.domain()?;
453 let middle = project_point_to_curve(&rim.conic, rim.old_mid)?.u;
454 let (from, to) = match (rim.start, rim.end) {
455 (RimEnd::Corner(a), RimEnd::Corner(b)) => {
456 let (low, high) = if a <= b { (a, b) } else { (b, a) };
457 if middle < low || middle > high {
458 return Err(
459 "offset_ruled_face: a multi-loop rim arc wraps the pushed carrier's \
460 periodic seam — deferred (refusing)"
461 .into(),
462 );
463 }
464 (low, high)
465 }
466 (RimEnd::Seam, RimEnd::Corner(corner)) | (RimEnd::Corner(corner), RimEnd::Seam) => {
467 if middle < corner {
468 (d0, corner)
469 } else {
470 (corner, d1)
471 }
472 }
473 (RimEnd::Seam, RimEnd::Seam) => {
474 return Err(
475 "offset_ruled_face: a multi-loop rim arc ends on the seam at BOTH ends — \
476 refusing"
477 .into(),
478 )
479 }
480 };
481 let mut trimmed = subcurve(&rim.conic, from, to)?;
482 let start_point = *new_vertex.get(&rim.start_vertex_id).ok_or_else(|| {
483 "offset_ruled_face: a multi-loop rim corner was not relocated — refusing".to_string()
484 })?;
485 let end_point = *new_vertex.get(&rim.end_vertex_id).ok_or_else(|| {
486 "offset_ruled_face: a multi-loop rim corner was not relocated — refusing".to_string()
487 })?;
488 let [t0, _] = trimmed.domain()?;
489 let head = trimmed.evaluate(t0)?;
490 if head.sub(start_point).length() > head.sub(end_point).length() {
491 trimmed = trimmed.reversed()?;
492 }
493 new_curve.insert(rim.edge_id, trimmed);
494 }
495
496 let pos = |vid: u64| -> Vec3 {
497 new_vertex
498 .get(&vid)
499 .copied()
500 .unwrap_or_else(|| vertex_pos[&vid])
501 };
502
503 for seam_id in &seam_edges {
510 let seam = *edge_by_id
511 .get(seam_id)
512 .ok_or_else(|| format!("offset_ruled_face: missing seam edge {seam_id}"))?;
513 let start = *new_vertex.get(&seam.start_vertex_id).ok_or_else(|| {
514 "offset_ruled_face: seam endpoint was not relocated by a rim — refusing".to_string()
515 })?;
516 let end = *new_vertex.get(&seam.end_vertex_id).ok_or_else(|| {
517 "offset_ruled_face: seam endpoint was not relocated by a rim — refusing".to_string()
518 })?;
519 new_curve.insert(*seam_id, make_line(start, end)?);
520 }
521
522 for ruled in &ruled_faces {
528 let (nshell, nface) = find_face(solid, *ruled)
529 .ok_or_else(|| format!("offset_ruled_face: missing ruled neighbour {ruled}"))?;
530 for loop_record in &solid.shells[nshell].faces[nface].loops {
531 for coedge in &loop_record.coedges {
532 let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
533 format!("offset_ruled_face: missing edge {}", coedge.edge_id)
534 })?;
535 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
536 let is_seam = incident.iter().all(|f| *f == *ruled);
537 let touches_moved = new_vertex.contains_key(&edge.start_vertex_id)
538 || new_vertex.contains_key(&edge.end_vertex_id);
539 if is_seam && touches_moved && !new_curve.contains_key(&edge.id) {
540 new_curve.insert(
541 edge.id,
542 make_line(pos(edge.start_vertex_id), pos(edge.end_vertex_id))?,
543 );
544 }
545 }
546 }
547 }
548
549 let mut new_range: HashMap<u64, (f64, f64)> = HashMap::default();
558 for curved in &curved_faces {
559 let (nshell, nface) = find_face(solid, *curved)
560 .ok_or_else(|| format!("offset_ruled_face: missing curved neighbour {curved}"))?;
561 for loop_record in &solid.shells[nshell].faces[nface].loops {
562 for coedge in &loop_record.coedges {
563 let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
564 format!("offset_ruled_face: missing edge {}", coedge.edge_id)
565 })?;
566 if new_curve.contains_key(&edge.id) || new_range.contains_key(&edge.id) {
567 continue;
568 }
569 let start_moved = new_vertex.get(&edge.start_vertex_id).copied();
570 let end_moved = new_vertex.get(&edge.end_vertex_id).copied();
571 if start_moved.is_none() && end_moved.is_none() {
572 continue;
573 }
574 if edge.start_vertex_id == edge.end_vertex_id {
575 return Err(format!(
580 "offset_ruled_face: the push moved the vertex of curved neighbour \
581 {curved}'s CLOSED edge {} — deferred (refusing)",
582 edge.id
583 ));
584 }
585 let mut range = (edge.t0, edge.t1);
586 for (moved, slot) in [(start_moved, 0usize), (end_moved, 1usize)] {
587 let Some(point) = moved else { continue };
588 let projection = project_point_to_curve(&edge.curve, point)?;
589 if projection.distance > plane_tolerance {
590 return Err(format!(
591 "offset_ruled_face: a relocated rim vertex left curved neighbour \
592 {curved}'s edge {} (off by {:.3e}) — refusing",
593 edge.id, projection.distance
594 ));
595 }
596 if slot == 0 {
597 range.0 = projection.u;
598 } else {
599 range.1 = projection.u;
600 }
601 }
602 new_range.insert(edge.id, range);
603 }
604 }
605 }
606 if !curved_faces.is_empty() {
611 for edge in &solid.edges {
612 if new_curve.contains_key(&edge.id) || new_range.contains_key(&edge.id) {
613 continue;
614 }
615 if new_vertex.contains_key(&edge.start_vertex_id)
616 || new_vertex.contains_key(&edge.end_vertex_id)
617 {
618 return Err(format!(
619 "offset_ruled_face: relocating a curved neighbour's rim moved the end of \
620 edge {}, which this push does not rebuild — refusing",
621 edge.id
622 ));
623 }
624 }
625 }
626
627 if !open_rims.is_empty() {
635 let mut corner_edges: Vec<(u64, NurbsCurve)> = Vec::new();
636 for edge in &solid.edges {
637 if new_curve.contains_key(&edge.id) {
638 continue;
639 }
640 if !new_vertex.contains_key(&edge.start_vertex_id)
641 && !new_vertex.contains_key(&edge.end_vertex_id)
642 {
643 continue;
644 }
645 if edge.curve.degree != 1 || edge.curve.control_points.len() != 2 {
646 return Err(
647 "offset_ruled_face: the push moved the end of a CURVED edge that is not a \
648 rebuilt rim — refusing"
649 .into(),
650 );
651 }
652 let start = pos(edge.start_vertex_id);
653 let end = pos(edge.end_vertex_id);
654 for neighbour in faces_of_edge.get(&edge.id).cloned().unwrap_or_default() {
655 if !cap_faces.contains(&neighbour) {
656 return Err(
657 "offset_ruled_face: a relocated corner borders a face this push does not \
658 re-trim — refusing"
659 .into(),
660 );
661 }
662 let (nshell, nface) = find_face(solid, neighbour).ok_or_else(|| {
663 format!("offset_ruled_face: missing neighbour {neighbour}")
664 })?;
665 let plane = plane_of_surface(
666 &solid.shells[nshell].faces[nface].surface,
667 plane_tolerance,
668 "offset_ruled_face",
669 )?;
670 for point in [start, end] {
671 if point.sub(plane.origin).dot(plane.normal).abs() > plane_tolerance {
672 return Err(
673 "offset_ruled_face: a relocated corner left one of its fixed \
674 neighbour planes — refusing"
675 .into(),
676 );
677 }
678 }
679 }
680 corner_edges.push((edge.id, make_line(start, end)?));
681 }
682 for (edge_id, curve) in corner_edges {
683 new_curve.insert(edge_id, curve);
684 }
685 }
686
687 let changed_edges: HashSet<u64> = new_curve
690 .keys()
691 .chain(new_range.keys())
692 .copied()
693 .collect();
694
695 let mut result = solid.clone();
697 for edge in &mut result.edges {
698 if let Some(curve) = new_curve.get(&edge.id) {
699 let [d0, d1] = curve.domain()?;
704 edge.curve = curve.clone();
705 edge.t0 = d0;
706 edge.t1 = d1;
707 } else if let Some((t0, t1)) = new_range.get(&edge.id) {
708 edge.t0 = *t0;
710 edge.t1 = *t1;
711 }
712 }
713 for vertex in &mut result.vertices {
714 if let Some(point) = new_vertex.get(&vertex.id) {
715 vertex.point = *point;
716 }
717 }
718 result.shells[pshell].faces[pface].surface = s_prime;
723
724 let final_edges: HashMap<u64, EdgeRecord> =
725 result.edges.iter().map(|e| (e.id, e.clone())).collect();
726
727 if !ruled_faces.is_empty() {
740 retrim_offset_ruled_face(&mut result, face_id, &final_edges, tolerance)?;
741 for ruled in &ruled_faces {
742 retrim_offset_ruled_face(&mut result, *ruled, &final_edges, tolerance)?;
743 }
744 for curved in &curved_faces {
745 refit_changed_pcurves(&mut result, *curved, &final_edges, &changed_edges, tolerance)?;
746 }
747 } else if !curved_faces.is_empty() {
748 let (gshell, gface) = find_face(&result, face_id)
752 .ok_or_else(|| format!("offset_ruled_face: missing face {face_id}"))?;
753 let samples = boundary_samples(
754 &result.shells[gshell].faces[gface],
755 &final_edges,
756 "offset_ruled_face",
757 )?;
758 extend_ruled_neighbour_over(&mut result, face_id, &samples, tolerance)?;
759 refit_changed_pcurves(&mut result, face_id, &final_edges, &changed_edges, tolerance)?;
760 for curved in &curved_faces {
761 refit_changed_pcurves(&mut result, *curved, &final_edges, &changed_edges, tolerance)?;
762 }
763 } else if !open_rims.is_empty() {
764 let rebuilt: HashSet<u64> = rim_edges.iter().copied().collect();
779 let surface = result.shells[pshell].faces[pface].surface.clone();
780 let [u_start, u_end] = surface.domain_u()?;
781 let u_period = u_end - u_start;
782 for loop_record in &mut result.shells[pshell].faces[pface].loops {
783 for coedge in &mut loop_record.coedges {
784 if !rebuilt.contains(&coedge.edge_id) {
785 continue;
786 }
787 let edge = final_edges
788 .get(&coedge.edge_id)
789 .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
790 let mut pcurve = build_pcurve_on_surface(&surface, &edge.curve)?;
791 if !coedge.forward {
792 pcurve = pcurve.reversed()?;
793 }
794 coedge.pcurve = reanchor_pcurve_u(&pcurve, &coedge.pcurve, u_period)?;
795 }
796 }
797 }
798
799 for cap in &cap_faces {
801 let (cshell, cface) = find_face(&result, *cap)
802 .ok_or_else(|| format!("offset_ruled_face: missing cap {cap}"))?;
803 let plane = plane_of_surface(
804 &result.shells[cshell].faces[cface].surface,
805 plane_tolerance,
806 "offset_ruled_face",
807 )?;
808 retrim_planar_face(
809 &mut result.shells[cshell].faces[cface],
810 &plane,
811 &final_edges,
812 scale,
813 "offset_ruled_face",
814 )?;
815 }
816
817 let issues = result.validate();
818 if !issues.is_empty() {
819 return Err(format!(
820 "offset_ruled_face: pushed solid failed validation: {issues:?}"
821 ));
822 }
823 if let (Ok(before), Ok(after)) =
824 (solid_signed_volume(solid), solid_signed_volume(&result))
825 {
826 if before * after <= 0.0 {
827 return Err("offset_ruled_face: the push inverts the solid — refusing".into());
828 }
829 }
830 Ok(result)
831}
832
833#[derive(Clone, Copy)]
835enum RimEnd {
836 Corner(f64),
840 Seam,
845}
846
847struct OpenRim {
849 edge_id: u64,
850 conic: NurbsCurve,
852 start: RimEnd,
853 end: RimEnd,
854 old_mid: Vec3,
857 start_vertex_id: u64,
858 end_vertex_id: u64,
859}
860
861fn resolve_open_rim_end(
877 solid: &BrepSolid,
878 face_id: u64,
879 faces_of_edge: &HashMap<u64, Vec<u64>>,
880 conic: &NurbsCurve,
881 adjacent: &EdgeRecord,
882 old_point: Vec3,
883 plane_tolerance: f64,
884) -> Result<(RimEnd, Vec3), String> {
885 let incident = faces_of_edge.get(&adjacent.id).cloned().unwrap_or_default();
886 if incident.iter().all(|f| *f == face_id) {
887 let [d0, _] = conic.domain()?;
888 return Ok((RimEnd::Seam, conic.evaluate(d0)?));
889 }
890 let other = *incident
891 .iter()
892 .find(|f| **f != face_id)
893 .ok_or_else(|| format!("offset_ruled_face: edge {} has no neighbour", adjacent.id))?;
894 let (nshell, nface) = find_face(solid, other)
895 .ok_or_else(|| format!("offset_ruled_face: missing neighbour {other}"))?;
896 let plane = plane_of_surface(
897 &solid.shells[nshell].faces[nface].surface,
898 plane_tolerance,
899 "offset_ruled_face",
900 )?;
901 if curve_lies_in_plane(conic, &plane, plane_tolerance)? {
902 let projection = project_point_to_curve(conic, old_point)?;
903 return Ok((RimEnd::Corner(projection.u), conic.evaluate(projection.u)?));
904 }
905 let mut best: Option<(f64, f64)> = None;
906 for parameter in plane_crossing_params(conic, &plane)? {
907 let distance = conic.evaluate(parameter)?.sub(old_point).length();
908 if best.map(|(best, _)| distance < best).unwrap_or(true) {
909 best = Some((distance, parameter));
910 }
911 }
912 let (_, parameter) = best.ok_or_else(|| {
913 "offset_ruled_face: a multi-loop rim corner no longer meets its adjacent neighbour \
914 (the push pulled the window off it) — refusing"
915 .to_string()
916 })?;
917 Ok((RimEnd::Corner(parameter), conic.evaluate(parameter)?))
918}
919
920fn curve_lies_in_plane(
924 curve: &NurbsCurve,
925 plane: &Plane,
926 plane_tolerance: f64,
927) -> Result<bool, String> {
928 let [d0, d1] = curve.domain()?;
929 for step in 0..=16 {
930 let t = d0 + (d1 - d0) * step as f64 / 16.0;
931 if curve
932 .evaluate(t)?
933 .sub(plane.origin)
934 .dot(plane.normal)
935 .abs()
936 > plane_tolerance
937 {
938 return Ok(false);
939 }
940 }
941 Ok(true)
942}
943
944fn plane_crossing_params(curve: &NurbsCurve, plane: &Plane) -> Result<Vec<f64>, String> {
950 let [d0, d1] = curve.domain()?;
951 let signed = |t: f64| -> Result<f64, String> {
952 Ok(curve.evaluate(t)?.sub(plane.origin).dot(plane.normal))
953 };
954 const SAMPLES: usize = 512;
955 let mut roots = Vec::new();
956 let mut previous = (d0, signed(d0)?);
957 for index in 1..=SAMPLES {
958 let t = d0 + (d1 - d0) * index as f64 / SAMPLES as f64;
959 let value = signed(t)?;
960 if previous.1 == 0.0 {
961 roots.push(previous.0);
962 } else if (previous.1 < 0.0) != (value < 0.0) {
963 let (mut low, mut high) = (previous.0, t);
964 let mut low_value = previous.1;
965 for _ in 0..100 {
966 let middle = 0.5 * (low + high);
967 if middle <= low || middle >= high {
968 break;
969 }
970 let middle_value = signed(middle)?;
971 if (low_value < 0.0) != (middle_value < 0.0) {
972 high = middle;
973 } else {
974 low = middle;
975 low_value = middle_value;
976 }
977 }
978 roots.push(0.5 * (low + high));
979 }
980 previous = (t, value);
981 }
982 if previous.1 == 0.0 {
983 roots.push(previous.0);
984 }
985 Ok(roots)
986}
987
988fn subcurve(curve: &NurbsCurve, from: f64, to: f64) -> Result<NurbsCurve, String> {
992 let [d0, d1] = curve.domain()?;
993 let span = (d1 - d0).max(1e-12);
994 if to - from <= 1e-9 * span {
995 return Err("offset_ruled_face: a rebuilt rim arc collapsed to a point — refusing".into());
996 }
997 let mut trimmed = curve.clone();
998 if to < d1 - 1e-9 * span {
999 trimmed = trimmed.split(to)?.0;
1000 }
1001 if from > d0 + 1e-9 * span {
1002 trimmed = trimmed.split(from)?.1;
1003 }
1004 Ok(trimmed)
1005}
1006
1007fn match_closed_rim_direction(
1025 rim: NurbsCurve,
1026 previous: &EdgeRecord,
1027) -> Result<NurbsCurve, String> {
1028 let [d0, _] = rim.domain()?;
1029 let incoming = previous.curve.derivatives(previous.t0, 1)?;
1030 let rebuilt = rim.derivatives(d0, 1)?;
1031 if incoming[1].dot(rebuilt[1]) < 0.0 {
1032 return rim.reversed();
1033 }
1034 Ok(rim)
1035}
1036
1037fn reanchor_pcurve_u(
1055 pcurve: &NurbsCurve,
1056 previous: &NurbsCurve,
1057 u_period: f64,
1058) -> Result<NurbsCurve, String> {
1059 if !(u_period.is_finite() && u_period > 0.0) {
1060 return Ok(pcurve.clone());
1061 }
1062 let [a0, a1] = pcurve.domain()?;
1063 let [b0, b1] = previous.domain()?;
1064 let shift = ((previous.evaluate(b0)?.x - pcurve.evaluate(a0)?.x) / u_period).round() * u_period;
1065 let shifted = if shift == 0.0 {
1066 pcurve.clone()
1067 } else {
1068 let controls = pcurve
1069 .control_points
1070 .iter()
1071 .map(|control| {
1072 let mut point = control.point()?;
1073 point.x += shift;
1074 Ok(crate::Vec4::from_point(point, control.w))
1075 })
1076 .collect::<Result<Vec<_>, String>>()?;
1077 NurbsCurve::new(pcurve.degree, pcurve.knots.clone(), controls)?
1078 };
1079 let end_drift = (shifted.evaluate(a1)?.x - previous.evaluate(b1)?.x).abs();
1083 if end_drift > 0.25 * u_period {
1084 return Err(format!(
1085 "offset_ruled_face: a rebuilt rim traverses the carrier's periodic parameter \
1086 differently from the edge it replaces (end drift {end_drift}) — refusing"
1087 ));
1088 }
1089 Ok(shifted)
1090}
1091
1092pub(super) fn ruled_neighbour_is_coaxial(
1098 neighbour: &NurbsSurface,
1099 axis_origin: Vec3,
1100 axis_dir: Vec3,
1101 scale: f64,
1102) -> bool {
1103 let Some(AnalyticSurface::RuledRevolution { frame, .. }) = neighbour.analytic() else {
1104 return false;
1105 };
1106 if frame.axis.dot(axis_dir).abs() < 1.0 - 1e-9 {
1107 return false;
1108 }
1109 let offset = frame.origin.sub(axis_origin);
1110 let perpendicular = offset.sub(axis_dir.scale(offset.dot(axis_dir)));
1111 perpendicular.length() <= 1e-9 * scale.max(1.0)
1112}
1113
1114pub(super) fn retrim_offset_ruled_face(
1123 result: &mut BrepSolid,
1124 face_id: u64,
1125 final_edges: &HashMap<u64, EdgeRecord>,
1126 tolerance: f64,
1127) -> Result<(), String> {
1128 let (shell, face_pos) = find_face(result, face_id)
1129 .ok_or_else(|| format!("offset_ruled_face: missing ruled face {face_id}"))?;
1130 retrim_face_in_solid(
1131 result,
1132 shell,
1133 face_pos,
1134 final_edges,
1135 |solid, points| extend_ruled_neighbour_over(solid, face_id, points, tolerance),
1136 PcurveFit::SubrangeAware { tolerance },
1137 "offset_ruled_face",
1138 )
1139}
1140
1141struct SingleNeighbourHole {
1143 neighbour: u64,
1144 edge_ids: Vec<u64>,
1146 pinned: Vec<(u64, u64)>,
1152}
1153
1154fn seam_axial_plane(surface: &NurbsSurface, seam: &EdgeRecord) -> Option<Plane> {
1163 let structure = crate::revolution_structure(surface)?;
1164 let midpoint = seam
1165 .curve
1166 .evaluate(0.5 * (seam.t0 + seam.t1))
1167 .ok()?;
1168 let offset = midpoint.sub(structure.frame.origin);
1169 let radial = offset
1170 .sub(structure.frame.axis.scale(offset.dot(structure.frame.axis)))
1171 .normalized()
1172 .ok()?;
1173 let normal = structure.frame.axis.cross(radial).normalized().ok()?;
1174 Some(Plane {
1175 origin: structure.frame.origin,
1176 u_dir: structure.frame.axis,
1177 v_dir: radial,
1178 normal,
1179 })
1180}
1181
1182#[allow(clippy::too_many_arguments)]
1202fn single_neighbour_hole(
1203 loop_record: &crate::topology::LoopRecord,
1204 face_id: u64,
1205 solid: &BrepSolid,
1206 faces_of_edge: &HashMap<u64, Vec<u64>>,
1207 edge_by_id: &HashMap<u64, &EdgeRecord>,
1208 frame_origin: Vec3,
1209 frame_axis: Vec3,
1210 scale: f64,
1211) -> Result<Option<SingleNeighbourHole>, String> {
1212 let debug = std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok();
1213 let mut neighbour: Option<u64> = None;
1214 let mut edge_ids: Vec<u64> = Vec::new();
1215 for coedge in &loop_record.coedges {
1216 let edge = *edge_by_id
1217 .get(&coedge.edge_id)
1218 .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
1219 if edge.start_vertex_id == edge.end_vertex_id {
1220 if debug { eprintln!("HOLE reject: edge {} is closed", edge.id); }
1221 return Ok(None); }
1223 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
1224 let others: Vec<u64> = incident.into_iter().filter(|f| *f != face_id).collect();
1225 if others.len() != 1 {
1226 if debug { eprintln!("HOLE reject: edge {} has {} others", edge.id, others.len()); }
1227 return Ok(None); }
1229 match neighbour {
1230 Some(known) if known != others[0] => {
1231 if debug { eprintln!("HOLE reject: mixed neighbours {known} / {}", others[0]); }
1232 return Ok(None);
1233 }
1234 Some(_) => {}
1235 None => neighbour = Some(others[0]),
1236 }
1237 if !edge_ids.contains(&edge.id) {
1238 edge_ids.push(edge.id);
1239 }
1240 }
1241 let Some(neighbour) = neighbour else {
1242 return Ok(None);
1243 };
1244 if edge_ids.len() < 2 {
1245 if debug { eprintln!("HOLE reject: only {} edges", edge_ids.len()); }
1246 return Ok(None);
1247 }
1248 let (nshell, nface) = find_face(solid, neighbour)
1249 .ok_or_else(|| format!("offset_ruled_face: missing neighbour {neighbour}"))?;
1250 let surface = &solid.shells[nshell].faces[nface].surface;
1251 if matches!(surface.analytic(), Some(AnalyticSurface::Plane { .. }))
1252 || ruled_neighbour_is_coaxial(surface, frame_origin, frame_axis, scale)
1253 {
1254 if debug { eprintln!("HOLE reject: neighbour {neighbour} is planar/coaxial"); }
1255 return Ok(None); }
1257 let neighbour_edges: HashSet<u64> = solid.shells[nshell].faces[nface]
1262 .loops
1263 .iter()
1264 .flat_map(|loop_record| &loop_record.coedges)
1265 .map(|coedge| coedge.edge_id)
1266 .collect();
1267 let mut pinned: Vec<(u64, u64)> = Vec::new();
1268 for edge_id in &edge_ids {
1269 let edge = *edge_by_id
1270 .get(edge_id)
1271 .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1272 for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1273 for other in &solid.edges {
1274 if other.start_vertex_id != vertex_id && other.end_vertex_id != vertex_id {
1275 continue;
1276 }
1277 if edge_ids.contains(&other.id) {
1278 continue;
1279 }
1280 if !neighbour_edges.contains(&other.id) {
1281 if debug {
1282 eprintln!("HOLE reject: vertex {vertex_id} also on foreign edge {}", other.id);
1283 }
1284 return Ok(None);
1285 }
1286 if pinned
1287 .iter()
1288 .any(|(known, pin)| *known == vertex_id && *pin != other.id)
1289 {
1290 if debug {
1291 eprintln!("HOLE reject: vertex {vertex_id} pinned by two neighbour edges");
1292 }
1293 return Ok(None);
1294 }
1295 if !pinned.iter().any(|(known, _)| *known == vertex_id) {
1296 pinned.push((vertex_id, other.id));
1297 }
1298 }
1299 }
1300 }
1301 if debug {
1302 eprintln!("HOLE accept: neighbour {neighbour} edges {edge_ids:?} pinned {pinned:?}");
1303 }
1304 Ok(Some(SingleNeighbourHole {
1305 neighbour,
1306 edge_ids,
1307 pinned,
1308 }))
1309}
1310
1311#[allow(clippy::too_many_arguments)]
1313fn rebuild_single_neighbour_hole(
1314 solid: &BrepSolid,
1315 s_prime: &NurbsSurface,
1316 hole: &SingleNeighbourHole,
1317 edge_by_id: &HashMap<u64, &EdgeRecord>,
1318 vertex_pos: &HashMap<u64, Vec3>,
1319 tolerance: f64,
1320 scale: f64,
1321 new_curve: &mut HashMap<u64, NurbsCurve>,
1322 new_vertex: &mut HashMap<u64, Vec3>,
1323) -> Result<(), String> {
1324 let (nshell, nface) = find_face(solid, hole.neighbour)
1325 .ok_or_else(|| format!("offset_ruled_face: missing neighbour {}", hole.neighbour))?;
1326 let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
1327
1328 let mut seeds: Vec<Vec3> = Vec::new();
1332 let mut reference: Vec<Vec3> = Vec::new();
1333 for edge_id in &hole.edge_ids {
1334 let edge = *edge_by_id
1335 .get(edge_id)
1336 .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1337 let samples = edge_seeds(edge, 9)?;
1338 reference.extend(samples.iter().copied());
1339 seeds.extend(samples);
1340 }
1341 let policy = MarchPolicy {
1342 tolerance,
1343 residual_tolerance: (scale * 5e-4).max(5e-6),
1344 seeds,
1345 };
1346 let found = match reintersect_carriers(s_prime, neighbour_surface, &policy) {
1347 Ok(found) => found,
1348 Err(ReintersectRefusal::Separated) => {
1349 return Err(
1350 "offset_ruled_face: the pushed carrier no longer meets a neighbour \
1351 (the push separated them, or the rim left the neighbour's domain) — refusing"
1352 .into(),
1353 )
1354 }
1355 Err(other) => return Err(format!("offset_ruled_face: {}", other.describe())),
1356 };
1357 if found.lane != RimLane::Marched {
1358 return Err(format!(
1362 "offset_ruled_face: the window bounded by face {} re-intersects in CLOSED FORM, \
1363 whose arc rebuild is the analytic lane's — deferred (refusing)",
1364 hole.neighbour
1365 ));
1366 }
1367 if std::env::var("BREP_PUSH_HOLE_DEBUG").is_ok() {
1368 eprintln!(
1369 "HOLE rebuild neighbour {}: lane {:?}, {} branch(es), residual {:.3e} \
1370 (gate {:.3e})",
1371 hole.neighbour,
1372 found.lane,
1373 found.sections.len(),
1374 found.residual,
1375 policy.residual_tolerance
1376 );
1377 }
1378 let section = found
1381 .nearest_section(&reference)
1382 .map_err(|error| format!("offset_ruled_face: {error}"))?;
1383
1384 for edge_id in &hole.edge_ids {
1393 let edge = *edge_by_id
1394 .get(edge_id)
1395 .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1396 for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
1397 if new_vertex.contains_key(&vertex_id) {
1398 continue;
1399 }
1400 let old = *vertex_pos
1401 .get(&vertex_id)
1402 .ok_or_else(|| format!("offset_ruled_face: missing vertex {vertex_id}"))?;
1403 let point = match hole
1404 .pinned
1405 .iter()
1406 .find(|(known, _)| *known == vertex_id)
1407 .map(|(_, pin)| *pin)
1408 {
1409 Some(pin) => {
1410 let seam = *edge_by_id
1411 .get(&pin)
1412 .ok_or_else(|| format!("offset_ruled_face: missing edge {pin}"))?;
1413 let plane = seam_axial_plane(neighbour_surface, seam).ok_or_else(|| {
1414 format!(
1415 "offset_ruled_face: the window's corner is pinned to edge {pin} of a \
1416 neighbour that is not a surface of revolution — refusing"
1417 )
1418 })?;
1419 let reach = reference
1423 .iter()
1424 .map(|point| point.sub(old).length())
1425 .fold(0.0f64, f64::max)
1426 .max(tolerance * 100.0);
1427 curve_plane_crossing_near(§ion.curve, &plane, old, reach)
1428 .map(|(_, point)| point)
1429 .ok_or_else(|| {
1430 format!(
1431 "offset_ruled_face: the rebuilt window never crosses the seam \
1432 (edge {pin}) its corner is pinned to — refusing"
1433 )
1434 })?
1435 }
1436 None => section_corner(section, old)
1437 .map_err(|error| format!("offset_ruled_face: {error}"))?,
1438 };
1439 new_vertex.insert(vertex_id, point);
1440 }
1441 }
1442 for edge_id in &hole.edge_ids {
1443 let edge = *edge_by_id
1444 .get(edge_id)
1445 .ok_or_else(|| format!("offset_ruled_face: missing edge {edge_id}"))?;
1446 let from = new_vertex[&edge.start_vertex_id];
1447 let to = new_vertex[&edge.end_vertex_id];
1448 let through = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
1449 let arc = arc_of_section(section, from, to, through, tolerance)
1450 .map_err(|error| format!("offset_ruled_face: {error}"))?;
1451 new_curve.insert(edge.id, arc);
1452 }
1453 Ok(())
1454}
1455
1456fn refit_changed_pcurves(
1487 result: &mut BrepSolid,
1488 face_id: u64,
1489 final_edges: &HashMap<u64, EdgeRecord>,
1490 changed: &HashSet<u64>,
1491 tolerance: f64,
1492) -> Result<(), String> {
1493 let (shell, face_pos) = find_face(result, face_id)
1494 .ok_or_else(|| format!("offset_ruled_face: missing face {face_id}"))?;
1495 let surface = result.shells[shell].faces[face_pos].surface.clone();
1496 let [u_start, u_end] = surface.domain_u()?;
1497 let u_period = u_end - u_start;
1498 for loop_record in &mut result.shells[shell].faces[face_pos].loops {
1499 for coedge in &mut loop_record.coedges {
1500 if !changed.contains(&coedge.edge_id) {
1501 continue;
1502 }
1503 let edge = final_edges
1504 .get(&coedge.edge_id)
1505 .ok_or_else(|| format!("offset_ruled_face: missing edge {}", coedge.edge_id))?;
1506 let [d0, d1] = edge.curve.domain()?;
1510 let span = (d1 - d0).max(1e-12);
1511 let is_subrange =
1512 (edge.t0 - d0).abs() > 1e-9 * span || (edge.t1 - d1).abs() > 1e-9 * span;
1513 let pcurve = if is_subrange {
1514 build_pcurve_on_surface_range(
1515 &surface,
1516 &edge.curve,
1517 edge.t0,
1518 edge.t1,
1519 coedge.forward,
1520 tolerance,
1521 )?
1522 } else {
1523 let mut pcurve = build_pcurve_on_surface(&surface, &edge.curve)?;
1524 if !coedge.forward {
1525 pcurve = pcurve.reversed()?;
1526 }
1527 pcurve
1528 };
1529 coedge.pcurve = reanchor_pcurve_u(&pcurve, &coedge.pcurve, u_period)?;
1530 }
1531 }
1532 Ok(())
1533}
1534
1535fn nearest_curve(curves: &[NurbsCurve], reference: Vec3) -> Result<NurbsCurve, String> {
1538 let mut best: Option<(f64, &NurbsCurve)> = None;
1539 for curve in curves {
1540 let mid = curve.evaluate(0.5)?;
1541 let d = mid.sub(reference).length();
1542 if best.map(|(best_d, _)| d < best_d).unwrap_or(true) {
1543 best = Some((d, curve));
1544 }
1545 }
1546 best.map(|(_, curve)| curve.clone())
1547 .ok_or_else(|| "offset_ruled_face: empty intersection".into())
1548}
1549
1550#[cfg(test)]
1551mod probe_tests {
1552 use super::*;
1553 use crate::{make_cone_brep, make_cylinder_brep};
1554
1555 fn radial(point: Vec3, origin: Vec3, axis: Vec3) -> f64 {
1557 let d = point.sub(origin);
1558 d.sub(axis.scale(d.dot(axis))).length()
1559 }
1560
1561 fn side_surface(solid: &BrepSolid) -> NurbsSurface {
1562 solid
1563 .shells
1564 .iter()
1565 .flat_map(|shell| &shell.faces)
1566 .find(|face| matches!(face.surface.analytic(), Some(AnalyticSurface::RuledRevolution { .. })))
1567 .expect("a ruled side face")
1568 .surface
1569 .clone()
1570 }
1571
1572 fn side_face_id(solid: &BrepSolid) -> u64 {
1573 solid
1574 .shells
1575 .iter()
1576 .flat_map(|shell| &shell.faces)
1577 .find(|face| matches!(face.surface.analytic(), Some(AnalyticSurface::RuledRevolution { .. })))
1578 .expect("a ruled side face")
1579 .id
1580 }
1581
1582 #[test]
1586 fn push_solid_cylinder_side_changes_radius() {
1587 let axis = Vec3::new(0.0, 0.0, 1.0);
1588 for (d, r_new) in [(2.0_f64, 7.0_f64), (-2.0, 3.0)] {
1589 let cyl = make_cylinder_brep(Vec3::default(), axis, 5.0, 10.0).unwrap();
1590 let side = side_face_id(&cyl);
1591 let pushed = offset_ruled_face(&cyl, side, d)
1592 .unwrap_or_else(|e| panic!("push cylinder side by {d}: {e}"));
1593 assert!(pushed.validate().is_empty(), "validate {d}: {:?}", pushed.validate());
1594 let got = solid_signed_volume(&pushed).unwrap().abs();
1595 let expected = std::f64::consts::PI * r_new * r_new * 10.0;
1596 assert!((got - expected).abs() < 1e-3, "push {d}: vol {got}, want {expected}");
1597 }
1598 }
1599
1600 #[test]
1605 fn push_drilled_hole_wall_resizes_the_hole() {
1606 let plate = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 4.0).unwrap();
1607 let cutter = make_cylinder_brep(Vec3::new(10.0, 10.0, -1.0), Vec3::new(0.0, 0.0, 1.0), 3.0, 6.0)
1608 .unwrap();
1609 let options = crate::BooleanOptions {
1610 merge_coplanar_faces: true,
1611 ..crate::BooleanOptions::default()
1612 };
1613 let drilled =
1614 crate::boolean_operation(&plate, &cutter, crate::BooleanOperation::Subtract, &options)
1615 .unwrap();
1616 let v0 = solid_signed_volume(&drilled).unwrap().abs();
1617 let wall = side_face_id(&drilled);
1618 let pushed = offset_ruled_face(&drilled, wall, 1.0)
1620 .unwrap_or_else(|e| panic!("push drilled-hole wall: {e}"));
1621 assert!(pushed.validate().is_empty(), "validate: {:?}", pushed.validate());
1622 let got = solid_signed_volume(&pushed).unwrap().abs();
1623 let expected = std::f64::consts::PI * (9.0 - 4.0) * 4.0;
1625 assert!(
1626 (got - v0 - expected).abs() < 1e-3,
1627 "hole-shrink volume delta {}, expected {expected}",
1628 got - v0
1629 );
1630 }
1631
1632 #[test]
1635 fn push_cylinder_side_through_axis_refuses() {
1636 let cyl = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 10.0).unwrap();
1637 let side = side_face_id(&cyl);
1638 let err = offset_ruled_face(&cyl, side, -5.0).expect_err("collapse must refuse");
1639 assert!(err.contains("axis") || err.contains("refus"), "unexpected: {err}");
1640 }
1641
1642 fn slotted_cylinder() -> BrepSolid {
1649 let cyl = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 10.0).unwrap();
1650 let slot = crate::make_box_brep(Vec3::new(-1.5, -9.0, 3.5), 3.0, 18.0, 3.0).unwrap();
1651 let options = crate::BooleanOptions {
1652 merge_coplanar_faces: true,
1653 ..crate::BooleanOptions::default()
1654 };
1655 crate::boolean_operation(&cyl, &slot, crate::BooleanOperation::Subtract, &options).unwrap()
1656 }
1657
1658 fn slotted_wall_id(solid: &BrepSolid) -> u64 {
1660 solid
1661 .shells
1662 .iter()
1663 .flat_map(|shell| &shell.faces)
1664 .filter(|face| {
1665 matches!(face.surface.analytic(), Some(AnalyticSurface::RuledRevolution { .. }))
1666 })
1667 .max_by_key(|face| face.loops.len())
1668 .expect("a ruled side face")
1669 .id
1670 }
1671
1672 fn slotted_cylinder_volume(radius: f64) -> f64 {
1678 let a = 1.5_f64;
1679 let strip =
1680 2.0 * (a * (radius * radius - a * a).sqrt() + radius * radius * (a / radius).asin());
1681 std::f64::consts::PI * radius * radius * 10.0 - 3.0 * strip
1682 }
1683
1684 #[test]
1698 fn push_multiloop_cylinder_wall_resizes() {
1699 let slotted = slotted_cylinder();
1700 assert!(slotted.validate().is_empty(), "fixture: {:?}", slotted.validate());
1701 let wall_id = slotted_wall_id(&slotted);
1702 let before: Vec<usize> = slotted
1703 .shells
1704 .iter()
1705 .flat_map(|shell| &shell.faces)
1706 .find(|face| face.id == wall_id)
1707 .expect("the wall")
1708 .loops
1709 .iter()
1710 .map(|loop_record| loop_record.coedges.len())
1711 .collect();
1712 assert!(
1713 before.len() >= 2,
1714 "fixture must give the wall multiple loops, got {before:?}"
1715 );
1716 let base = solid_signed_volume(&slotted).unwrap().abs();
1717 assert!(
1718 (base - slotted_cylinder_volume(5.0)).abs() < 1e-3,
1719 "fixture volume {base}, want {}",
1720 slotted_cylinder_volume(5.0)
1721 );
1722
1723 for (d, r_new) in [(1.0_f64, 6.0_f64), (-1.0, 4.0)] {
1724 let pushed = offset_ruled_face(&slotted, wall_id, d)
1725 .unwrap_or_else(|e| panic!("multi-loop wall push {d}: {e}"));
1726 assert!(pushed.validate().is_empty(), "validate {d}: {:?}", pushed.validate());
1727
1728 let wall = pushed
1729 .shells
1730 .iter()
1731 .flat_map(|shell| &shell.faces)
1732 .find(|face| face.id == wall_id)
1733 .expect("the pushed wall");
1734 let Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. }) =
1735 wall.surface.analytic()
1736 else {
1737 panic!("the pushed wall is no longer a ruled revolution");
1738 };
1739 assert!(
1740 (rho0 - r_new).abs() < 1e-9 && (rho1 - r_new).abs() < 1e-9,
1741 "push {d}: wall radii {rho0}/{rho1}, want {r_new}"
1742 );
1743 let after: Vec<usize> = wall
1744 .loops
1745 .iter()
1746 .map(|loop_record| loop_record.coedges.len())
1747 .collect();
1748 assert_eq!(after, before, "push {d} must preserve the wall's loop structure");
1749
1750 let corner_y = (r_new * r_new - 2.25_f64).sqrt();
1754 let mut corners = 0;
1755 for vertex in &pushed.vertices {
1756 let on_slot_plane =
1757 (vertex.point.z - 3.5).abs() < 1e-9 || (vertex.point.z - 6.5).abs() < 1e-9;
1758 if !on_slot_plane || (vertex.point.x.abs() - 1.5).abs() > 1e-9 {
1759 continue;
1760 }
1761 assert!(
1762 (vertex.point.y.abs() - corner_y).abs() < 1e-6,
1763 "push {d}: slot corner {:?} should sit at |y| = {corner_y}",
1764 vertex.point
1765 );
1766 corners += 1;
1767 }
1768 assert_eq!(corners, 8, "push {d}: the slot has eight corner vertices");
1769
1770 let expected = slotted_cylinder_volume(r_new);
1771 let got = solid_signed_volume(&pushed).unwrap().abs();
1772 assert!(
1773 (got - expected).abs() < 1e-3,
1774 "push {d}: volume {got}, want {expected} (delta {}, want {})",
1775 got - base,
1776 expected - base
1777 );
1778 }
1779 assert!(slotted.validate().is_empty());
1781 }
1782
1783 #[test]
1789 fn push_multiloop_cylinder_wall_collapsing_the_slot_refuses() {
1790 let slotted = slotted_cylinder();
1791 let wall_id = slotted_wall_id(&slotted);
1792 for d in [-3.6_f64, -5.0] {
1793 let err = offset_ruled_face(&slotted, wall_id, d)
1794 .expect_err("a push that collapses the slot must refuse");
1795 assert!(
1796 err.contains("refus") || err.contains("validation"),
1797 "push {d} must refuse with a typed error, got: {err}"
1798 );
1799 }
1800 assert!(slotted.validate().is_empty());
1801 }
1802
1803 #[test]
1812 fn push_multiloop_cone_wall_is_fail_safe() {
1813 let frustum =
1814 make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 6.0, 3.0, 10.0).unwrap();
1815 let slot = crate::make_box_brep(Vec3::new(-1.5, -9.0, 3.5), 3.0, 18.0, 3.0).unwrap();
1816 let options = crate::BooleanOptions {
1817 merge_coplanar_faces: true,
1818 ..crate::BooleanOptions::default()
1819 };
1820 let slotted =
1821 crate::boolean_operation(&frustum, &slot, crate::BooleanOperation::Subtract, &options)
1822 .unwrap();
1823 assert!(slotted.validate().is_empty(), "fixture: {:?}", slotted.validate());
1824 let wall = slotted_wall_id(&slotted);
1825 for d in [1.0_f64, -1.0] {
1826 match offset_ruled_face(&slotted, wall, d) {
1827 Err(error) => assert!(
1828 error.contains("refus"),
1829 "push {d} must refuse with a typed error, got: {error}"
1830 ),
1831 Ok(good) => assert!(
1832 good.validate().is_empty(),
1833 "if the cone multi-loop push is accepted it must be valid: {:?}",
1834 good.validate()
1835 ),
1836 }
1837 }
1838 assert!(slotted.validate().is_empty());
1839 }
1840
1841 #[test]
1846 fn push_cylinder_wall_with_a_radial_bore_still_refuses() {
1847 let cyl = make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 10.0).unwrap();
1848 let bore =
1849 make_cylinder_brep(Vec3::new(0.0, -9.0, 5.0), Vec3::new(0.0, 1.0, 0.0), 1.5, 18.0)
1850 .unwrap();
1851 let options = crate::BooleanOptions {
1852 merge_coplanar_faces: true,
1853 ..crate::BooleanOptions::default()
1854 };
1855 let bored =
1856 crate::boolean_operation(&cyl, &bore, crate::BooleanOperation::Subtract, &options)
1857 .unwrap();
1858 assert!(bored.validate().is_empty(), "fixture: {:?}", bored.validate());
1859 let wall = cylinder_side_id(&bored, Vec3::new(0.0, 0.0, 1.0));
1860 let err = offset_ruled_face(&bored, wall, 1.0)
1861 .expect_err("a radial bore's seam-straddling hole must still refuse");
1862 assert!(
1870 err.contains("seam") || err.contains("OPEN arc"),
1871 "expected the seam-straddling refusal, got: {err}"
1872 );
1873 assert!(bored.validate().is_empty());
1874 }
1875
1876 #[test]
1879 fn push_frustum_side_grows_both_radii() {
1880 let axis = Vec3::new(0.0, 0.0, 1.0);
1881 let (r_b, r_t, h, d) = (4.0_f64, 2.0_f64, 6.0_f64, 1.0_f64);
1882 let frustum = make_cone_brep(Vec3::default(), axis, r_b, r_t, h).unwrap();
1883 let side = side_face_id(&frustum);
1884 let pushed = offset_ruled_face(&frustum, side, d)
1885 .unwrap_or_else(|e| panic!("push frustum side: {e}"));
1886 assert!(pushed.validate().is_empty(), "validate: {:?}", pushed.validate());
1887 let slope = (r_t - r_b) / h;
1888 let grow = d * (1.0 + slope * slope).sqrt();
1889 let (rb, rt) = (r_b + grow, r_t + grow);
1890 let expected = std::f64::consts::PI * h / 3.0 * (rb * rb + rb * rt + rt * rt);
1891 let got = solid_signed_volume(&pushed).unwrap().abs();
1892 assert!((got - expected).abs() < 1e-3, "frustum push vol {got}, want {expected}");
1893 }
1894
1895 fn cap_surface_at(solid: &BrepSolid, axis: Vec3, target_axial: f64) -> NurbsSurface {
1896 solid
1897 .shells
1898 .iter()
1899 .flat_map(|shell| &shell.faces)
1900 .filter(|face| matches!(face.surface.analytic(), Some(AnalyticSurface::Plane { .. })))
1901 .find(|face| {
1902 let (u, v) = (0.5, 0.5);
1903 let p = face.surface.evaluate(u, v).unwrap();
1904 (p.dot(axis) - target_axial).abs() < 1e-6
1905 })
1906 .expect("a planar cap at the target height")
1907 .surface
1908 .clone()
1909 }
1910
1911 #[test]
1915 fn offset_carrier_recognizes_and_reintersects_the_caps() {
1916 let axis = Vec3::new(0.0, 0.0, 1.0);
1917 let tol = 1e-9;
1918
1919 let cyl = make_cylinder_brep(Vec3::default(), axis, 5.0, 10.0).unwrap();
1921 let s_prime = offset_ruled_carrier(&side_surface(&cyl), 2.0, tol).unwrap();
1922 match s_prime.analytic() {
1923 Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. }) => {
1924 assert!((rho0 - 7.0).abs() < 1e-6 && (rho1 - 7.0).abs() < 1e-6, "cyl rho {rho0},{rho1}");
1925 }
1926 other => panic!("cylinder offset must re-recognize as ruled, got {other:?}"),
1927 }
1928 for (z, want_r) in [(0.0, 7.0), (10.0, 7.0)] {
1929 let cap = cap_surface_at(&cyl, axis, z);
1930 let curves = intersect_analytic_pair(&s_prime, &cap, tol)
1931 .unwrap_or_else(|| panic!("no analytic intersection at z={z}"));
1932 assert_eq!(curves.len(), 1, "one rim circle at z={z}");
1933 for step in 0..=8 {
1934 let p = curves[0].evaluate(step as f64 / 8.0).unwrap();
1935 assert!((radial(p, Vec3::default(), axis) - want_r).abs() < 1e-6, "cyl rim r at z={z}");
1936 assert!((p.z - z).abs() < 1e-6, "cyl rim z");
1937 }
1938 }
1939
1940 let grow = (1.0 + (1.0f64 / 3.0).powi(2)).sqrt();
1943 let frustum = make_cone_brep(Vec3::default(), axis, 4.0, 2.0, 6.0).unwrap();
1944 let s_prime = offset_ruled_carrier(&side_surface(&frustum), 1.0, tol).unwrap();
1945 assert!(
1946 matches!(s_prime.analytic(), Some(AnalyticSurface::RuledRevolution { .. })),
1947 "frustum offset must re-recognize as ruled"
1948 );
1949 for (z, base_r) in [(0.0, 4.0), (6.0, 2.0)] {
1950 let cap = cap_surface_at(&frustum, axis, z);
1951 let curves = intersect_analytic_pair(&s_prime, &cap, tol)
1952 .unwrap_or_else(|| panic!("no frustum intersection at z={z}"));
1953 assert_eq!(curves.len(), 1, "one frustum rim circle at z={z}");
1954 let want_r = base_r + grow;
1955 for step in 0..=8 {
1956 let p = curves[0].evaluate(step as f64 / 8.0).unwrap();
1957 assert!(
1958 (radial(p, Vec3::default(), axis) - want_r).abs() < 1e-6,
1959 "frustum rim r at z={z}: got {}, want {want_r}",
1960 radial(p, Vec3::default(), axis)
1961 );
1962 assert!((p.z - z).abs() < 1e-6, "frustum rim z");
1963 }
1964 }
1965 }
1966
1967 fn coaxial_cone_cylinder_boss() -> BrepSolid {
1973 let axis = Vec3::new(0.0, 0.0, 1.0);
1974 let cone = make_cone_brep(Vec3::default(), axis, 5.0, 3.0, 4.0).unwrap();
1975 let cyl = make_cylinder_brep(Vec3::default(), axis, 3.0, 10.0).unwrap();
1976 let options = crate::BooleanOptions {
1977 merge_coplanar_faces: true,
1978 ..crate::BooleanOptions::default()
1979 };
1980 crate::boolean_operation(&cone, &cyl, crate::BooleanOperation::Union, &options).unwrap()
1981 }
1982
1983 fn cylinder_side_id(solid: &BrepSolid, axis: Vec3) -> u64 {
1986 solid
1987 .shells
1988 .iter()
1989 .flat_map(|shell| &shell.faces)
1990 .find(|face| match face.surface.analytic() {
1991 Some(AnalyticSurface::RuledRevolution { frame, rho0, rho1, .. }) => {
1992 (rho0 - rho1).abs() < 1e-9 && frame.axis.dot(axis).abs() > 1.0 - 1e-9
1993 }
1994 _ => false,
1995 })
1996 .expect("a cylinder side face")
1997 .id
1998 }
1999
2000 fn counts(solid: &BrepSolid) -> (usize, usize, usize) {
2001 let faces = solid.shells.iter().map(|s| s.faces.len()).sum();
2002 (solid.vertices.len(), solid.edges.len(), faces)
2003 }
2004
2005 #[test]
2010 fn push_cylinder_side_against_coaxial_cone_reintersects() {
2011 let axis = Vec3::new(0.0, 0.0, 1.0);
2012 let solid = coaxial_cone_cylinder_boss();
2013 assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
2014 let before_counts = counts(&solid);
2015 let before_vol = solid_signed_volume(&solid).unwrap();
2016
2017 let cyl = cylinder_side_id(&solid, axis);
2018 let pushed = offset_ruled_face(&solid, cyl, 1.0)
2019 .unwrap_or_else(|e| panic!("push cylinder side against coaxial cone: {e}"));
2020 assert!(pushed.validate().is_empty(), "validate: {:?}", pushed.validate());
2021
2022 assert_eq!(counts(&pushed), before_counts, "topology counts changed");
2024
2025 let expected =
2027 std::f64::consts::PI * (2.0 / 3.0 * (25.0 + 20.0 + 16.0) + 16.0 * 8.0);
2028 let got = solid_signed_volume(&pushed).unwrap().abs();
2029 assert!((got - expected).abs() < 1e-2, "vol {got}, want {expected}");
2030
2031 assert!(
2033 before_vol * solid_signed_volume(&pushed).unwrap() > 0.0,
2034 "volume sign flipped"
2035 );
2036
2037 let cyl_face = pushed
2039 .shells
2040 .iter()
2041 .flat_map(|s| &s.faces)
2042 .find(|f| f.id == cyl)
2043 .unwrap();
2044 match cyl_face.surface.analytic() {
2045 Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. }) => {
2046 assert!((rho0 - 4.0).abs() < 1e-6 && (rho1 - 4.0).abs() < 1e-6, "cyl r {rho0}");
2047 }
2048 other => panic!("pushed face must stay ruled, got {other:?}"),
2049 }
2050 let shared = pushed
2052 .edges
2053 .iter()
2054 .find(|e| {
2055 let m = e.curve.evaluate(0.5 * (e.t0 + e.t1)).unwrap();
2056 (radial(m, Vec3::default(), axis) - 4.0).abs() < 1e-6 && (m.z - 2.0).abs() < 1e-6
2057 })
2058 .expect("relocated shared rim at z=2, r=4");
2059 assert!(shared.start_vertex_id == shared.end_vertex_id, "shared rim is a closed circle");
2060 }
2061
2062 #[test]
2069 fn push_cylinder_side_against_a_crossing_pipe_matches_the_rebuilt_tee() {
2070 let vertical =
2071 make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0).unwrap();
2072 let horizontal =
2073 make_cylinder_brep(Vec3::new(-6.0, 0.0, 5.0), Vec3::new(1.0, 0.0, 0.0), 1.5, 12.0)
2074 .unwrap();
2075 let options = crate::BooleanOptions {
2076 merge_coplanar_faces: true,
2077 ..crate::BooleanOptions::default()
2078 };
2079 let solid = crate::boolean_operation(
2080 &vertical,
2081 &horizontal,
2082 crate::BooleanOperation::Union,
2083 &options,
2084 )
2085 .unwrap();
2086 assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
2087 let wall = cylinder_side_id(&solid, Vec3::new(0.0, 0.0, 1.0));
2089 let pushed = offset_ruled_face(&solid, wall, 1.0)
2096 .unwrap_or_else(|error| panic!("push a wall against a crossing pipe: {error}"));
2097 let issues = pushed.validate();
2098 assert!(issues.is_empty(), "validate: {issues:?}");
2099
2100 let oracle = {
2101 let grown =
2102 make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 4.0, 10.0).unwrap();
2103 crate::boolean_operation(
2104 &grown,
2105 &horizontal,
2106 crate::BooleanOperation::Union,
2107 &options,
2108 )
2109 .unwrap()
2110 };
2111 let got = solid_signed_volume(&pushed).unwrap().abs();
2112 let want = solid_signed_volume(&oracle).unwrap().abs();
2113 assert!(
2118 (got - want).abs() <= 1e-4 * want,
2119 "pushed volume {got} against the independently built {want} (relative {:.3e})",
2120 (got - want).abs() / want
2121 );
2122 assert_eq!(
2123 (
2124 pushed.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
2125 pushed.edges.len(),
2126 pushed.vertices.len()
2127 ),
2128 (
2129 oracle.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
2130 oracle.edges.len(),
2131 oracle.vertices.len()
2132 ),
2133 "the push must reproduce the rebuilt tee's topology"
2134 );
2135 let radii: Vec<f64> = pushed
2138 .shells
2139 .iter()
2140 .flat_map(|shell| &shell.faces)
2141 .filter_map(|face| match face.surface.analytic() {
2142 Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. })
2143 if (rho0 - rho1).abs() < 1e-9 =>
2144 {
2145 Some(*rho0)
2146 }
2147 _ => None,
2148 })
2149 .collect();
2150 assert!(
2151 radii.iter().any(|r| (r - 4.0).abs() < 1e-9),
2152 "the pushed wall must be at radius 4, got {radii:?}"
2153 );
2154 assert_eq!(
2155 radii.iter().filter(|r| (**r - 1.5).abs() < 1e-9).count(),
2156 2,
2157 "both stubs of the crossing pipe keep radius 1.5, got {radii:?}"
2158 );
2159 let mut window_samples = 0usize;
2162 for edge in &pushed.edges {
2163 for step in 0..=8 {
2164 let point = edge
2165 .curve
2166 .evaluate(edge.t0 + (edge.t1 - edge.t0) * step as f64 / 8.0)
2167 .unwrap();
2168 let on_wall = ((point.x * point.x + point.y * point.y).sqrt() - 4.0).abs();
2169 let on_pipe = ((point.y * point.y + (point.z - 5.0) * (point.z - 5.0)).sqrt()
2170 - 1.5)
2171 .abs();
2172 if on_wall < 1e-3 && on_pipe < 1e-3 {
2173 window_samples += 1;
2174 assert!(
2175 on_wall < 1e-5 && on_pipe < 1e-5,
2176 "a window arc drifts off its carriers: {on_wall:.3e} / {on_pipe:.3e}"
2177 );
2178 }
2179 }
2180 }
2181 assert!(
2182 window_samples >= 18,
2183 "the two window arcs must be present and sampled, got {window_samples}"
2184 );
2185 }
2186
2187 #[test]
2193 fn a_window_straddling_the_pushed_seam_still_refuses() {
2194 let axis = Vec3::new(0.0, 0.0, 1.0);
2195 let vertical = make_cylinder_brep(Vec3::default(), axis, 3.0, 10.0).unwrap();
2196 let crossing =
2198 make_cylinder_brep(Vec3::new(0.0, -6.0, 5.0), Vec3::new(0.0, 1.0, 0.0), 1.5, 12.0)
2199 .unwrap();
2200 let options = crate::BooleanOptions {
2201 merge_coplanar_faces: true,
2202 ..crate::BooleanOptions::default()
2203 };
2204 let solid = crate::boolean_operation(
2205 &vertical,
2206 &crossing,
2207 crate::BooleanOperation::Union,
2208 &options,
2209 )
2210 .unwrap();
2211 assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
2212 let wall = cylinder_side_id(&solid, axis);
2213 let error = offset_ruled_face(&solid, wall, 1.0)
2214 .expect_err("a seam-straddling window must refuse");
2215 assert!(
2216 error.contains("OPEN arc") || error.contains("seam"),
2217 "unexpected refusal: {error}"
2218 );
2219 assert!(solid.validate().is_empty(), "the source is never mutated");
2220 }
2221
2222 #[test]
2225 fn push_cylinder_side_coaxial_inward_refuses_cleanly() {
2226 let axis = Vec3::new(0.0, 0.0, 1.0);
2227 let solid = coaxial_cone_cylinder_boss();
2228 let cyl = cylinder_side_id(&solid, axis);
2229 let result = offset_ruled_face(&solid, cyl, -1.0);
2231 match result {
2232 Err(_) => {}
2233 Ok(good) => assert!(
2234 good.validate().is_empty(),
2235 "if inward is accepted it must still be valid: {:?}",
2236 good.validate()
2237 ),
2238 }
2239 }
2240
2241 #[test]
2256 fn push_cylinder_wall_against_a_spherical_dome_matches_the_rebuilt_solid() {
2257 let ball_capped = |radius: f64| {
2258 let rod =
2259 make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), radius, 10.0)
2260 .unwrap();
2261 let ball =
2262 crate::make_sphere_brep(Vec3::new(0.0, 0.0, 10.0), 4.0, Vec3::new(0.0, 0.0, 1.0))
2263 .unwrap();
2264 crate::boolean_operation(
2265 &rod,
2266 &ball,
2267 crate::BooleanOperation::Union,
2268 &crate::BooleanOptions {
2269 merge_coplanar_faces: true,
2270 ..crate::BooleanOptions::default()
2271 },
2272 )
2273 .unwrap()
2274 };
2275 let source = ball_capped(3.0);
2276 let wall = cylinder_side_id(&source, Vec3::new(0.0, 0.0, 1.0));
2277 let pushed = offset_ruled_face(&source, wall, 0.5)
2278 .unwrap_or_else(|error| panic!("push a wall against a dome: {error}"));
2279 let issues = pushed.validate();
2280 assert!(issues.is_empty(), "validate: {issues:?}");
2281
2282 let oracle = ball_capped(3.5);
2283 let got = solid_signed_volume(&pushed).unwrap().abs();
2284 let want = solid_signed_volume(&oracle).unwrap().abs();
2285 assert!(
2290 (got - want).abs() <= 1e-9 * want,
2291 "pushed volume {got} against the independently built {want}"
2292 );
2293 assert_eq!(
2294 (
2295 pushed.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
2296 pushed.edges.len(),
2297 pushed.vertices.len()
2298 ),
2299 (
2300 oracle.shells.iter().map(|s| s.faces.len()).sum::<usize>(),
2301 oracle.edges.len(),
2302 oracle.vertices.len()
2303 ),
2304 "the push must reproduce the rebuilt solid's topology, not merely its volume"
2305 );
2306 }
2307
2308 #[test]
2312 fn push_cylinder_wall_against_a_toroidal_groove_moves_the_rim_onto_the_tube() {
2313 let axis = Vec3::new(0.0, 0.0, 1.0);
2314 let shaft = make_cylinder_brep(Vec3::default(), axis, 5.0, 10.0).unwrap();
2315 let groove = crate::make_torus_brep(Vec3::new(0.0, 0.0, 5.0), axis, 5.0, 1.5).unwrap();
2316 let grooved = crate::boolean_operation(
2317 &shaft,
2318 &groove,
2319 crate::BooleanOperation::Subtract,
2320 &crate::BooleanOptions {
2321 merge_coplanar_faces: true,
2322 ..crate::BooleanOptions::default()
2323 },
2324 )
2325 .unwrap();
2326 assert!(grooved.validate().is_empty(), "fixture: {:?}", grooved.validate());
2327 let before = solid_signed_volume(&grooved).unwrap().abs();
2328 let band = cylinder_side_id(&grooved, axis);
2329 let pushed = offset_ruled_face(&grooved, band, 0.5)
2330 .unwrap_or_else(|error| panic!("push a wall band against a groove: {error}"));
2331 let issues = pushed.validate();
2332 assert!(issues.is_empty(), "validate: {issues:?}");
2333
2334 let rim_z = 5.0 + 2.0_f64.sqrt();
2341 let after = solid_signed_volume(&pushed).unwrap().abs();
2342 assert!(after > before, "an outward push must grow the solid");
2343 let rim = pushed
2344 .edges
2345 .iter()
2346 .find(|edge| {
2347 let Ok(mid) = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)) else {
2348 return false;
2349 };
2350 (mid.z - rim_z).abs() < 1e-9
2351 && ((mid.x * mid.x + mid.y * mid.y).sqrt() - 5.5).abs() < 1e-9
2352 })
2353 .expect("a rebuilt rim circle of radius 5.5 at z = 5 + √2");
2354 for step in 0..=8 {
2355 let point = rim
2356 .curve
2357 .evaluate(rim.t0 + (rim.t1 - rim.t0) * step as f64 / 8.0)
2358 .unwrap();
2359 assert!(
2360 ((point.x * point.x + point.y * point.y).sqrt() - 5.5).abs() < 1e-9
2361 && (point.z - rim_z).abs() < 1e-9,
2362 "the rim must be the exact circle, not a fitted approximation: {point:?}"
2363 );
2364 }
2365 let radii: Vec<f64> = pushed
2368 .shells
2369 .iter()
2370 .flat_map(|shell| &shell.faces)
2371 .filter_map(|face| match face.surface.analytic() {
2372 Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. })
2373 if (rho0 - rho1).abs() < 1e-9 =>
2374 {
2375 Some(*rho0)
2376 }
2377 _ => None,
2378 })
2379 .collect();
2380 assert!(
2381 radii.iter().any(|r| (r - 5.5).abs() < 1e-9)
2382 && radii.iter().any(|r| (r - 5.0).abs() < 1e-9),
2383 "expected one band at 5.5 and one still at 5.0, got {radii:?}"
2384 );
2385 assert!(
2386 pushed
2387 .shells
2388 .iter()
2389 .flat_map(|shell| &shell.faces)
2390 .any(|face| matches!(
2391 face.surface.analytic(),
2392 Some(AnalyticSurface::Torus { major_radius, minor_radius, .. })
2393 if (major_radius - 5.0).abs() < 1e-12
2394 && (minor_radius - 1.5).abs() < 1e-12
2395 )),
2396 "the groove's torus must be untouched — only its trim moved"
2397 );
2398 }
2399
2400 #[test]
2404 fn push_that_pulls_a_wall_out_of_its_curved_neighbour_refuses() {
2405 let rod =
2406 make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 3.0, 10.0).unwrap();
2407 let ball =
2408 crate::make_sphere_brep(Vec3::new(0.0, 0.0, 10.0), 4.0, Vec3::new(0.0, 0.0, 1.0))
2409 .unwrap();
2410 let capped = crate::boolean_operation(
2411 &rod,
2412 &ball,
2413 crate::BooleanOperation::Union,
2414 &crate::BooleanOptions {
2415 merge_coplanar_faces: true,
2416 ..crate::BooleanOptions::default()
2417 },
2418 )
2419 .unwrap();
2420 let wall = cylinder_side_id(&capped, Vec3::new(0.0, 0.0, 1.0));
2421 let error = offset_ruled_face(&capped, wall, 1.5)
2424 .expect_err("a wall pushed clear of its dome must refuse");
2425 assert!(
2426 error.contains("no longer meets") || error.contains("refus"),
2427 "unexpected refusal: {error}"
2428 );
2429 assert!(capped.validate().is_empty(), "the source is never mutated");
2430 }
2431}