Skip to main content

brep_kernel/edit/direct_edit/
delete_face.rs

1use super::*;
2
3struct HealPlan {
4    /// The two boundary-edge indices whose neighbours are the primary pair.
5    primary: [usize; 2],
6    /// The two lateral boundary-edge indices (end caps).
7    lateral: [usize; 2],
8    /// Triple point (recovered corner) for each lateral index.
9    lateral_point: HashMap<usize, Vec3>,
10}
11
12/// Choose the unique opposite neighbour pair whose planes re-intersect through
13/// the transition face's region.
14fn plan_heal(planes: &[Plane; 4], f_center: Vec3, f_reach: f64) -> Result<HealPlan, String> {
15    let mut candidates: Vec<HealPlan> = Vec::new();
16    for start in 0..2usize {
17        let primary = [start, start + 2];
18        let lateral = [(start + 1) % 4, (start + 3) % 4];
19        let Some(line) = intersect_planes(&planes[primary[0]], &planes[primary[1]]) else {
20            continue;
21        };
22        let mut lateral_point = HashMap::default();
23        let mut ok = true;
24        for &lat in &lateral {
25            match intersect_line_plane(&line, &planes[lat]) {
26                Some(point) if point.sub(f_center).length() <= f_reach => {
27                    lateral_point.insert(lat, point);
28                }
29                _ => {
30                    ok = false;
31                    break;
32                }
33            }
34        }
35        if !ok {
36            continue;
37        }
38        candidates.push(HealPlan {
39            primary,
40            lateral,
41            lateral_point,
42        });
43    }
44    match candidates.len() {
45        1 => Ok(candidates.pop().unwrap()),
46        0 => Err(
47            "delete_face_and_heal: the neighbours do not re-intersect cleanly \
48                  (parallel planes, non-adjacent healing, or a multi-face gap) — refusing \
49                  rather than emitting an invalid solid"
50                .into(),
51        ),
52        _ => Err(
53            "delete_face_and_heal: healing is ambiguous — both opposite neighbour \
54                  pairs re-intersect through the deleted face"
55                .into(),
56        ),
57    }
58}
59
60// `retrim_planar_face` moved to the shared re-trim home
61// (`crate::offset_retrim`, offset-unification audit §10) — its body is
62// unchanged and it is re-exported here so its nine direct-edit call sites keep
63// the bare name they have always used.
64pub(super) use crate::offset_retrim::retrim_planar_face;
65
66/// Find, in a face's loops, the (loop index, coedge index) of the coedge that
67/// references `edge_id`.
68pub(super) fn locate_coedge(face: &FaceRecord, edge_id: u64) -> Option<(usize, usize)> {
69    for (loop_index, loop_record) in face.loops.iter().enumerate() {
70        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
71            if coedge.edge_id == edge_id {
72                return Some((loop_index, coedge_index));
73            }
74        }
75    }
76    None
77}
78
79pub(super) fn coedge_from_vertex(coedge: &CoedgeRecord, edges: &HashMap<u64, EdgeRecord>) -> Option<u64> {
80    let edge = edges.get(&coedge.edge_id)?;
81    Some(if coedge.forward {
82        edge.start_vertex_id
83    } else {
84        edge.end_vertex_id
85    })
86}
87
88pub(super) fn coedge_to_vertex(coedge: &CoedgeRecord, edges: &HashMap<u64, EdgeRecord>) -> Option<u64> {
89    let edge = edges.get(&coedge.edge_id)?;
90    Some(if coedge.forward {
91        edge.end_vertex_id
92    } else {
93        edge.start_vertex_id
94    })
95}
96
97/// Golovanov §6.12 — delete a transition face and heal the hole by extending
98/// and re-intersecting its immediate neighbours. See the module docs for the
99/// covered vs deferred cases. Returns a fresh solid that is guaranteed to
100/// `validate()`, or a clear `Err` describing why the heal was refused.
101pub fn delete_face_and_heal(solid: &BrepSolid, face_id: u64) -> Result<BrepSolid, String> {
102    let mut solid = solid.clone();
103    let scale = solid_model_scale(&solid);
104    let tolerance = (scale * 1e-7).max(1e-9);
105
106    let (shell_index, face_index) = find_face(&solid, face_id)
107        .ok_or_else(|| format!("delete_face_and_heal: no face with id {face_id}"))?;
108
109    // --- Read the transition face's boundary (immutable snapshot) ---------
110    let boundary: Vec<(u64, bool)> = {
111        let face = &solid.shells[shell_index].faces[face_index];
112        if face.loops.len() != 1 {
113            return Err(format!(
114                "delete_face_and_heal: face {face_id} has {} loops; only a simple \
115                 single-loop transition face is supported",
116                face.loops.len()
117            ));
118        }
119        face.loops[0]
120            .coedges
121            .iter()
122            .map(|coedge| (coedge.edge_id, coedge.forward))
123            .collect()
124    };
125    if boundary.len() != 4 {
126        return Err(format!(
127            "delete_face_and_heal: face {face_id} has {} boundary edges; only 4-sided \
128             transition faces (a single chamfer/fillet edge) are supported \
129             (deferred: multi-face gaps)",
130            boundary.len()
131        ));
132    }
133    let boundary_edge_ids: HashSet<u64> = boundary.iter().map(|(edge_id, _)| *edge_id).collect();
134    if boundary_edge_ids.len() == 3 {
135        // A CLOSED transition strip (a fillet/chamfer around a full rim):
136        // loop = [seam+, rim_a, seam-, rim_b] — the seam doubled, two closed
137        // rims. Heals by re-intersecting the two neighbours analytically.
138        return heal_closed_transition(&solid, shell_index, face_index, &boundary);
139    }
140    if boundary_edge_ids.len() != 4 {
141        return Err(
142            "delete_face_and_heal: transition face uses an edge more than once \
143                    (deferred: periodic/closed transition)"
144                .into(),
145        );
146    }
147
148    // Neighbour faces (one per boundary edge, in loop order).
149    let mut neighbour_ids = [0u64; 4];
150    for (index, (edge_id, _)) in boundary.iter().enumerate() {
151        neighbour_ids[index] = other_face_of_edge(&solid, *edge_id, face_id)?;
152    }
153    let unique: HashSet<u64> = neighbour_ids.iter().copied().collect();
154    if unique.len() != 4 {
155        return Err(
156            "delete_face_and_heal: the transition face touches a neighbour more \
157                    than once (deferred: periodic/closed transition)"
158                .into(),
159        );
160    }
161
162    // Carriers of the neighbours: all-planar takes the closed-form planar
163    // path below; any curved analytic neighbour routes to the mixed
164    // open-chain heal (plane × cylinder/ruled-revolution re-intersection).
165    let neighbour_planes: [Plane; 4] = {
166        let mut planes: Vec<Option<Plane>> = Vec::with_capacity(4);
167        for &neighbour_id in &neighbour_ids {
168            let (ns, nf) = find_face(&solid, neighbour_id)
169                .ok_or_else(|| format!("delete_face_and_heal: missing neighbour {neighbour_id}"))?;
170            planes.push(
171                plane_of_surface(
172                    &solid.shells[ns].faces[nf].surface,
173                    (scale * 1e-6).max(1e-7),
174                    "delete_face_and_heal",
175                )
176                .ok(),
177            );
178        }
179        if planes.iter().any(Option::is_none) {
180            return heal_open_transition_mixed(
181                &solid,
182                shell_index,
183                face_index,
184                &boundary,
185                &neighbour_ids,
186            );
187        }
188        [
189            planes[0].unwrap(),
190            planes[1].unwrap(),
191            planes[2].unwrap(),
192            planes[3].unwrap(),
193        ]
194    };
195
196    // Transition-face region gate (centre + reach), from its loop vertices.
197    let transition_vertices: Vec<u64> = boundary
198        .iter()
199        .map(|(edge_id, forward)| {
200            let edge = solid
201                .edges
202                .iter()
203                .find(|edge| edge.id == *edge_id)
204                .ok_or_else(|| format!("delete_face_and_heal: missing edge {edge_id}"))?;
205            Ok(if *forward {
206                edge.start_vertex_id
207            } else {
208                edge.end_vertex_id
209            })
210        })
211        .collect::<Result<Vec<u64>, String>>()?;
212    if transition_vertices.iter().collect::<HashSet<_>>().len() != 4 {
213        return Err(
214            "delete_face_and_heal: transition face has repeated corner vertices \
215                    (deferred: degenerate transition)"
216                .into(),
217        );
218    }
219    let mut f_center = Vec3::default();
220    for &vertex_id in &transition_vertices {
221        f_center = f_center.add(edge_point(&solid, vertex_id)?);
222    }
223    f_center = f_center.scale(0.25);
224    let mut f_reach = 0.0f64;
225    for &vertex_id in &transition_vertices {
226        f_reach = f_reach.max(edge_point(&solid, vertex_id)?.sub(f_center).length());
227    }
228    let f_reach = f_reach * 3.0 + tolerance;
229
230    let plan = plan_heal(&neighbour_planes, f_center, f_reach)?;
231
232    // --- New recovered corners (triple points) and the new sharp edge ------
233    let mut next_id = max_topology_id(&solid) + 1;
234    let mut alloc = || {
235        let value = next_id;
236        next_id += 1;
237        value
238    };
239
240    let lateral_a = plan.lateral[0];
241    let lateral_b = plan.lateral[1];
242    let point_a = plan.lateral_point[&lateral_a];
243    let point_b = plan.lateral_point[&lateral_b];
244    if point_a.sub(point_b).length() <= tolerance {
245        return Err(
246            "delete_face_and_heal: recovered corners coincide — the neighbours \
247                    do not bound a clean edge"
248                .into(),
249        );
250    }
251    let vertex_a = alloc();
252    let vertex_b = alloc();
253    let mut lateral_vertex: HashMap<usize, u64> = HashMap::default();
254    lateral_vertex.insert(lateral_a, vertex_a);
255    lateral_vertex.insert(lateral_b, vertex_b);
256
257    // The new sharp edge S (start = corner A, end = corner B).
258    let sharp_edge_id = alloc();
259    let sharp_edge = EdgeRecord {
260        id: sharp_edge_id,
261        curve: make_line(point_a, point_b)?,
262        t0: 0.0,
263        t1: 1.0,
264        start_vertex_id: vertex_a,
265        end_vertex_id: vertex_b,
266        degenerate: false,
267        name: None,
268    };
269
270    // --- Collapse each transition corner onto its recovered corner ---------
271    // Corner vertex `transition_vertices[i]` sits between neighbour[(i+3)%4]
272    // and neighbour[i]; exactly one of those two is a lateral face, and the
273    // corner collapses onto that lateral's recovered corner.
274    let lateral_set: HashSet<usize> = plan.lateral.iter().copied().collect();
275    let mut collapse: HashMap<u64, u64> = HashMap::default();
276    for index in 0..4usize {
277        let previous = (index + 3) % 4;
278        let lateral_index = if lateral_set.contains(&previous) {
279            previous
280        } else if lateral_set.contains(&index) {
281            index
282        } else {
283            return Err(
284                "delete_face_and_heal: transition corner is not flanked by a \
285                        lateral face (unexpected neighbour ordering)"
286                    .into(),
287            );
288        };
289        let target = lateral_vertex[&lateral_index];
290        collapse.insert(transition_vertices[index], target);
291    }
292    let new_vertex_points: HashMap<u64, Vec3> = [(vertex_a, point_a), (vertex_b, point_b)]
293        .into_iter()
294        .collect();
295
296    // Relocate every non-transition edge that ends on a collapsed corner.
297    for edge in &mut solid.edges {
298        if boundary_edge_ids.contains(&edge.id) {
299            continue;
300        }
301        let start_target = collapse.get(&edge.start_vertex_id).copied();
302        let end_target = collapse.get(&edge.end_vertex_id).copied();
303        if start_target.is_none() && end_target.is_none() {
304            continue;
305        }
306        if edge.curve.degree != 1 || edge.curve.control_points.len() != 2 {
307            return Err(
308                "delete_face_and_heal: a side edge meeting the transition face is \
309                        not a straight line (deferred: curved neighbour edges)"
310                    .into(),
311            );
312        }
313        let mut start_point = edge.curve.control_points[0].point()?;
314        let mut end_point = edge.curve.control_points[1].point()?;
315        if let Some(target) = start_target {
316            edge.start_vertex_id = target;
317            start_point = new_vertex_points[&target];
318        }
319        if let Some(target) = end_target {
320            edge.end_vertex_id = target;
321            end_point = new_vertex_points[&target];
322        }
323        if start_point.sub(end_point).length() <= tolerance {
324            return Err(
325                "delete_face_and_heal: healing would collapse a side edge to zero \
326                        length (deferred: degenerate transition)"
327                    .into(),
328            );
329        }
330        edge.curve = make_line(start_point, end_point)?;
331        edge.t0 = 0.0;
332        edge.t1 = 1.0;
333    }
334
335    // Index the (now relocated) edges for loop rewrites and pcurve rebuilds.
336    let mut edges_by_id: HashMap<u64, EdgeRecord> = solid
337        .edges
338        .iter()
339        .map(|edge| (edge.id, edge.clone()))
340        .collect();
341    edges_by_id.insert(sharp_edge_id, sharp_edge.clone());
342
343    // --- Rewrite neighbour loops ------------------------------------------
344    // Primary faces: replace their support coedge (on the deleted face's edge)
345    // with a coedge on the new sharp edge S.
346    for &primary_index in &plan.primary {
347        let primary_edge = boundary[primary_index].0;
348        let neighbour_id = neighbour_ids[primary_index];
349        let (ns, nf) = find_face(&solid, neighbour_id)
350            .ok_or_else(|| format!("delete_face_and_heal: missing neighbour {neighbour_id}"))?;
351        let face = &mut solid.shells[ns].faces[nf];
352        let (loop_index, coedge_index) = locate_coedge(face, primary_edge).ok_or_else(|| {
353            format!(
354                "delete_face_and_heal: neighbour {neighbour_id} does not use edge {primary_edge}"
355            )
356        })?;
357        let coedges = &face.loops[loop_index].coedges;
358        let count = coedges.len();
359        let previous = &coedges[(coedge_index + count - 1) % count];
360        let next = &coedges[(coedge_index + 1) % count];
361        let required_from = coedge_to_vertex(previous, &edges_by_id).ok_or_else(|| {
362            "delete_face_and_heal: could not resolve loop connectivity".to_string()
363        })?;
364        let required_to = coedge_from_vertex(next, &edges_by_id).ok_or_else(|| {
365            "delete_face_and_heal: could not resolve loop connectivity".to_string()
366        })?;
367        let forward = if required_from == vertex_a && required_to == vertex_b {
368            true
369        } else if required_from == vertex_b && required_to == vertex_a {
370            false
371        } else {
372            return Err(
373                "delete_face_and_heal: new edge does not close the primary loop \
374                        (unexpected connectivity)"
375                    .into(),
376            );
377        };
378        let new_coedge = CoedgeRecord {
379            id: alloc(),
380            edge_id: sharp_edge_id,
381            forward,
382            // Placeholder; retrim_planar_face recomputes every pcurve below.
383            pcurve: make_line(Vec3::default(), Vec3::new(1.0, 0.0, 0.0))?,
384        };
385        face.loops[loop_index].coedges[coedge_index] = new_coedge;
386    }
387
388    // Lateral (cap) faces: drop the cap coedge; its two neighbours already
389    // meet at the recovered corner.
390    for &lateral_index in &plan.lateral {
391        let lateral_edge = boundary[lateral_index].0;
392        let neighbour_id = neighbour_ids[lateral_index];
393        let (ns, nf) = find_face(&solid, neighbour_id)
394            .ok_or_else(|| format!("delete_face_and_heal: missing neighbour {neighbour_id}"))?;
395        let face = &mut solid.shells[ns].faces[nf];
396        let (loop_index, coedge_index) = locate_coedge(face, lateral_edge).ok_or_else(|| {
397            format!(
398                "delete_face_and_heal: neighbour {neighbour_id} does not use edge {lateral_edge}"
399            )
400        })?;
401        face.loops[loop_index].coedges.remove(coedge_index);
402        if face.loops[loop_index].coedges.is_empty() {
403            return Err("delete_face_and_heal: healing emptied a lateral face loop".into());
404        }
405    }
406
407    // --- Prune the deleted face, its edges, and its corner vertices --------
408    solid.shells[shell_index]
409        .faces
410        .retain(|face| face.id != face_id);
411    solid
412        .edges
413        .retain(|edge| !boundary_edge_ids.contains(&edge.id));
414    let removed_vertices: HashSet<u64> = transition_vertices.iter().copied().collect();
415    solid.edges.push(sharp_edge);
416    solid
417        .vertices
418        .retain(|vertex| !removed_vertices.contains(&vertex.id));
419    solid.vertices.push(VertexRecord {
420        id: vertex_a,
421        point: point_a,
422    });
423    solid.vertices.push(VertexRecord {
424        id: vertex_b,
425        point: point_b,
426    });
427
428    // --- Re-trim every affected neighbour on its (extended) carrier --------
429    let final_edges: HashMap<u64, EdgeRecord> = solid
430        .edges
431        .iter()
432        .map(|edge| (edge.id, edge.clone()))
433        .collect();
434    for (index, &neighbour_id) in neighbour_ids.iter().enumerate() {
435        let plane = neighbour_planes[index];
436        let (ns, nf) = find_face(&solid, neighbour_id)
437            .ok_or_else(|| format!("delete_face_and_heal: missing neighbour {neighbour_id}"))?;
438        retrim_planar_face(
439            &mut solid.shells[ns].faces[nf],
440            &plane,
441            &final_edges,
442            scale,
443            "delete_face_and_heal",
444        )?;
445    }
446
447    // Genus is preserved by removing a genus-neutral transition face; the
448    // Euler check inside validate() confirms it.
449    let issues = solid.validate();
450    if !issues.is_empty() {
451        return Err(format!(
452            "delete_face_and_heal: healed solid failed validation: {issues:?}"
453        ));
454    }
455    Ok(solid)
456}
457
458/// Resolve the id of the face nearest a 3D point (used by the app: the picked
459/// point lies on the selected face). Mirrors `resolve_edge_by_point`.
460pub fn resolve_face_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, String> {
461    let scale = solid_model_scale(&solid);
462    let mut best: Option<(u64, f64)> = None;
463    for shell in &solid.shells {
464        for face in &shell.faces {
465            let Ok(projection) = crate::project_point_to_surface(&face.surface, point) else {
466                continue;
467            };
468            if best
469                .map(|(_, known)| projection.distance < known)
470                .unwrap_or(true)
471            {
472                best = Some((face.id, projection.distance));
473            }
474        }
475    }
476    match best {
477        Some((face_id, distance)) if distance <= (scale * 1e-3).max(1e-4) => Ok(face_id),
478        Some((_, distance)) => Err(format!(
479            "delete_face_and_heal: no face within tolerance of the point (nearest {distance:.6})"
480        )),
481        None => Err("delete_face_and_heal: solid has no faces".into()),
482    }
483}
484
485// ---------------------------------------------------------------------------
486// Tests — the capability matrix `examples/delete_face_matrix_probe.rs` measures,
487// pinned. The probe is the instrument; these are the assertions it justified.
488//
489// The three lanes `delete_face_and_heal` dispatches across are covered in
490// `tests.rs` (all-planar), `closed_heal_tests.rs` (closed full rim) and
491// `open_heal_tests.rs` (open mixed). What lives HERE is what those files left
492// unpinned: the dispatch GATES that never reach a lane at all, the closed
493// lane's carrier breadth, and the one measurement that decides whether the
494// shared re-intersection seam can replace the planar closed form.
495// ---------------------------------------------------------------------------
496#[cfg(test)]
497mod delete_face_tests {
498    use super::*;
499    use crate::{
500        boolean_operation, chamfer_edge, fillet_edge, make_box_brep, make_cone_brep,
501        make_cylinder_brep, make_sphere_brep, solid_mass_properties, BooleanOperation,
502        BooleanOptions,
503    };
504
505    /// The closed rim edge of a body of revolution at height `z`.
506    fn closed_rim_at(solid: &BrepSolid, z: f64) -> u64 {
507        solid
508            .edges
509            .iter()
510            .find(|edge| {
511                !edge.degenerate
512                    && edge.start_vertex_id == edge.end_vertex_id
513                    && edge
514                        .curve
515                        .evaluate(0.5 * (edge.t0 + edge.t1))
516                        .map(|point| (point.z - z).abs() < 1e-6)
517                        .unwrap_or(false)
518            })
519            .expect("closed rim")
520            .id
521    }
522
523    fn named_face(solid: &BrepSolid, name: &str) -> u64 {
524        solid
525            .shells
526            .iter()
527            .flat_map(|shell| &shell.faces)
528            .find(|face| face.name.as_deref() == Some(name))
529            .expect("named blend face")
530            .id
531    }
532
533    fn volume(solid: &BrepSolid) -> f64 {
534        solid_mass_properties(solid)
535            .expect("mass properties")
536            .volume
537    }
538
539    /// Blend a closed rim, delete the blend, and require the ORIGINAL body
540    /// back: same face count, same volume, clean `validate()`. The blend is
541    /// the only thing that changed, so anything else that moves is a bug.
542    fn round_trip(
543        sharp: &BrepSolid,
544        rim: u64,
545        blend: fn(&BrepSolid, u64, f64, Option<&str>) -> Result<BrepSolid, String>,
546        size: f64,
547    ) -> BrepSolid {
548        let faces_before: usize = sharp.shells.iter().map(|shell| shell.faces.len()).sum();
549        let volume_before = volume(sharp);
550        let blended = blend(sharp, rim, size, Some("B1")).expect("blend");
551        assert!(blended.validate().is_empty(), "{:?}", blended.validate());
552        let strip = named_face(&blended, "B1");
553
554        let healed = delete_face_and_heal(&blended, strip).expect("heal");
555        assert!(
556            healed.validate().is_empty(),
557            "healed solid must validate: {:?}",
558            healed.validate()
559        );
560        assert_eq!(
561            healed
562                .shells
563                .iter()
564                .map(|shell| shell.faces.len())
565                .sum::<usize>(),
566            faces_before,
567            "face count must return to the pre-blend count"
568        );
569        let volume_after = volume(&healed);
570        assert!(
571            (volume_after - volume_before).abs() <= 1e-6 * volume_before.abs(),
572            "expected the pre-blend volume {volume_before}, got {volume_after}"
573        );
574        healed
575    }
576
577    // --- closed lane: carrier breadth ------------------------------------
578
579    /// A rim CHAMFER leaves a conical strip between the same two neighbours a
580    /// rim fillet leaves a toroidal one between. `closed_heal_tests.rs` pins
581    /// only the fillet; this pins that the strip's own carrier is irrelevant
582    /// to the heal, which re-intersects the NEIGHBOURS.
583    #[test]
584    fn closed_heal_restores_the_cylinder_after_a_rim_chamfer() {
585        let cylinder =
586            make_cylinder_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 4.0, 6.0).unwrap();
587        let rim = closed_rim_at(&cylinder, 6.0);
588        let healed = round_trip(&cylinder, rim, chamfer_edge, 1.0);
589        // The re-sharpened rim is back at the top, radius 4.
590        assert!(healed.edges.iter().any(|edge| {
591            let Ok(point) = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)) else {
592                return false;
593            };
594            (point.z - 6.0).abs() < 1e-6 && (point.x.hypot(point.y) - 4.0).abs() < 1e-6
595        }));
596    }
597
598    /// A cone frustum's wall is a TAPERED ruled revolution — the closed form
599    /// the rim rebind uses is `axial / height`, which is only the identity for
600    /// a cylinder. Pins that the taper is carried.
601    #[test]
602    fn closed_heal_restores_the_cone_frustum_after_a_rim_fillet() {
603        let cone =
604            make_cone_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 2.5, 6.0).unwrap();
605        let rim = closed_rim_at(&cone, 6.0);
606        let healed = round_trip(&cone, rim, fillet_edge, 0.8);
607        assert!(healed.edges.iter().any(|edge| {
608            let Ok(point) = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)) else {
609                return false;
610            };
611            (point.z - 6.0).abs() < 1e-6 && (point.x.hypot(point.y) - 2.5).abs() < 1e-6
612        }));
613    }
614
615    /// An upper half-ball: a flat disk whose only neighbour is the spherical
616    /// dome, meeting along the great-circle equator.
617    fn upper_half_ball(radius: f64) -> BrepSolid {
618        let sphere = make_sphere_brep(Vec3::default(), radius, Vec3::new(0.0, 0.0, 1.0)).unwrap();
619        let box_up = make_box_brep(
620            Vec3::new(-2.0 * radius, -2.0 * radius, 0.0),
621            4.0 * radius,
622            4.0 * radius,
623            2.0 * radius,
624        )
625        .unwrap();
626        let options = BooleanOptions {
627            merge_coplanar_faces: true,
628            ..BooleanOptions::default()
629        };
630        boolean_operation(&sphere, &box_up, BooleanOperation::Intersect, &options).unwrap()
631    }
632
633    /// A SPHERE neighbour. `intersect_analytic_pair` answers Sphere×Plane with
634    /// the exact equator circle, but the rim rebind used to refuse anything
635    /// that was not a `RuledRevolution` — the gate was narrower than the
636    /// intersector behind it. Measured by the matrix probe as
637    /// `ERR curved neighbour is not a ruled revolution` before this landed.
638    #[test]
639    fn closed_heal_rebinds_a_sphere_neighbour_after_a_rim_fillet() {
640        let ball = upper_half_ball(5.0);
641        let rim = closed_rim_at(&ball, 0.0);
642        let healed = round_trip(&ball, rim, fillet_edge, 0.8);
643        // The recovered rim is the great circle: radius 5 in the plane z = 0.
644        assert!(healed.edges.iter().any(|edge| {
645            let Ok(point) = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1)) else {
646                return false;
647            };
648            point.z.abs() < 1e-6 && (point.x.hypot(point.y) - 5.0).abs() < 1e-6
649        }));
650        // Every point of every edge still sits on the ball or its base plane —
651        // the check that catches a seam meridian replaced by its chord.
652        for edge in &healed.edges {
653            if edge.degenerate {
654                continue;
655            }
656            for step in 0..=8 {
657                let t = edge.t0 + (edge.t1 - edge.t0) * f64::from(step) / 8.0;
658                let point = edge.curve.evaluate(t).unwrap();
659                let on_sphere = (point.length() - 5.0).abs() < 1e-6;
660                let on_base = point.z.abs() < 1e-6 && point.length() <= 5.0 + 1e-6;
661                assert!(
662                    on_sphere || on_base,
663                    "edge {} left both carriers at t={t}: {point:?}",
664                    edge.id
665                );
666            }
667        }
668    }
669
670    /// The same neighbour pair with a CONICAL strip instead of a toroidal one.
671    #[test]
672    fn closed_heal_rebinds_a_sphere_neighbour_after_a_rim_chamfer() {
673        let ball = upper_half_ball(5.0);
674        let rim = closed_rim_at(&ball, 0.0);
675        round_trip(&ball, rim, chamfer_edge, 0.8);
676    }
677
678    /// A TORUS neighbour, the other carrier `intersect_plane_quadric` answers
679    /// and the rim rebind used to refuse. Half a torus cut by the plane
680    /// through its tube: `Plane × Torus` returns TWO closed circles (inner and
681    /// outer rim), so this also pins that the heal picks the branch the strip
682    /// surrounded rather than the first one offered.
683    #[test]
684    fn closed_heal_rebinds_a_torus_neighbour_after_a_rim_fillet() {
685        let torus =
686            crate::make_torus_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 5.0, 2.0).unwrap();
687        let box_up = make_box_brep(Vec3::new(-20.0, -20.0, 0.0), 40.0, 40.0, 20.0).unwrap();
688        let options = BooleanOptions {
689            merge_coplanar_faces: true,
690            ..BooleanOptions::default()
691        };
692        let half =
693            boolean_operation(&torus, &box_up, BooleanOperation::Intersect, &options).unwrap();
694        // Half a torus: V = pi^2 * R * r^2.
695        let expected = std::f64::consts::PI.powi(2) * 5.0 * 4.0;
696        assert!((volume(&half) - expected).abs() <= 1e-6 * expected);
697        let rim = closed_rim_at(&half, 0.0);
698        let healed = round_trip(&half, rim, fillet_edge, 0.4);
699        assert!((volume(&healed) - expected).abs() <= 1e-6 * expected);
700    }
701
702    // --- dispatch gates: the refusals that never reach a lane --------------
703
704    #[test]
705    fn refuses_a_face_that_does_not_exist() {
706        let cube = make_box_brep(Vec3::default(), 1.0, 1.0, 1.0).unwrap();
707        let error = delete_face_and_heal(&cube, 999_999).unwrap_err();
708        assert!(
709            error.contains("no face with id 999999"),
710            "unexpected refusal: {error}"
711        );
712    }
713
714    /// A drilled plate's top face has TWO loops (outer boundary + the hole).
715    /// Healing a multi-loop face is a different problem from healing a
716    /// transition strip; the gate must say so rather than heal the outer loop
717    /// and leave the hole dangling.
718    #[test]
719    fn refuses_a_multi_loop_face_naming_the_loop_count() {
720        let plate = make_box_brep(Vec3::default(), 20.0, 20.0, 4.0).unwrap();
721        let drill = make_cylinder_brep(
722            Vec3::new(10.0, 10.0, -1.0),
723            Vec3::new(0.0, 0.0, 1.0),
724            3.0,
725            6.0,
726        )
727        .unwrap();
728        let drilled = boolean_operation(
729            &plate,
730            &drill,
731            BooleanOperation::Subtract,
732            &BooleanOptions::default(),
733        )
734        .unwrap();
735        let top = resolve_face_by_point(&drilled, Vec3::new(2.0, 2.0, 4.0)).unwrap();
736        let error = delete_face_and_heal(&drilled, top).unwrap_err();
737        assert!(
738            error.contains("2 loops") && error.contains("single-loop"),
739            "refusal must name the loop count: {error}"
740        );
741    }
742
743    /// A 3-sided face is a CORNER transition: healing it recovers a vertex,
744    /// not an edge, and that is a different algorithm (heal-tail plan §3.3,
745    /// k = 3). The gate must name the side count so the refusal is actionable.
746    #[test]
747    fn refuses_a_face_that_is_not_four_sided() {
748        let cube = make_box_brep(Vec3::default(), 10.0, 10.0, 10.0).unwrap();
749        let profile = [
750            make_line(Vec3::new(8.0, 0.0, 10.0), Vec3::new(0.0, 8.0, 10.0)).unwrap(),
751            make_line(Vec3::new(0.0, 8.0, 10.0), Vec3::new(20.0, 20.0, 10.0)).unwrap(),
752            make_line(Vec3::new(20.0, 20.0, 10.0), Vec3::new(8.0, 0.0, 10.0)).unwrap(),
753        ];
754        let cutter =
755            crate::extrude_profile_brep(&profile, Vec3::new(0.0, 0.0, -1.0), 8.0).unwrap();
756        let cut = boolean_operation(
757            &cube,
758            &cutter,
759            BooleanOperation::Subtract,
760            &BooleanOptions::default(),
761        )
762        .unwrap();
763        let triangle = cut
764            .shells
765            .iter()
766            .flat_map(|shell| &shell.faces)
767            .find(|face| face.loops.len() == 1 && face.loops[0].coedges.len() == 3)
768            .expect("3-sided cut face")
769            .id;
770        let error = delete_face_and_heal(&cut, triangle).unwrap_err();
771        assert!(
772            error.contains("3 boundary edges") && error.contains("4-sided"),
773            "refusal must name the side count: {error}"
774        );
775    }
776
777    // --- the shared re-intersection seam, measured -------------------------
778
779    /// Can `offset_reintersect::reintersect_carriers` — the shared "these two
780    /// carriers used to meet through a face that moved, where do they meet
781    /// now" service — replace the all-planar closed form in `plan_heal`?
782    ///
783    /// MEASURED, not predicted: it ANSWERS — but only through the MARCHED
784    /// lane, and that is the whole answer.
785    ///
786    /// `intersect_analytic_pair` declines Plane×Plane **by design**
787    /// (`geometry/analytic_surface/intersect.rs:173-179`, with the reason in a
788    /// comment there), so the shared seam falls straight through its exact
789    /// lane for the commonest delete-face case there is and traces the line
790    /// instead. What comes back is a `fit_polyline` of a traced polyline,
791    /// residual-gated but an approximation; `plan_heal` returns the exact
792    /// `intersect_planes` line. Swapping the seam in for the closed form would
793    /// therefore replace an exact edge with a fitted one on every chamfered
794    /// box in the corpus — strictly less exact, and not bit-identical.
795    ///
796    /// (A prediction this test refuted, recorded so it is not made twice: the
797    /// march was expected to find NOTHING, on the grounds that it clamps to
798    /// each surface's stored domain — `intersect/surface_surface_intersection.rs:111-117`
799    /// — and a blend trims its neighbours back. It does clamp; but `chamfer_edge`
800    /// leaves the neighbour CARRIERS at their original extents and only rewrites
801    /// the trim loops, so the patches still overlap. Carrier coverage after a
802    /// blend is a property of the blend, not something a general re-intersection
803    /// may assume either way.)
804    #[test]
805    fn the_shared_reintersect_seam_declines_the_chamfered_cube_primaries() {
806        let cube = make_box_brep(Vec3::default(), 1.0, 1.0, 1.0).unwrap();
807        let edge = cube
808            .edges
809            .iter()
810            .find(|edge| {
811                edge.curve
812                    .evaluate(0.5 * (edge.t0 + edge.t1))
813                    .map(|point| (point.x - 1.0).abs() < 1e-9 && (point.z - 1.0).abs() < 1e-9)
814                    .unwrap_or(false)
815            })
816            .expect("top-right cube edge")
817            .id;
818        let chamfered = chamfer_edge(&cube, edge, 0.2, Some("C1")).unwrap();
819        let strip = named_face(&chamfered, "C1");
820
821        // The two PRIMARY neighbours: the faces the chamfer's opposite
822        // boundary edges border — the ones whose re-intersection is the
823        // recovered sharp edge.
824        let top = resolve_face_by_point(&chamfered, Vec3::new(0.4, 0.5, 1.0)).unwrap();
825        let right = resolve_face_by_point(&chamfered, Vec3::new(1.0, 0.5, 0.4)).unwrap();
826        assert_ne!(top, strip);
827        assert_ne!(right, strip);
828        let surface_of = |id: u64| {
829            chamfered
830                .shells
831                .iter()
832                .flat_map(|shell| &shell.faces)
833                .find(|face| face.id == id)
834                .map(|face| face.surface.clone())
835                .expect("face")
836        };
837
838        // Seeded on the very boundary the heal is replacing — the strongest
839        // seed set there is, so a refusal here is not a seeding accident.
840        let mut seeds: Vec<Vec3> = Vec::new();
841        for edge in &chamfered.edges {
842            for step in 0..=4 {
843                let t = edge.t0 + (edge.t1 - edge.t0) * f64::from(step) / 4.0;
844                if let Ok(point) = edge.curve.evaluate(t) {
845                    seeds.push(point);
846                }
847            }
848        }
849        let policy = MarchPolicy {
850            tolerance: 1e-7,
851            residual_tolerance: 1e-5,
852            seeds,
853        };
854        let found = reintersect_carriers(&surface_of(top), &surface_of(right), &policy)
855            .expect("the seam answers this pair");
856        assert!(
857            matches!(found.lane, RimLane::Marched),
858            "plane x plane has no closed form, so the answer must come from the \
859             marched lane; got the analytic one"
860        );
861        assert_eq!(found.sections.len(), 1, "one line, traced");
862        // It is the right line, to within a fit residual — which is exactly the
863        // exactness the closed form does not spend.
864        let section = &found.sections[0];
865        let [s0, s1] = section.curve.domain().unwrap();
866        let mut worst = 0.0f64;
867        for step in 0..=16 {
868            let point = section
869                .curve
870                .evaluate(s0 + (s1 - s0) * f64::from(step) / 16.0)
871                .unwrap();
872            worst = worst.max((point.x - 1.0).abs().max((point.z - 1.0).abs()));
873        }
874        assert!(worst <= 1e-5, "the marched line drifts {worst} off x=1,z=1");
875
876        // And the operation heals them anyway, through the closed form.
877        let healed = delete_face_and_heal(&chamfered, strip).unwrap();
878        assert!(healed.validate().is_empty());
879        assert!((volume(&healed) - 1.0).abs() < 1e-9);
880    }
881}