1use super::*;
2
3pub fn offset_sphere_face(
78 solid: &BrepSolid,
79 face_id: u64,
80 distance: f64,
81) -> Result<BrepSolid, String> {
82 if !distance.is_finite() {
83 return Err("offset_sphere_face: distance must be finite".into());
84 }
85 let scale = solid_model_scale(solid);
86 let tolerance = (scale * 1e-7).max(1e-9);
87 let plane_tolerance = (scale * 1e-6).max(1e-7);
88
89 let (pshell, pface) = find_face(solid, face_id)
90 .ok_or_else(|| format!("offset_sphere_face: no face {face_id}"))?;
91 let pushed = &solid.shells[pshell].faces[pface];
92 let Some(AnalyticSurface::Sphere { frame, radius }) = pushed.surface.analytic() else {
93 return Err("offset_sphere_face: the pushed face is not a sphere".into());
94 };
95 let center = frame.origin;
96 let axis = frame.axis;
97 let seam_x = frame.x_axis;
98 let seam_y = frame.y_axis;
99 let radius = *radius;
100 if radius <= tolerance {
101 return Err("offset_sphere_face: degenerate sphere radius".into());
102 }
103
104 let [u0, u1] = pushed.surface.domain_u()?;
109 let [v0, v1] = pushed.surface.domain_v()?;
110 let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
111 let point = pushed.surface.evaluate(um, vm)?;
112 let mut normal = pushed.surface.normal(um, vm)?;
113 if !pushed.same_sense {
114 normal = normal.scale(-1.0);
115 }
116 let radial = point.sub(center);
117 if radial.length() <= tolerance {
118 return Err("offset_sphere_face: degenerate radial direction".into());
119 }
120 let outward_sign = if normal.dot(radial) >= 0.0 { 1.0 } else { -1.0 };
121 let radius_new = radius + distance * outward_sign;
122 if radius_new <= tolerance {
123 return Err(
124 "offset_sphere_face: the push collapses the sphere to or past its centre — refusing"
125 .into(),
126 );
127 }
128
129 let k = radius_new / radius;
132 let scale_about_center = AffineTransform::new([
133 k, 0.0, 0.0, (1.0 - k) * center.x,
134 0.0, k, 0.0, (1.0 - k) * center.y,
135 0.0, 0.0, k, (1.0 - k) * center.z,
136 0.0, 0.0, 0.0, 1.0,
137 ])?;
138 let s_prime = transform_surface(&pushed.surface, scale_about_center)?;
139 match s_prime.analytic() {
141 Some(AnalyticSurface::Sphere { frame, radius }) => {
142 if frame.origin.sub(center).length() > plane_tolerance
143 || (radius - radius_new).abs() > plane_tolerance.max(radius_new * 1e-9)
144 {
145 return Err("offset_sphere_face: offset carrier is not the expected \
146 concentric sphere — refusing"
147 .into());
148 }
149 }
150 _ => {
151 return Err(
152 "offset_sphere_face: offset carrier did not re-recognise as a sphere — refusing"
153 .into(),
154 )
155 }
156 }
157
158 let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
162 for shell in &solid.shells {
163 for face in &shell.faces {
164 for loop_record in &face.loops {
165 for coedge in &loop_record.coedges {
166 faces_of_edge
167 .entry(coedge.edge_id)
168 .or_default()
169 .push(face.id);
170 }
171 }
172 }
173 }
174 let edge_by_id: HashMap<u64, &EdgeRecord> =
175 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
176
177 let [dv0, dv1] = s_prime.domain_v()?;
180
181 enum RimKind {
188 OffCentrePlane(Plane),
189 NeighbourSphere,
190 CoaxialRuled,
191 }
192 let mut edges_to_scale: HashSet<u64> = HashSet::default();
193 let mut through_centre_caps: HashSet<u64> = HashSet::default();
194 let mut own_only_edges: Vec<u64> = Vec::new();
195 let mut all_boundary_vertices: HashSet<u64> = HashSet::default();
196 let mut raw_rims: Vec<(u64, u64, RimKind)> = Vec::new();
197
198 for loop_record in &pushed.loops {
199 for coedge in &loop_record.coedges {
200 let edge = *edge_by_id
201 .get(&coedge.edge_id)
202 .ok_or_else(|| format!("offset_sphere_face: missing edge {}", coedge.edge_id))?;
203 all_boundary_vertices.insert(edge.start_vertex_id);
204 all_boundary_vertices.insert(edge.end_vertex_id);
205
206 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
207 if incident.iter().all(|f| *f == face_id) {
208 own_only_edges.push(edge.id); continue;
210 }
211 let neighbour = *incident
212 .iter()
213 .find(|f| **f != face_id)
214 .ok_or_else(|| format!("offset_sphere_face: edge {} has no neighbour", edge.id))?;
215 let (nshell, nface) = find_face(solid, neighbour)
216 .ok_or_else(|| format!("offset_sphere_face: missing neighbour {neighbour}"))?;
217 let neighbour_surface = &solid.shells[nshell].faces[nface].surface;
218 match neighbour_surface.analytic() {
219 Some(AnalyticSurface::Plane { .. }) => {
220 let plane =
221 plane_of_surface(neighbour_surface, plane_tolerance, "offset_sphere_face")?;
222 if center.sub(plane.origin).dot(plane.normal).abs() <= plane_tolerance {
223 through_centre_caps.insert(neighbour);
225 edges_to_scale.insert(edge.id);
226 } else {
227 raw_rims.push((edge.id, neighbour, RimKind::OffCentrePlane(plane)));
228 }
229 }
230 Some(AnalyticSurface::Sphere { .. }) => {
231 raw_rims.push((edge.id, neighbour, RimKind::NeighbourSphere));
232 }
233 Some(AnalyticSurface::RuledRevolution { .. }) => {
234 if !super::face_offset::ruled_neighbour_is_coaxial(
235 neighbour_surface,
236 center,
237 axis,
238 scale,
239 ) {
240 return Err("offset_sphere_face: a cylinder/cone rim neighbour is \
241 NON-coaxial (only coaxial sphere × ruled intersections are \
242 supported) — refusing"
243 .into());
244 }
245 raw_rims.push((edge.id, neighbour, RimKind::CoaxialRuled));
246 }
247 Some(AnalyticSurface::Torus { .. }) => {
248 return Err("offset_sphere_face: a toroidal rim neighbour is deferred \
249 (torus is untouched in this slice)"
250 .into());
251 }
252 _ => {
253 return Err("offset_sphere_face: a rim neighbour is not a supported plane, \
254 sphere, or coaxial cylinder/cone (general revolution and \
255 free-form neighbours are deferred in this slice)"
256 .into());
257 }
258 }
259 }
260 }
261
262 let mut closure_vertices: HashSet<u64> = HashSet::default();
265 for (edge_id, _, _) in &raw_rims {
266 let edge = *edge_by_id.get(edge_id).unwrap();
267 if edge.start_vertex_id != edge.end_vertex_id {
268 return Err(
269 "offset_sphere_face: a recomputed rim is not a single closed edge \
270 (multi-edge / open rims are deferred in this slice)"
271 .into(),
272 );
273 }
274 closure_vertices.insert(edge.start_vertex_id);
275 }
276
277 let mut coupled_meridians: HashSet<u64> = HashSet::default();
281 for own in &own_only_edges {
282 let e = *edge_by_id.get(own).unwrap();
283 if e.degenerate {
284 edges_to_scale.insert(*own);
285 continue;
286 }
287 if closure_vertices.contains(&e.start_vertex_id)
288 || closure_vertices.contains(&e.end_vertex_id)
289 {
290 coupled_meridians.insert(*own);
291 } else {
292 edges_to_scale.insert(*own);
293 }
294 }
295 let vertices_to_scale: HashSet<u64> = all_boundary_vertices
297 .iter()
298 .copied()
299 .filter(|v| !closure_vertices.contains(v))
300 .collect();
301
302 struct RimBuild {
304 edge_id: u64,
305 neighbour_id: u64,
306 neighbour_is_sphere: bool,
307 closure_vertex: u64,
308 circle: NurbsCurve,
309 closure_point: Vec3,
310 v_rim: f64,
311 pushed_pcurve: NurbsCurve,
312 }
313 let mut rim_builds: Vec<RimBuild> = Vec::new();
314 for (edge_id, neighbour, kind) in &raw_rims {
315 let edge = *edge_by_id.get(edge_id).unwrap();
316 let closure_vertex = edge.start_vertex_id;
317 let seam_coupled = coupled_meridians.iter().any(|m| {
318 let e = *edge_by_id.get(m).unwrap();
319 e.start_vertex_id == closure_vertex || e.end_vertex_id == closure_vertex
320 });
321
322 let mut circle = if seam_coupled {
323 match kind {
329 RimKind::OffCentrePlane(plane) => {
330 if plane.normal.dot(axis).abs() < 1.0 - 1e-6 {
331 return Err(
332 "offset_sphere_face: a seam-crossing rim on an OBLIQUE plane is \
333 deferred in this slice (only axis-perpendicular caps)"
334 .into(),
335 );
336 }
337 let a = plane.origin.sub(center).dot(axis);
338 let rr2 = radius_new * radius_new - a * a;
339 if rr2 <= tolerance * tolerance {
340 return Err(
341 "offset_sphere_face: the push pulls the grown sphere off its cap \
342 plane (the cap vanishes) — refusing"
343 .into(),
344 );
345 }
346 crate::make_arc(
347 center.add(axis.scale(a)),
348 seam_x,
349 seam_y,
350 rr2.sqrt(),
351 0.0,
352 std::f64::consts::TAU,
353 )?
354 }
355 RimKind::NeighbourSphere => {
356 return Err(
357 "offset_sphere_face: a sphere neighbour whose rim crosses the \
358 pushed sphere's seam is deferred in this slice"
359 .into(),
360 )
361 }
362 RimKind::CoaxialRuled => {
363 let (ns, nf) = find_face(solid, *neighbour).unwrap();
369 let neighbour_surface = &solid.shells[ns].faces[nf].surface;
370 let curves = intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
371 .filter(|curves| !curves.is_empty())
372 .ok_or_else(|| {
373 "offset_sphere_face: the grown sphere no longer meets its coaxial \
374 cylinder/cone neighbour — refusing"
375 .to_string()
376 })?;
377 let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
378 nearest_circle(&curves, old_mid)?
379 }
380 }
381 } else {
382 let (ns, nf) = find_face(solid, *neighbour).unwrap();
383 let neighbour_surface = &solid.shells[ns].faces[nf].surface;
384 let curves = intersect_analytic_pair(&s_prime, neighbour_surface, tolerance)
385 .filter(|curves| !curves.is_empty())
386 .ok_or_else(|| {
387 "offset_sphere_face: the grown sphere no longer meets a neighbour (the cap \
388 vanishes / tangent / separated) — refusing"
389 .to_string()
390 })?;
391 let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
392 nearest_circle(&curves, old_mid)?
393 };
394
395 let closure_old = edge.curve.evaluate(edge.t0)?;
398 let old_tan = edge
399 .curve
400 .evaluate(edge.t0 + 0.01 * (edge.t1 - edge.t0))?
401 .sub(closure_old);
402 let [c0, c1] = circle.domain()?;
403 let new_start = circle.evaluate(c0)?;
404 let new_tan = circle.evaluate(c0 + 0.01 * (c1 - c0))?.sub(new_start);
405 if old_tan.dot(new_tan) < 0.0 {
406 circle = circle.reversed()?;
407 }
408 let closure_point = circle.evaluate(circle.domain()?[0])?;
409 let pushed_pcurve = build_pcurve_on_surface(&s_prime, &circle)?;
410 let [q0, q1] = pushed_pcurve.domain()?;
413 let v_rim = pushed_pcurve.evaluate(0.5 * (q0 + q1))?.y;
414
415 rim_builds.push(RimBuild {
416 edge_id: *edge_id,
417 neighbour_id: *neighbour,
418 neighbour_is_sphere: matches!(kind, RimKind::NeighbourSphere),
419 closure_vertex,
420 circle,
421 closure_point,
422 v_rim,
423 pushed_pcurve,
424 });
425 }
426
427 let iso_meridian = s_prime.iso_curve_u(0.0)?;
431
432 let mut result = solid.clone();
434
435 for edge in &mut result.edges {
437 if edges_to_scale.contains(&edge.id) {
438 edge.curve = transform_curve(&edge.curve, scale_about_center)?;
439 }
440 }
441 for rb in &rim_builds {
443 if let Some(edge) = result.edges.iter_mut().find(|e| e.id == rb.edge_id) {
444 let [d0, d1] = rb.circle.domain()?;
445 edge.curve = rb.circle.clone();
446 edge.t0 = d0;
447 edge.t1 = d1;
448 }
449 }
450 for mer in &coupled_meridians {
453 let e = *edge_by_id.get(mer).unwrap();
454 let cv = if closure_vertices.contains(&e.start_vertex_id) {
455 e.start_vertex_id
456 } else {
457 e.end_vertex_id
458 };
459 let v_rim = rim_builds
460 .iter()
461 .find(|rb| rb.closure_vertex == cv)
462 .map(|rb| rb.v_rim)
463 .ok_or_else(|| {
464 "offset_sphere_face: a seam meridian is not paired with a rim — refusing"
465 .to_string()
466 })?;
467 let closure_at_start = e.start_vertex_id == cv;
468 let fixed_vertex = if closure_at_start {
469 e.end_vertex_id
470 } else {
471 e.start_vertex_id
472 };
473 let fixed_old = solid
474 .vertices
475 .iter()
476 .find(|v| v.id == fixed_vertex)
477 .map(|v| v.point)
478 .ok_or_else(|| format!("offset_sphere_face: missing seam vertex {fixed_vertex}"))?;
479 let fixed_new = scale_about_center.point(fixed_old);
480 let fixed_projection = project_point_to_curve(&iso_meridian, fixed_new)?;
481 if fixed_projection.distance > plane_tolerance {
482 return Err(format!(
483 "offset_sphere_face: a coupled own-edge is not on the sphere seam \
484 (off by {:.3e}) — refusing",
485 fixed_projection.distance
486 ));
487 }
488 let v_start = if closure_at_start {
489 v_rim
490 } else {
491 fixed_projection.u
492 };
493 let v_end = if closure_at_start {
494 fixed_projection.u
495 } else {
496 v_rim
497 };
498 let (curve, t0, t1) = if v_start <= v_end {
499 (iso_meridian.clone(), v_start, v_end)
500 } else {
501 (
502 iso_meridian.reversed()?,
503 dv0 + dv1 - v_start,
504 dv0 + dv1 - v_end,
505 )
506 };
507 if let Some(edge) = result.edges.iter_mut().find(|x| x.id == *mer) {
508 edge.curve = curve;
509 edge.t0 = t0;
510 edge.t1 = t1;
511 }
512 }
513
514 for vertex in &mut result.vertices {
516 if vertices_to_scale.contains(&vertex.id) {
517 vertex.point = scale_about_center.point(vertex.point);
518 }
519 }
520 for rb in &rim_builds {
521 if let Some(v) = result.vertices.iter_mut().find(|v| v.id == rb.closure_vertex) {
522 v.point = rb.closure_point;
523 }
524 }
525
526 {
530 let pface_rec = &mut result.shells[pshell].faces[pface];
531 for loop_record in &mut pface_rec.loops {
532 for coedge in &mut loop_record.coedges {
533 if let Some(rb) = rim_builds.iter().find(|rb| rb.edge_id == coedge.edge_id) {
534 let mut pcurve = rb.pushed_pcurve.clone();
535 if !coedge.forward {
536 pcurve = pcurve.reversed()?;
537 }
538 coedge.pcurve = pcurve;
539 } else if coupled_meridians.contains(&coedge.edge_id) {
540 let e = *edge_by_id.get(&coedge.edge_id).unwrap();
541 let cv = if closure_vertices.contains(&e.start_vertex_id) {
542 e.start_vertex_id
543 } else {
544 e.end_vertex_id
545 };
546 let v_rim = rim_builds
547 .iter()
548 .find(|rb| rb.closure_vertex == cv)
549 .map(|rb| rb.v_rim)
550 .unwrap();
551 let closure_at_pcurve_start = (e.start_vertex_id == cv) == coedge.forward;
552 coedge.pcurve =
553 patch_meridian_pcurve(&coedge.pcurve, v_rim, closure_at_pcurve_start)?;
554 }
555 }
556 }
557 }
558 result.shells[pshell].faces[pface].surface = s_prime;
559
560 let result_vertex: HashMap<u64, Vec3> =
564 result.vertices.iter().map(|v| (v.id, v.point)).collect();
565 let mut ruled_seams: HashSet<u64> = HashSet::default();
566 for rb in &rim_builds {
567 let (ns, nf) = find_face(&result, rb.neighbour_id)
568 .ok_or_else(|| format!("offset_sphere_face: missing neighbour {}", rb.neighbour_id))?;
569 if !matches!(
570 result.shells[ns].faces[nf].surface.analytic(),
571 Some(AnalyticSurface::RuledRevolution { .. })
572 ) {
573 continue;
574 }
575 for loop_record in &result.shells[ns].faces[nf].loops {
576 for coedge in &loop_record.coedges {
577 let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
578 format!("offset_sphere_face: missing edge {}", coedge.edge_id)
579 })?;
580 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
581 let is_seam = incident.iter().all(|f| *f == rb.neighbour_id);
582 let touches_rim = closure_vertices.contains(&edge.start_vertex_id)
583 || closure_vertices.contains(&edge.end_vertex_id);
584 if is_seam && touches_rim {
585 ruled_seams.insert(edge.id);
586 }
587 }
588 }
589 }
590 let rebuilt_ruled_seams: HashSet<u64> = ruled_seams.iter().copied().collect();
591 for seam_id in ruled_seams {
592 let edge = *edge_by_id
593 .get(&seam_id)
594 .ok_or_else(|| format!("offset_sphere_face: missing ruled seam {seam_id}"))?;
595 let curve = make_line(
596 result_vertex[&edge.start_vertex_id],
597 result_vertex[&edge.end_vertex_id],
598 )?;
599 if let Some(out) = result
600 .edges
601 .iter_mut()
602 .find(|candidate| candidate.id == seam_id)
603 {
604 let [t0, t1] = curve.domain()?;
605 out.curve = curve;
606 out.t0 = t0;
607 out.t1 = t1;
608 }
609 }
610
611 let mut relocated: HashMap<u64, Vec3> = HashMap::default();
637 for vertex in &result.vertices {
638 let Some(old) = solid.vertices.iter().find(|o| o.id == vertex.id) else {
639 continue;
640 };
641 if vertex.point.sub(old.point).length() > tolerance {
642 relocated.insert(vertex.id, vertex.point);
643 }
644 }
645 let already_rebuilt: HashSet<u64> = edges_to_scale
646 .iter()
647 .copied()
648 .chain(rim_builds.iter().map(|rb| rb.edge_id))
649 .chain(coupled_meridians.iter().copied())
650 .chain(rebuilt_ruled_seams.iter().copied())
651 .collect();
652 let mut resized_edges: HashSet<u64> = HashSet::default();
653 for edge in &mut result.edges {
654 if edge.degenerate || already_rebuilt.contains(&edge.id) {
655 continue;
656 }
657 for at_start in [true, false] {
658 let vertex_id = if at_start {
659 edge.start_vertex_id
660 } else {
661 edge.end_vertex_id
662 };
663 let Some(&target) = relocated.get(&vertex_id) else {
664 continue;
665 };
666 let projection = project_point_to_curve(&edge.curve, target)?;
667 if projection.distance > 10.0 * tolerance {
668 return Err(format!(
669 "offset_sphere_face: the push moves rim corner (vertex {vertex_id}) OFF \
670 fixed edge {} by {:.3e} — a rim corner that leaves its own side edge \
671 needs the corner re-solved against the neighbour carriers, which is \
672 deferred — refusing",
673 edge.id, projection.distance
674 ));
675 }
676 let [d0, d1] = edge.curve.domain()?;
677 let span = (d1 - d0).max(1e-12);
678 let fixed_t = if at_start { edge.t1 } else { edge.t0 };
679 let old_t = if at_start { edge.t0 } else { edge.t1 };
680 let new_t = projection.u;
681 if (new_t - fixed_t) * (old_t - fixed_t) <= 0.0
682 || (new_t - fixed_t).abs() <= 1e-7 * span
683 {
684 return Err(format!(
685 "offset_sphere_face: the push collapses or inverts the trim of fixed edge \
686 {} at rim corner (vertex {vertex_id}) — refusing",
687 edge.id
688 ));
689 }
690 if at_start {
691 edge.t0 = new_t;
692 } else {
693 edge.t1 = new_t;
694 }
695 resized_edges.insert(edge.id);
696 }
697 }
698
699 let final_edges: HashMap<u64, EdgeRecord> =
701 result.edges.iter().map(|e| (e.id, e.clone())).collect();
702 for rb in &rim_builds {
703 let (ns, nf) = find_face(&result, rb.neighbour_id)
704 .ok_or_else(|| format!("offset_sphere_face: missing neighbour {}", rb.neighbour_id))?;
705 if rb.neighbour_is_sphere {
706 let nsurf = result.shells[ns].faces[nf].surface.clone();
708 for loop_record in &mut result.shells[ns].faces[nf].loops {
709 for coedge in &mut loop_record.coedges {
710 if coedge.edge_id == rb.edge_id {
711 let mut pcurve = build_pcurve_on_surface(&nsurf, &rb.circle)?;
712 if !coedge.forward {
713 pcurve = pcurve.reversed()?;
714 }
715 coedge.pcurve = pcurve;
716 }
717 }
718 }
719 } else if matches!(
720 result.shells[ns].faces[nf].surface.analytic(),
721 Some(AnalyticSurface::RuledRevolution { .. })
722 ) {
723 super::face_offset::retrim_offset_ruled_face(
724 &mut result,
725 rb.neighbour_id,
726 &final_edges,
727 tolerance,
728 )?;
729 } else {
730 let plane = plane_of_surface(
731 &result.shells[ns].faces[nf].surface,
732 plane_tolerance,
733 "offset_sphere_face",
734 )?;
735 retrim_planar_face(
736 &mut result.shells[ns].faces[nf],
737 &plane,
738 &final_edges,
739 scale,
740 "offset_sphere_face",
741 )?;
742 refit_subrange_pcurves(
743 &mut result.shells[ns].faces[nf],
744 &final_edges,
745 tolerance,
746 )?;
747 }
748 }
749 for cap in &through_centre_caps {
751 let (cshell, cface) = find_face(&result, *cap)
752 .ok_or_else(|| format!("offset_sphere_face: missing cap {cap}"))?;
753 let plane = plane_of_surface(
754 &result.shells[cshell].faces[cface].surface,
755 plane_tolerance,
756 "offset_sphere_face",
757 )?;
758 retrim_planar_face(
759 &mut result.shells[cshell].faces[cface],
760 &plane,
761 &final_edges,
762 scale,
763 "offset_sphere_face",
764 )?;
765 refit_subrange_pcurves(
766 &mut result.shells[cshell].faces[cface],
767 &final_edges,
768 tolerance,
769 )?;
770 }
771 for shell in &mut result.shells {
775 for face in &mut shell.faces {
776 if face
777 .loops
778 .iter()
779 .flat_map(|l| &l.coedges)
780 .any(|c| resized_edges.contains(&c.edge_id))
781 {
782 refit_touched_pcurves(
783 face,
784 &final_edges,
785 &resized_edges,
786 true,
787 tolerance,
788 "offset_sphere_face",
789 )?;
790 }
791 }
792 }
793
794 let issues = result.validate();
795 if !issues.is_empty() {
796 return Err(format!(
797 "offset_sphere_face: pushed solid failed validation: {issues:?}"
798 ));
799 }
800 if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
801 if before * after <= 0.0 {
802 return Err("offset_sphere_face: the push inverts the solid — refusing".into());
803 }
804 }
805 Ok(result)
806}
807
808fn refit_subrange_pcurves(
823 face: &mut FaceRecord,
824 edges: &HashMap<u64, EdgeRecord>,
825 tolerance: f64,
826) -> Result<(), String> {
827 let face_edges: HashSet<u64> = face
828 .loops
829 .iter()
830 .flat_map(|loop_record| loop_record.coedges.iter().map(|c| c.edge_id))
831 .collect();
832 refit_touched_pcurves(
833 face,
834 edges,
835 &face_edges,
836 true,
837 tolerance,
838 "offset_sphere_face",
839 )
840}
841
842fn nearest_circle(curves: &[NurbsCurve], reference: Vec3) -> Result<NurbsCurve, String> {
845 let mut best: Option<(f64, &NurbsCurve)> = None;
846 for curve in curves {
847 let [d0, d1] = curve.domain()?;
848 let mid = curve.evaluate(0.5 * (d0 + d1))?;
849 let distance = mid.sub(reference).length();
850 if best.map(|(known, _)| distance < known).unwrap_or(true) {
851 best = Some((distance, curve));
852 }
853 }
854 best.map(|(_, curve)| curve.clone())
855 .ok_or_else(|| "offset_sphere_face: the sphere ∩ neighbour intersection is empty".into())
856}
857
858fn patch_meridian_pcurve(
863 pcurve: &NurbsCurve,
864 v_new: f64,
865 closure_at_start: bool,
866) -> Result<NurbsCurve, String> {
867 let [q0, q1] = pcurve.domain()?;
868 let start = pcurve.evaluate(q0)?;
869 let end = pcurve.evaluate(q1)?;
870 let (v_start, v_end) = if closure_at_start {
871 (v_new, end.y)
872 } else {
873 (start.y, v_new)
874 };
875 make_line(
876 Vec3::new(start.x, v_start, 0.0),
877 Vec3::new(end.x, v_end, 0.0),
878 )
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884 use crate::{make_sphere_brep, AnalyticSurface};
885
886 fn sphere_face_id(solid: &BrepSolid) -> u64 {
887 solid
888 .shells
889 .iter()
890 .flat_map(|shell| &shell.faces)
891 .find(|face| {
892 matches!(
893 face.surface.analytic(),
894 Some(AnalyticSurface::Sphere { .. })
895 )
896 })
897 .expect("a spherical face")
898 .id
899 }
900
901 fn sphere_radius(solid: &BrepSolid) -> f64 {
902 solid
903 .shells
904 .iter()
905 .flat_map(|shell| &shell.faces)
906 .find_map(|face| match face.surface.analytic() {
907 Some(AnalyticSurface::Sphere { radius, .. }) => Some(*radius),
908 _ => None,
909 })
910 .expect("a spherical radius")
911 }
912
913 fn rim_radius_off_axis(solid: &BrepSolid, axis_point: Vec3, axis: Vec3) -> f64 {
916 let mut faces_of_edge: std::collections::HashMap<u64, Vec<u64>> = Default::default();
917 let mut kind: std::collections::HashMap<u64, &'static str> = Default::default();
918 for shell in &solid.shells {
919 for face in &shell.faces {
920 let k = match face.surface.analytic() {
921 Some(AnalyticSurface::Sphere { .. }) => "sphere",
922 Some(AnalyticSurface::Plane { .. }) => "plane",
923 _ => "other",
924 };
925 kind.insert(face.id, k);
926 for lp in &face.loops {
927 for ce in &lp.coedges {
928 faces_of_edge.entry(ce.edge_id).or_default().push(face.id);
929 }
930 }
931 }
932 }
933 for edge in &solid.edges {
934 if edge.degenerate {
935 continue;
936 }
937 let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
938 let has_sphere = incident.iter().any(|f| kind.get(f) == Some(&"sphere"));
939 let has_plane = incident.iter().any(|f| kind.get(f) == Some(&"plane"));
940 if has_sphere && has_plane {
941 let point = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).unwrap();
942 let delta = point.sub(axis_point);
943 let radial = delta.sub(axis.scale(delta.dot(axis)));
944 return radial.length();
945 }
946 }
947 panic!("no sphere×plane rim edge found");
948 }
949
950
951 fn box_corner_pocket(size: f64, radius: f64) -> BrepSolid {
959 let block = crate::make_box_brep(Vec3::default(), size, size, size).unwrap();
960 let ball =
961 make_sphere_brep(Vec3::default(), radius, Vec3::new(0.0, 0.0, 1.0)).unwrap();
962 crate::boolean_operation(
963 &block,
964 &ball,
965 crate::BooleanOperation::Subtract,
966 &crate::BooleanOptions {
967 merge_coplanar_faces: true,
968 ..crate::BooleanOptions::default()
969 },
970 )
971 .expect("box − corner ball")
972 }
973
974 fn every_edge_reaches_its_vertices(solid: &BrepSolid) {
978 for edge in &solid.edges {
979 if edge.degenerate {
980 continue;
981 }
982 for (parameter, vertex_id) in [
983 (edge.t0, edge.start_vertex_id),
984 (edge.t1, edge.end_vertex_id),
985 ] {
986 let point = edge.curve.evaluate(parameter).expect("edge evaluates");
987 let vertex = solid
988 .vertices
989 .iter()
990 .find(|v| v.id == vertex_id)
991 .unwrap_or_else(|| panic!("missing vertex {vertex_id}"));
992 let gap = point.sub(vertex.point).length();
993 assert!(
994 gap < 1e-9,
995 "edge {} does not reach vertex {vertex_id}: gap {gap}",
996 edge.id
997 );
998 }
999 }
1000 }
1001
1002 #[test]
1008 fn push_a_spherical_pocket_in_a_box_corner_carries_its_rim_corners() {
1009 let (size, radius) = (20.0_f64, 5.0_f64);
1010 let solid = box_corner_pocket(size, radius);
1011 assert!(solid.validate().is_empty(), "fixture: {:?}", solid.validate());
1012 let face = sphere_face_id(&solid);
1014 let rim: Vec<&EdgeRecord> = solid
1015 .shells
1016 .iter()
1017 .flat_map(|shell| &shell.faces)
1018 .filter(|f| f.id == face)
1019 .flat_map(|f| &f.loops)
1020 .flat_map(|l| &l.coedges)
1021 .filter_map(|c| solid.edges.iter().find(|e| e.id == c.edge_id))
1022 .collect();
1023 assert_eq!(rim.len(), 3, "an octant cavity has three rim arcs");
1024 assert!(
1025 rim.iter().all(|e| e.start_vertex_id != e.end_vertex_id),
1026 "every rim arc must be OPEN — a closed rim never reaches this path"
1027 );
1028 let before = solid_signed_volume(&solid).unwrap().abs();
1029
1030 for distance in [1.5_f64, -1.5, 3.0, -3.0] {
1031 let pushed = offset_sphere_face(&solid, face, distance)
1032 .unwrap_or_else(|e| panic!("pocket push {distance}: {e}"));
1033 let issues = pushed.validate();
1034 assert!(issues.is_empty(), "push {distance} is invalid: {issues:?}");
1035 every_edge_reaches_its_vertices(&pushed);
1036 let shrunk = radius - distance;
1039 assert!(
1040 (sphere_radius(&pushed) - shrunk).abs() < 1e-9,
1041 "push {distance}: radius {} vs {shrunk}",
1042 sphere_radius(&pushed)
1043 );
1044 let gained = solid_signed_volume(&pushed).unwrap().abs() - before;
1045 let expected =
1046 std::f64::consts::PI / 6.0 * (radius.powi(3) - shrunk.powi(3));
1047 assert!(
1054 (gained - expected).abs() < 1e-4 * expected.abs(),
1055 "push {distance}: gained {gained}, expected {expected}"
1056 );
1057 assert_eq!(pushed.edges.len(), solid.edges.len(), "topology untouched");
1058 assert_eq!(
1059 pushed.vertices.len(),
1060 solid.vertices.len(),
1061 "topology untouched"
1062 );
1063 }
1064 assert!(solid.validate().is_empty(), "the input is never mutated");
1065 }
1066
1067 #[test]
1073 fn growing_a_corner_pocket_until_its_rim_corner_reaches_the_far_end_refuses() {
1074 let (size, radius) = (20.0_f64, 5.0_f64);
1075 let solid = box_corner_pocket(size, radius);
1076 let face = sphere_face_id(&solid);
1077 let ok = offset_sphere_face(&solid, face, -(size - radius - 0.5))
1079 .expect("a pocket that still leaves a sliver of box edge heals");
1080 assert!(ok.validate().is_empty(), "{:?}", ok.validate());
1081 every_edge_reaches_its_vertices(&ok);
1082 let collapse = offset_sphere_face(&solid, face, -(size - radius))
1087 .expect_err("a rim corner exactly at the far end must refuse");
1088 assert!(
1089 collapse.contains("collapses or inverts the trim of fixed edge"),
1090 "expected the trim-collapse guard, got: {collapse}"
1091 );
1092 for over in [0.5_f64, 5.0] {
1096 let error = offset_sphere_face(&solid, face, -(size - radius) - over)
1097 .expect_err("a rim corner past the end of its own edge must refuse");
1098 assert!(
1099 error.contains("moves rim corner") && error.contains("OFF fixed edge"),
1100 "expected the corner-off-its-edge guard, got: {error}"
1101 );
1102 }
1103 assert!(solid.validate().is_empty(), "the input is never mutated");
1104 }
1105
1106 #[test]
1110 fn push_full_sphere_changes_radius() {
1111 for (d, r_new) in [(2.0_f64, 7.0_f64), (-2.0, 3.0)] {
1112 let sphere =
1113 make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1114 let face = sphere_face_id(&sphere);
1115 let pushed = offset_sphere_face(&sphere, face, d)
1116 .unwrap_or_else(|e| panic!("push full sphere by {d}: {e}"));
1117 let issues = pushed.validate();
1118 assert!(issues.is_empty(), "validate {d}: {issues:?}");
1119 assert!(
1120 (sphere_radius(&pushed) - r_new).abs() < 1e-9,
1121 "radius after push {d}: got {}, want {r_new}",
1122 sphere_radius(&pushed)
1123 );
1124 let got = solid_signed_volume(&pushed).unwrap().abs();
1125 let expected = 4.0 / 3.0 * std::f64::consts::PI * r_new.powi(3);
1126 assert!(
1127 (got - expected).abs() < 1e-2,
1128 "ball volume {got}, want {expected}"
1129 );
1130 }
1131 }
1132
1133 #[test]
1139 fn push_hemispherical_pocket_resizes_the_cavity() {
1140 let r = 5.0f64;
1141 for (d, r_new) in [(1.0_f64, 4.0_f64), (-1.0, 6.0)] {
1142 let block = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 10.0).unwrap();
1143 let ball =
1144 make_sphere_brep(Vec3::new(10.0, 10.0, 10.0), r, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1145 let options = crate::BooleanOptions {
1146 merge_coplanar_faces: true,
1147 ..crate::BooleanOptions::default()
1148 };
1149 let pocket = crate::boolean_operation(
1150 &block,
1151 &ball,
1152 crate::BooleanOperation::Subtract,
1153 &options,
1154 )
1155 .unwrap();
1156 let v0 = solid_signed_volume(&pocket).unwrap().abs();
1157 let face = sphere_face_id(&pocket);
1158
1159 let pushed = offset_sphere_face(&pocket, face, d)
1160 .unwrap_or_else(|e| panic!("push pocket by {d}: {e}"));
1161 let issues = pushed.validate();
1162 assert!(issues.is_empty(), "validate {d}: {issues:?}");
1163 assert!(
1164 (sphere_radius(&pushed) - r_new).abs() < 1e-6,
1165 "pocket radius after push {d}: got {}, want {r_new}",
1166 sphere_radius(&pushed)
1167 );
1168 let got = solid_signed_volume(&pushed).unwrap().abs();
1171 let cavity_before = 2.0 / 3.0 * std::f64::consts::PI * r.powi(3);
1172 let cavity_after = 2.0 / 3.0 * std::f64::consts::PI * r_new.powi(3);
1173 let expected = v0 + (cavity_before - cavity_after);
1174 assert!(
1175 (got - expected).abs() < 1e-2,
1176 "pocket push {d}: volume {got}, want {expected}"
1177 );
1178 }
1179 }
1180
1181 #[test]
1184 fn push_full_sphere_through_centre_refuses() {
1185 let sphere = make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1186 let face = sphere_face_id(&sphere);
1187 let err = offset_sphere_face(&sphere, face, -5.0).expect_err("collapse must refuse");
1188 assert!(err.contains("centre") || err.contains("refus"), "unexpected: {err}");
1189 }
1190
1191 #[test]
1198 fn push_offcentre_spherical_cap_resizes() {
1199 let r = 5.0f64;
1200 let h = 3.0f64; for (d, r_new) in [(1.0_f64, 4.0_f64), (-1.0, 6.0)] {
1202 let block = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 10.0).unwrap();
1203 let ball =
1204 make_sphere_brep(Vec3::new(10.0, 10.0, 7.0), r, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1205 let options = crate::BooleanOptions {
1206 merge_coplanar_faces: true,
1207 ..crate::BooleanOptions::default()
1208 };
1209 let pocket = crate::boolean_operation(
1210 &block,
1211 &ball,
1212 crate::BooleanOperation::Subtract,
1213 &options,
1214 )
1215 .unwrap();
1216 let face = sphere_face_id(&pocket);
1217 let pushed = offset_sphere_face(&pocket, face, d)
1218 .unwrap_or_else(|e| panic!("push off-centre cap by {d}: {e}"));
1219 let issues = pushed.validate();
1220 assert!(issues.is_empty(), "validate {d}: {issues:?}");
1221 assert!(
1222 (sphere_radius(&pushed) - r_new).abs() < 1e-6,
1223 "off-centre cap radius after push {d}: got {}, want {r_new}",
1224 sphere_radius(&pushed)
1225 );
1226 let want_rim = (r_new * r_new - h * h).sqrt();
1228 let got_rim = rim_radius_off_axis(
1229 &pushed,
1230 Vec3::new(10.0, 10.0, 0.0),
1231 Vec3::new(0.0, 0.0, 1.0),
1232 );
1233 assert!(
1234 (got_rim - want_rim).abs() < 1e-6,
1235 "rim radius after push {d}: got {got_rim}, want {want_rim}"
1236 );
1237 }
1238 }
1239
1240 #[test]
1246 fn push_offcentre_northern_cap_resizes() {
1247 let r = 5.0f64;
1248 let h = 3.0f64; for (d, r_new) in [(1.0_f64, 4.0_f64), (-1.0, 6.0)] {
1250 let block = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 10.0).unwrap();
1251 let ball =
1252 make_sphere_brep(Vec3::new(10.0, 10.0, 3.0), r, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1253 let options = crate::BooleanOptions {
1254 merge_coplanar_faces: true,
1255 ..crate::BooleanOptions::default()
1256 };
1257 let pocket = crate::boolean_operation(
1258 &block,
1259 &ball,
1260 crate::BooleanOperation::Subtract,
1261 &options,
1262 )
1263 .unwrap();
1264 let face = sphere_face_id(&pocket);
1265 let pushed = offset_sphere_face(&pocket, face, d)
1266 .unwrap_or_else(|e| panic!("push northern off-centre cap by {d}: {e}"));
1267 let issues = pushed.validate();
1268 assert!(issues.is_empty(), "validate {d}: {issues:?}");
1269 assert!(
1270 (sphere_radius(&pushed) - r_new).abs() < 1e-6,
1271 "northern cap radius after push {d}: got {}, want {r_new}",
1272 sphere_radius(&pushed)
1273 );
1274 let want_rim = (r_new * r_new - h * h).sqrt();
1275 let got_rim = rim_radius_off_axis(
1276 &pushed,
1277 Vec3::new(10.0, 10.0, 0.0),
1278 Vec3::new(0.0, 0.0, 1.0),
1279 );
1280 assert!(
1281 (got_rim - want_rim).abs() < 1e-6,
1282 "northern rim radius after push {d}: got {got_rim}, want {want_rim}"
1283 );
1284 }
1285 }
1286
1287 #[test]
1290 fn push_offcentre_cap_vanish_refuses() {
1291 let block = crate::make_box_brep(Vec3::new(0.0, 0.0, 0.0), 20.0, 20.0, 10.0).unwrap();
1292 let ball =
1293 make_sphere_brep(Vec3::new(10.0, 10.0, 7.0), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1294 let options = crate::BooleanOptions {
1295 merge_coplanar_faces: true,
1296 ..crate::BooleanOptions::default()
1297 };
1298 let pocket =
1299 crate::boolean_operation(&block, &ball, crate::BooleanOperation::Subtract, &options)
1300 .unwrap();
1301 let face = sphere_face_id(&pocket);
1302 let err = offset_sphere_face(&pocket, face, 2.5).expect_err("a vanished cap must refuse");
1304 assert!(
1305 err.contains("vanish") || err.contains("collapse") || err.contains("centre"),
1306 "unexpected refusal: {err}"
1307 );
1308 }
1309
1310 #[test]
1315 fn push_sphere_with_sphere_neighbour_resizes() {
1316 for (d, r_new) in [(1.0_f64, 7.0_f64), (-1.0, 5.0)] {
1317 let a = make_sphere_brep(Vec3::new(0.0, 0.0, 0.0), 6.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1318 let b = make_sphere_brep(Vec3::new(8.0, 0.0, 0.0), 6.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1319 let options = crate::BooleanOptions {
1320 merge_coplanar_faces: true,
1321 ..crate::BooleanOptions::default()
1322 };
1323 let uni =
1324 crate::boolean_operation(&a, &b, crate::BooleanOperation::Union, &options).unwrap();
1325 let face = uni
1327 .shells
1328 .iter()
1329 .flat_map(|shell| &shell.faces)
1330 .filter(|f| matches!(f.surface.analytic(), Some(AnalyticSurface::Sphere { .. })))
1331 .find(|f| {
1332 matches!(
1333 f.surface.analytic(),
1334 Some(AnalyticSurface::Sphere { frame, .. }) if frame.origin.length() < 1e-6
1335 )
1336 })
1337 .expect("sphere A face")
1338 .id;
1339 let pushed = offset_sphere_face(&uni, face, d)
1340 .unwrap_or_else(|e| panic!("push sphere-neighbour by {d}: {e}"));
1341 let issues = pushed.validate();
1342 assert!(issues.is_empty(), "validate {d}: {issues:?}");
1343 let radii: Vec<f64> = pushed
1345 .shells
1346 .iter()
1347 .flat_map(|shell| &shell.faces)
1348 .filter_map(|f| match f.surface.analytic() {
1349 Some(AnalyticSurface::Sphere { radius, .. }) => Some(*radius),
1350 _ => None,
1351 })
1352 .collect();
1353 assert!(
1354 radii.iter().any(|r| (r - r_new).abs() < 1e-6),
1355 "grown sphere radius {r_new} not found in {radii:?}"
1356 );
1357 assert!(
1358 radii.iter().any(|r| (r - 6.0).abs() < 1e-6),
1359 "fixed sphere radius 6 not found in {radii:?}"
1360 );
1361 }
1362 }
1363
1364 #[test]
1368 fn push_sphere_with_coaxial_cylinder_neighbour_resizes() {
1369 for (d, r_new) in [(0.5_f64, 5.5_f64), (-0.5, 4.5)] {
1370 let ball = make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1371 let cylinder = crate::make_cylinder_brep(
1372 Vec3::new(0.0, 0.0, 3.0),
1373 Vec3::new(0.0, 0.0, 1.0),
1374 2.0,
1375 5.0,
1376 )
1377 .unwrap();
1378 let joined = crate::boolean_operation(
1379 &ball,
1380 &cylinder,
1381 crate::BooleanOperation::Union,
1382 &crate::BooleanOptions::default(),
1383 )
1384 .unwrap();
1385 let face = sphere_face_id(&joined);
1386 let pushed = offset_sphere_face(&joined, face, d)
1387 .unwrap_or_else(|e| panic!("push sphere×cylinder by {d}: {e}"));
1388 assert!(pushed.validate().is_empty());
1389 assert!((sphere_radius(&pushed) - r_new).abs() < 1e-6);
1390 let want_z = (r_new * r_new - 4.0).sqrt();
1392 let rim_z = pushed
1393 .edges
1394 .iter()
1395 .filter(|edge| edge.start_vertex_id == edge.end_vertex_id && !edge.degenerate)
1396 .map(|edge| edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)).unwrap().z)
1397 .min_by(|a, b| (a - want_z).abs().total_cmp(&(b - want_z).abs()))
1398 .expect("closed sphere×cylinder rim");
1399 assert!(
1400 (rim_z - want_z).abs() < 1e-6,
1401 "rim z={rim_z}, want {want_z}"
1402 );
1403 }
1404 }
1405
1406 #[test]
1409 fn push_sphere_with_coaxial_cone_neighbour_resizes() {
1410 for (d, r_new) in [(0.35_f64, 5.35_f64), (-0.35, 4.65)] {
1411 let ball = make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1412 let cone = crate::make_cone_brep(
1413 Vec3::new(0.0, 0.0, 3.0),
1414 Vec3::new(0.0, 0.0, 1.0),
1415 2.0,
1416 1.0,
1417 5.0,
1418 )
1419 .unwrap();
1420 let joined = crate::boolean_operation(
1421 &ball,
1422 &cone,
1423 crate::BooleanOperation::Union,
1424 &crate::BooleanOptions::default(),
1425 )
1426 .unwrap();
1427 let face = sphere_face_id(&joined);
1428 let pushed = offset_sphere_face(&joined, face, d)
1429 .unwrap_or_else(|e| panic!("push sphere×cone by {d}: {e}"));
1430 let issues = pushed.validate();
1431 assert!(issues.is_empty(), "validate {d}: {issues:?}");
1432 assert!((sphere_radius(&pushed) - r_new).abs() < 1e-6);
1433 assert!(pushed.shells.iter().flat_map(|s| &s.faces).any(|face| {
1434 matches!(
1435 face.surface.analytic(),
1436 Some(AnalyticSurface::RuledRevolution { rho0, rho1, .. })
1437 if (rho0 - rho1).abs() > 1e-6
1438 )
1439 }));
1440 }
1441 }
1442
1443 #[test]
1447 fn push_sphere_with_noncoaxial_cylinder_neighbour_refuses() {
1448 let ball = make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1449 let cylinder =
1450 crate::make_cylinder_brep(Vec3::new(1.0, 0.0, 3.0), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0)
1451 .unwrap();
1452 let joined = crate::boolean_operation(
1453 &ball,
1454 &cylinder,
1455 crate::BooleanOperation::Union,
1456 &crate::BooleanOptions::default(),
1457 )
1458 .unwrap();
1459 let face = sphere_face_id(&joined);
1460 let err = offset_sphere_face(&joined, face, 0.25)
1461 .expect_err("non-coaxial sphere×cylinder must refuse");
1462 assert!(err.contains("NON-coaxial"), "unexpected refusal: {err}");
1463 assert!(joined.validate().is_empty(), "the input solid was mutated");
1464 }
1465
1466 #[test]
1467 fn push_sphere_off_coaxial_cylinder_refuses_cleanly() {
1468 let ball = make_sphere_brep(Vec3::default(), 5.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
1469 let cylinder =
1470 crate::make_cylinder_brep(Vec3::new(0.0, 0.0, 3.0), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0)
1471 .unwrap();
1472 let joined = crate::boolean_operation(
1473 &ball,
1474 &cylinder,
1475 crate::BooleanOperation::Union,
1476 &crate::BooleanOptions::default(),
1477 )
1478 .unwrap();
1479 let face = sphere_face_id(&joined);
1480 let err = offset_sphere_face(&joined, face, -3.5)
1483 .expect_err("separated sphere×cylinder must refuse");
1484 assert!(
1485 err.contains("no longer meets") || err.contains("refus"),
1486 "unexpected refusal: {err}"
1487 );
1488 }
1489}