Skip to main content

brep_kernel/edit/direct_edit/
delete_face.rs

1use super::*;
2use crate::{KernelRefusal, KernelStage, OrRefuse};
3
4struct HealPlan {
5    /// The two boundary-edge indices whose neighbours are the primary pair.
6    primary: [usize; 2],
7    /// The two lateral boundary-edge indices (end caps).
8    lateral: [usize; 2],
9    /// Triple point (recovered corner) for each lateral index.
10    lateral_point: HashMap<usize, Vec3>,
11}
12
13/// Choose the unique opposite neighbour pair whose planes re-intersect through
14/// the transition face's region.
15fn plan_heal(planes: &[Plane; 4], f_center: Vec3, f_reach: f64) -> Result<HealPlan, KernelRefusal> {
16    let mut candidates: Vec<HealPlan> = Vec::new();
17    for start in 0..2usize {
18        let primary = [start, start + 2];
19        let lateral = [(start + 1) % 4, (start + 3) % 4];
20        let Some(line) = intersect_planes(&planes[primary[0]], &planes[primary[1]]) else {
21            continue;
22        };
23        let mut lateral_point = HashMap::default();
24        let mut ok = true;
25        for &lat in &lateral {
26            match intersect_line_plane(&line, &planes[lat]) {
27                Some(point) if point.sub(f_center).length() <= f_reach => {
28                    lateral_point.insert(lat, point);
29                }
30                _ => {
31                    ok = false;
32                    break;
33                }
34            }
35        }
36        if !ok {
37            continue;
38        }
39        candidates.push(HealPlan {
40            primary,
41            lateral,
42            lateral_point,
43        });
44    }
45    match candidates.len() {
46        1 => Ok(candidates.pop().unwrap()),
47        0 => Err(KernelRefusal::ill_posed(
48            KernelStage::Classify,
49            "no_reintersection",
50            "delete_face_and_heal: the neighbours do not re-intersect cleanly \
51                  (parallel planes, non-adjacent healing, or a multi-face gap) — refusing \
52                  rather than emitting an invalid solid",
53        )),
54        _ => Err(KernelRefusal::ill_posed(
55            KernelStage::Classify,
56            "ambiguous_pair",
57            "delete_face_and_heal: healing is ambiguous — both opposite neighbour \
58                  pairs re-intersect through the deleted face",
59        )),
60    }
61}
62
63/// The position of a neighbour the transition face's own boundary named, so a
64/// miss is a consistency failure of the solid rather than a caller error.
65fn neighbour_position(solid: &BrepSolid, neighbour_id: u64) -> Result<(usize, usize), KernelRefusal> {
66    find_face(solid, neighbour_id).ok_or_else(|| {
67        KernelRefusal::internal(
68            KernelStage::Collect,
69            "missing_neighbour",
70            format!("delete_face_and_heal: missing neighbour {neighbour_id}"),
71        )
72    })
73}
74
75// `retrim_planar_face` moved to the shared re-trim home
76// (`crate::offset_retrim`, offset-unification audit §10) — its body is
77// unchanged and it is re-exported here so its nine direct-edit call sites keep
78// the bare name they have always used.
79pub(super) use crate::offset_retrim::retrim_planar_face;
80
81/// Find, in a face's loops, the (loop index, coedge index) of the coedge that
82/// references `edge_id`.
83pub(super) fn locate_coedge(face: &FaceRecord, edge_id: u64) -> Option<(usize, usize)> {
84    for (loop_index, loop_record) in face.loops.iter().enumerate() {
85        for (coedge_index, coedge) in loop_record.coedges.iter().enumerate() {
86            if coedge.edge_id == edge_id {
87                return Some((loop_index, coedge_index));
88            }
89        }
90    }
91    None
92}
93
94pub(super) fn coedge_from_vertex(coedge: &CoedgeRecord, edges: &HashMap<u64, EdgeRecord>) -> Option<u64> {
95    let edge = edges.get(&coedge.edge_id)?;
96    Some(if coedge.forward {
97        edge.start_vertex_id
98    } else {
99        edge.end_vertex_id
100    })
101}
102
103pub(super) fn coedge_to_vertex(coedge: &CoedgeRecord, edges: &HashMap<u64, EdgeRecord>) -> Option<u64> {
104    let edge = edges.get(&coedge.edge_id)?;
105    Some(if coedge.forward {
106        edge.end_vertex_id
107    } else {
108        edge.start_vertex_id
109    })
110}
111
112/// The transition corners that cannot move to their recovered corner, by
113/// lateral index: a mitred chamfer's outer corner carries an edge whose far face
114/// is no neighbour of the strip and does not reach the recovered corner, so the
115/// corner stays and the mitre becomes the edge along lateral x wall (four
116/// chamfers around a block's top). At most one per lateral: with both ends
117/// anchored there is no corner left to recover, and the gate refuses it.
118fn anchored_corners(
119    solid: &BrepSolid,
120    boundary_edges: &HashSet<u64>,
121    corners: &[u64],
122    neighbours: &[u64; 4],
123    plan: &HealPlan,
124    plane_tolerance: f64,
125) -> HashMap<usize, u64> {
126    let held_at = |corner: u64, target: Vec3| -> bool {
127        solid.edges.iter().filter(|edge| {
128            !boundary_edges.contains(&edge.id)
129                && (edge.start_vertex_id == corner || edge.end_vertex_id == corner)
130        }).any(|edge| {
131            solid.shells.iter().flat_map(|shell| &shell.faces).filter(|face| {
132                !neighbours.contains(&face.id)
133                    && face.loops.iter().flat_map(|l| &l.coedges).any(|coedge| coedge.edge_id == edge.id)
134            }).any(|face| {
135                plane_of_surface(&face.surface, plane_tolerance, "delete_face_and_heal").is_ok_and(|plane| {
136                    target.sub(plane.origin).dot(plane.normal).abs() > plane_tolerance
137                })
138            })
139        })
140    };
141    let mut anchored = HashMap::default();
142    for &lateral in &plan.lateral {
143        let target = plan.lateral_point[&lateral];
144        let ends = [corners[lateral], corners[(lateral + 1) % 4]];
145        let held: Vec<u64> = ends.into_iter().filter(|corner| held_at(*corner, target)).collect();
146        if let [corner] = held[..] {
147            let at_target = solid.vertices.iter().any(|vertex| vertex.id == corner && vertex.point.sub(target).length() <= plane_tolerance);
148            if !at_target {
149                anchored.insert(lateral, corner);
150            }
151        }
152    }
153    anchored
154}
155
156/// Golovanov §6.12 — delete a transition face and heal the hole by extending
157/// and re-intersecting its immediate neighbours. See the module docs for the
158/// covered vs deferred cases. Returns a fresh solid that is guaranteed to
159/// `validate()` AND to be sound, or a clear `Err` describing why the heal was
160/// refused.
161///
162/// `validate()` is an incidence test and cannot see a body passing through
163/// itself, which a re-intersected rim is exactly the kind of thing to produce:
164/// a fold or a crossing changes the AREA where it does not change the volume,
165/// so no closed-form oracle downstream notices. [`crate::accept_sound`] is
166/// that floor, and it runs on the way out of the public entry only — the
167/// set driver's chain calls `delete_face_and_heal_impl` so a ten-face
168/// selection pays for one scan and not ten.
169pub fn delete_face_and_heal(solid: &BrepSolid, face_id: u64) -> Result<BrepSolid, KernelRefusal> {
170    crate::accept_sound(delete_face_and_heal_impl(solid, face_id)?, "deleteFace")
171}
172
173/// The heal itself, without the soundness floor: see [`delete_face_and_heal`].
174pub(super) fn delete_face_and_heal_impl(
175    solid: &BrepSolid,
176    face_id: u64,
177) -> Result<BrepSolid, KernelRefusal> {
178    let mut solid = solid.clone();
179    let scale = solid_model_scale(&solid);
180    let tolerance = (scale * 1e-7).max(1e-9);
181
182    let (shell_index, face_index) = find_face(&solid, face_id).ok_or_else(|| {
183        KernelRefusal::input(
184            KernelStage::Collect,
185            "face_id",
186            format!("delete_face_and_heal: no face with id {face_id}"),
187        )
188    })?;
189
190    // --- Read the transition face's boundary (immutable snapshot) ---------
191    let boundary: Vec<(u64, bool)> = {
192        let face = &solid.shells[shell_index].faces[face_index];
193        if face.loops.len() != 1 {
194            return Err(KernelRefusal::unsupported(
195                KernelStage::Classify,
196                "multi_loop",
197                format!(
198                    "delete_face_and_heal: face {face_id} has {} loops; only a simple \
199                     single-loop transition face is supported",
200                    face.loops.len()
201                ),
202            ));
203        }
204        face.loops[0]
205            .coedges
206            .iter()
207            .map(|coedge| (coedge.edge_id, coedge.forward))
208            .collect()
209    };
210    // A degenerate pole (an exact octant's, a runout's) is no side: a face
211    // whose other edges do not number four is refused by its real side count.
212    let degenerate = |edge_id: u64| solid.edges.iter().any(|edge| edge.id == edge_id && edge.degenerate);
213    let sides = boundary.iter().filter(|(edge_id, _)| !degenerate(*edge_id)).count();
214    if boundary.len() != 4 || (sides != boundary.len() && sides != 4) {
215        return Err(KernelRefusal::unsupported(
216            KernelStage::Classify,
217            "side_count",
218            format!(
219                "delete_face_and_heal: face {face_id} has {} boundary edges; only 4-sided \
220                 transition faces (a single chamfer/fillet edge) are supported \
221                 (deferred: multi-face gaps)",
222                if sides != boundary.len() { sides } else { boundary.len() }
223            ),
224        ));
225    }
226    let boundary_edge_ids: HashSet<u64> = boundary.iter().map(|(edge_id, _)| *edge_id).collect();
227    if boundary_edge_ids.len() == 3 {
228        // A CLOSED transition strip (a fillet/chamfer around a full rim):
229        // loop = [seam+, rim_a, seam-, rim_b] — the seam doubled, two closed
230        // rims. Heals by re-intersecting the two neighbours analytically.
231        return heal_closed_transition(&solid, shell_index, face_index, &boundary)
232            ;
233    }
234    if boundary_edge_ids.len() != 4 {
235        return Err(KernelRefusal::unsupported(
236            KernelStage::Classify,
237            "repeated_edge",
238            "delete_face_and_heal: transition face uses an edge more than once \
239                    (deferred: periodic/closed transition)",
240        ));
241    }
242
243    // Neighbour faces (one per boundary edge, in loop order).
244    let mut neighbour_ids = [0u64; 4];
245    for (index, (edge_id, _)) in boundary.iter().enumerate() {
246        neighbour_ids[index] =
247            other_face_of_edge(&solid, *edge_id, face_id)?;
248    }
249    let unique: HashSet<u64> = neighbour_ids.iter().copied().collect();
250    if unique.len() != 4 {
251        return Err(KernelRefusal::unsupported(
252            KernelStage::Classify,
253            "repeated_neighbour",
254            "delete_face_and_heal: the transition face touches a neighbour more \
255                    than once (deferred: periodic/closed transition)",
256        ));
257    }
258
259    // Carriers of the neighbours: all-planar takes the closed-form planar
260    // path below; any curved analytic neighbour routes to the mixed
261    // open-chain heal (plane × cylinder/ruled-revolution re-intersection).
262    let neighbour_planes: [Plane; 4] = {
263        let mut planes: Vec<Option<Plane>> = Vec::with_capacity(4);
264        for &neighbour_id in &neighbour_ids {
265            let (ns, nf) = neighbour_position(&solid, neighbour_id)?;
266            planes.push(
267                plane_of_surface(
268                    &solid.shells[ns].faces[nf].surface,
269                    (scale * 1e-6).max(1e-7),
270                    "delete_face_and_heal",
271                )
272                .ok(),
273            );
274        }
275        if planes.iter().any(Option::is_none) {
276            return heal_open_transition_mixed(
277                &solid,
278                shell_index,
279                face_index,
280                &boundary,
281                &neighbour_ids,
282            )
283            ;
284        }
285        [
286            planes[0].unwrap(),
287            planes[1].unwrap(),
288            planes[2].unwrap(),
289            planes[3].unwrap(),
290        ]
291    };
292
293    // Transition-face region gate (centre + reach), from its loop vertices.
294    let transition_vertices: Vec<u64> = boundary
295        .iter()
296        .map(|(edge_id, forward)| {
297            let edge = solid
298                .edges
299                .iter()
300                .find(|edge| edge.id == *edge_id)
301                .ok_or_else(|| {
302                    KernelRefusal::internal(
303                        KernelStage::Collect,
304                        "missing_edge",
305                        format!("delete_face_and_heal: missing edge {edge_id}"),
306                    )
307                })?;
308            Ok(if *forward {
309                edge.start_vertex_id
310            } else {
311                edge.end_vertex_id
312            })
313        })
314        .collect::<Result<Vec<u64>, KernelRefusal>>()?;
315    if transition_vertices.iter().collect::<HashSet<_>>().len() != 4 {
316        return Err(KernelRefusal::unsupported(
317            KernelStage::Classify,
318            "repeated_corner",
319            "delete_face_and_heal: transition face has repeated corner vertices \
320                    (deferred: degenerate transition)",
321        ));
322    }
323    let mut f_center = Vec3::default();
324    for &vertex_id in &transition_vertices {
325        f_center = f_center.add(edge_point(&solid, vertex_id)?);
326    }
327    f_center = f_center.scale(0.25);
328    let mut f_reach = 0.0f64;
329    for &vertex_id in &transition_vertices {
330        f_reach = f_reach.max(
331            edge_point(&solid, vertex_id)
332                ?
333                .sub(f_center)
334                .length(),
335        );
336    }
337    let f_reach = f_reach * 3.0 + tolerance;
338
339    if let Some(directory) = census_directory() {
340        record_planar_open_heal(&directory, &solid, face_id);
341    }
342    let plan = plan_heal(&neighbour_planes, f_center, f_reach)?;
343
344    // --- New recovered corners (triple points) and the new sharp edge ------
345    let mut next_id = max_topology_id(&solid) + 1;
346    let mut alloc = || {
347        let value = next_id;
348        next_id += 1;
349        value
350    };
351
352    let lateral_a = plan.lateral[0];
353    let lateral_b = plan.lateral[1];
354    let point_a = plan.lateral_point[&lateral_a];
355    let point_b = plan.lateral_point[&lateral_b];
356    if point_a.sub(point_b).length() <= tolerance {
357        return Err(KernelRefusal::unsupported(
358            KernelStage::Classify,
359            "coincident_corners",
360            "delete_face_and_heal: recovered corners coincide — the neighbours \
361                    do not bound a clean edge",
362        ));
363    }
364    let vertex_a = alloc();
365    let vertex_b = alloc();
366    let mut lateral_vertex: HashMap<usize, u64> = HashMap::default();
367    lateral_vertex.insert(lateral_a, vertex_a);
368    lateral_vertex.insert(lateral_b, vertex_b);
369
370    // The new sharp edge S (start = corner A, end = corner B).
371    let sharp_edge_id = alloc();
372    let sharp_edge = EdgeRecord {
373        id: sharp_edge_id,
374        curve: make_line(point_a, point_b).or_refuse(KernelStage::Fragment, "make_line")?,
375        t0: 0.0,
376        t1: 1.0,
377        start_vertex_id: vertex_a,
378        end_vertex_id: vertex_b,
379        degenerate: false,
380        name: None,
381    };
382
383    // --- Collapse each transition corner onto its recovered corner ---------
384    // Corner vertex `transition_vertices[i]` sits between neighbour[(i+3)%4]
385    // and neighbour[i]; exactly one of those two is a lateral face, and the
386    // corner collapses onto that lateral's recovered corner.
387    let lateral_set: HashSet<usize> = plan.lateral.iter().copied().collect();
388    let plane_tolerance = (scale * 1e-6).max(1e-7);
389    let anchored_corners =
390        anchored_corners(&solid, &boundary_edge_ids, &transition_vertices, &neighbour_ids, &plan, plane_tolerance);
391    let mut collapse: HashMap<u64, u64> = HashMap::default();
392    for index in 0..4usize {
393        let previous = (index + 3) % 4;
394        let lateral_index = if lateral_set.contains(&previous) {
395            previous
396        } else if lateral_set.contains(&index) {
397            index
398        } else {
399            return Err(KernelRefusal::internal(
400                KernelStage::Classify,
401                "corner_flank",
402                "delete_face_and_heal: transition corner is not flanked by a \
403                        lateral face (unexpected neighbour ordering)",
404            ));
405        };
406        if anchored_corners.get(&lateral_index) == Some(&transition_vertices[index]) {
407            continue;
408        }
409        let target = lateral_vertex[&lateral_index];
410        collapse.insert(transition_vertices[index], target);
411    }
412    // Each anchored corner reaches its recovered corner along lateral x wall.
413    let mut bridges: HashMap<u64, (u64, u64)> = HashMap::default();
414    let mut bridge_of_lateral: HashMap<usize, (u64, u64)> = HashMap::default();
415    let mut bridge_edges: Vec<EdgeRecord> = Vec::new();
416    for (lateral_index, corner) in &anchored_corners {
417        let target = lateral_vertex[lateral_index];
418        let edge_id = alloc();
419        let from = edge_point(&solid, *corner)?;
420        let to = plan.lateral_point[lateral_index];
421        bridge_edges.push(EdgeRecord {
422            id: edge_id,
423            curve: make_line(from, to).or_refuse(KernelStage::Fragment, "make_line")?,
424            t0: 0.0,
425            t1: 1.0,
426            start_vertex_id: *corner,
427            end_vertex_id: target,
428            degenerate: false,
429            name: None,
430        });
431        bridges.insert(*corner, (edge_id, target));
432        bridge_of_lateral.insert(*lateral_index, (edge_id, *corner));
433    }
434    let new_vertex_points: HashMap<u64, Vec3> = [(vertex_a, point_a), (vertex_b, point_b)]
435        .into_iter()
436        .collect();
437
438    // Relocate every non-transition edge that ends on a collapsed corner.
439    let unmoved = solid.edges.clone();
440    let mut moved: HashSet<u64> = HashSet::default();
441    for edge in &mut solid.edges {
442        if boundary_edge_ids.contains(&edge.id) {
443            continue;
444        }
445        let start_target = collapse.get(&edge.start_vertex_id).copied();
446        let end_target = collapse.get(&edge.end_vertex_id).copied();
447        if start_target.is_none() && end_target.is_none() {
448            continue;
449        }
450        moved.insert(edge.id);
451        if edge.curve.straight_segment(tolerance).is_none() {
452            return Err(KernelRefusal::unsupported(
453                KernelStage::Classify,
454                "curved_side_edge",
455                "delete_face_and_heal: a side edge meeting the transition face is \
456                        not a straight line (deferred: curved neighbour edges)",
457            ));
458        }
459        // The edge's OWN ends, at `t0`/`t1`. `straight_segment` answers "is this
460        // a line" with the first and last control points — the WHOLE curve's ends
461        // — and a side edge is often a subrange of its line: a cap's top edge
462        // already trimmed back by another blend on its far end. Taking the curve's
463        // ends there moved that far end back out to the old sharp corner, so
464        // deleting one of two chamfers rebuilt the cap edge right through the
465        // other one.
466        let mut start_point = edge
467            .curve
468            .evaluate(edge.t0)
469            .or_refuse(KernelStage::Fragment, "evaluate")?;
470        let mut end_point = edge
471            .curve
472            .evaluate(edge.t1)
473            .or_refuse(KernelStage::Fragment, "evaluate")?;
474        if let Some(target) = start_target {
475            edge.start_vertex_id = target;
476            start_point = new_vertex_points[&target];
477        }
478        if let Some(target) = end_target {
479            edge.end_vertex_id = target;
480            end_point = new_vertex_points[&target];
481        }
482        if start_point.sub(end_point).length() <= tolerance {
483            return Err(KernelRefusal::unsupported(
484                KernelStage::Fragment,
485                "collapsed_side_edge",
486                "delete_face_and_heal: healing would collapse a side edge to zero \
487                        length (deferred: degenerate transition)",
488            ));
489        }
490        edge.curve = make_line(start_point, end_point).or_refuse(KernelStage::Fragment, "make_line")?;
491        edge.t0 = 0.0;
492        edge.t1 = 1.0;
493    }
494    // One plane per neighbour, read by the gate and by the re-trim below alike.
495    let retrimmed: HashMap<u64, Plane> =
496        neighbour_ids.iter().copied().zip(neighbour_planes.iter().copied()).collect();
497    moved_edges_lie_on_their_faces(
498        &unmoved,
499        &solid,
500        &moved,
501        &retrimmed,
502        (scale * 1e-6).max(1e-7),
503        "delete_face_and_heal",
504    )
505    ?;
506
507    // Index the (now relocated) edges for loop rewrites and pcurve rebuilds.
508    let mut edges_by_id: HashMap<u64, EdgeRecord> = solid
509        .edges
510        .iter()
511        .map(|edge| (edge.id, edge.clone()))
512        .collect();
513    edges_by_id.insert(sharp_edge_id, sharp_edge.clone());
514    let old_boundary: HashMap<u64, (u64, u64)> = unmoved
515        .iter()
516        .filter(|edge| boundary_edge_ids.contains(&edge.id))
517        .map(|edge| (edge.id, (edge.start_vertex_id, edge.end_vertex_id)))
518        .collect();
519
520    // --- Rewrite neighbour loops ------------------------------------------
521    // Primary faces: replace their support coedge (on the deleted face's edge)
522    // with a coedge on the new sharp edge S.
523    for &primary_index in &plan.primary {
524        let primary_edge = boundary[primary_index].0;
525        let neighbour_id = neighbour_ids[primary_index];
526        let (ns, nf) = neighbour_position(&solid, neighbour_id)?;
527        let face = &mut solid.shells[ns].faces[nf];
528        let (loop_index, coedge_index) = locate_coedge(face, primary_edge).ok_or_else(|| {
529            KernelRefusal::internal(
530                KernelStage::Sew,
531                "primary_coedge",
532                format!(
533                    "delete_face_and_heal: neighbour {neighbour_id} does not use edge {primary_edge}"
534                ),
535            )
536        })?;
537        let coedges = &face.loops[loop_index].coedges;
538        let count = coedges.len();
539        let previous = &coedges[(coedge_index + count - 1) % count];
540        let next = &coedges[(coedge_index + 1) % count];
541        let required_from = coedge_to_vertex(previous, &edges_by_id).ok_or_else(|| {
542            KernelRefusal::internal(
543                KernelStage::Sew,
544                "loop_connectivity_from",
545                "delete_face_and_heal: could not resolve loop connectivity",
546            )
547        })?;
548        let required_to = coedge_from_vertex(next, &edges_by_id).ok_or_else(|| {
549            KernelRefusal::internal(
550                KernelStage::Sew,
551                "loop_connectivity_to",
552                "delete_face_and_heal: could not resolve loop connectivity",
553            )
554        })?;
555        // A loop arriving at an anchored corner reaches the sharp edge over
556        // that corner's bridge.
557        let bridge_in = bridges.get(&required_from).copied();
558        let bridge_out = bridges.get(&required_to).copied();
559        let sharp_from = bridge_in.map_or(required_from, |(_, target)| target);
560        let sharp_to = bridge_out.map_or(required_to, |(_, target)| target);
561        let forward = if sharp_from == vertex_a && sharp_to == vertex_b {
562            true
563        } else if sharp_from == vertex_b && sharp_to == vertex_a {
564            false
565        } else {
566            return Err(KernelRefusal::internal(
567                KernelStage::Sew,
568                "primary_loop_closure",
569                "delete_face_and_heal: new edge does not close the primary loop \
570                        (unexpected connectivity)",
571            ));
572        };
573        let new_coedge = CoedgeRecord {
574            id: alloc(),
575            edge_id: sharp_edge_id,
576            forward,
577            // Placeholder; retrim_planar_face recomputes every pcurve below.
578            pcurve: make_line(Vec3::default(), Vec3::new(1.0, 0.0, 0.0))
579                .or_refuse(KernelStage::Sew, "make_line")?,
580        };
581        // Placeholders likewise: the re-trim below recomputes every pcurve.
582        let placeholder = new_coedge.pcurve.clone();
583        let mut spliced = Vec::with_capacity(3);
584        if let Some((edge_id, _)) = bridge_in {
585            spliced.push(CoedgeRecord { id: alloc(), edge_id, forward: true, pcurve: placeholder.clone() });
586        }
587        spliced.push(new_coedge);
588        if let Some((edge_id, _)) = bridge_out {
589            spliced.push(CoedgeRecord { id: alloc(), edge_id, forward: false, pcurve: placeholder.clone() });
590        }
591        face.loops[loop_index].coedges.splice(coedge_index..=coedge_index, spliced);
592    }
593
594    // Lateral (cap) faces: drop the cap coedge; its two neighbours already
595    // meet at the recovered corner.
596    for &lateral_index in &plan.lateral {
597        let lateral_edge = boundary[lateral_index].0;
598        let neighbour_id = neighbour_ids[lateral_index];
599        let (ns, nf) = neighbour_position(&solid, neighbour_id)?;
600        let face = &mut solid.shells[ns].faces[nf];
601        let (loop_index, coedge_index) = locate_coedge(face, lateral_edge).ok_or_else(|| {
602            KernelRefusal::internal(
603                KernelStage::Sew,
604                "lateral_coedge",
605                format!(
606                    "delete_face_and_heal: neighbour {neighbour_id} does not use edge {lateral_edge}"
607                ),
608            )
609        })?;
610        if let Some(&(edge_id, corner)) = bridge_of_lateral.get(&lateral_index) {
611            // An anchored end: the cap keeps a side, now along the wall.
612            let coedge = &mut face.loops[loop_index].coedges[coedge_index];
613            let (start, end) = old_boundary[&lateral_edge];
614            coedge.forward = (if coedge.forward { start } else { end }) == corner;
615            coedge.edge_id = edge_id;
616            coedge.id = alloc();
617            continue;
618        }
619        face.loops[loop_index].coedges.remove(coedge_index);
620        if face.loops[loop_index].coedges.is_empty() {
621            return Err(KernelRefusal::internal(
622                KernelStage::Sew,
623                "emptied_loop",
624                "delete_face_and_heal: healing emptied a lateral face loop",
625            ));
626        }
627    }
628
629    // --- Prune the deleted face, its edges, and its corner vertices --------
630    solid.shells[shell_index]
631        .faces
632        .retain(|face| face.id != face_id);
633    solid
634        .edges
635        .retain(|edge| !boundary_edge_ids.contains(&edge.id));
636    let removed_vertices: HashSet<u64> =
637        transition_vertices.iter().copied().filter(|vertex| !bridges.contains_key(vertex)).collect();
638    solid.edges.push(sharp_edge);
639    solid.edges.extend(bridge_edges);
640    solid
641        .vertices
642        .retain(|vertex| !removed_vertices.contains(&vertex.id));
643    solid.vertices.push(VertexRecord {
644        id: vertex_a,
645        point: point_a,
646    });
647    solid.vertices.push(VertexRecord {
648        id: vertex_b,
649        point: point_b,
650    });
651
652    // --- Re-trim every affected neighbour on its (extended) carrier --------
653    let final_edges: HashMap<u64, EdgeRecord> = solid
654        .edges
655        .iter()
656        .map(|edge| (edge.id, edge.clone()))
657        .collect();
658    for neighbour_id in neighbour_ids {
659        let plane = &retrimmed[&neighbour_id];
660        let (ns, nf) = neighbour_position(&solid, neighbour_id)?;
661        retrim_planar_face(
662            &mut solid.shells[ns].faces[nf],
663            plane,
664            &final_edges,
665            scale,
666            "delete_face_and_heal",
667        )
668        ?;
669    }
670
671    // Genus is preserved by removing a genus-neutral transition face; the
672    // Euler check inside validate() confirms it.
673    let issues = solid.validate();
674    if !issues.is_empty() {
675        return Err(KernelRefusal::internal(
676            KernelStage::Validate,
677            "validate",
678            format!("delete_face_and_heal: healed solid failed validation: {issues:?}"),
679        ));
680    }
681    Ok(solid)
682}
683
684/// Resolve the id of the face nearest a 3D point (used by the app: the picked
685/// point lies on the selected face). Mirrors `resolve_edge_by_point`.
686pub fn resolve_face_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, KernelRefusal> {
687    let scale = solid_model_scale(&solid);
688    let mut best: Option<(u64, f64)> = None;
689    for shell in &solid.shells {
690        for face in &shell.faces {
691            let Ok(projection) = crate::project_point_to_surface(&face.surface, point) else {
692                continue;
693            };
694            if best
695                .map(|(_, known)| projection.distance < known)
696                .unwrap_or(true)
697            {
698                best = Some((face.id, projection.distance));
699            }
700        }
701    }
702    match best {
703        Some((face_id, distance)) if distance <= (scale * 1e-3).max(1e-4) => Ok(face_id),
704        Some((_, distance)) => Err(KernelRefusal::input(
705            KernelStage::Collect,
706            "point_off_faces",
707            format!(
708                "delete_face_and_heal: no face within tolerance of the point (nearest {distance:.6})"
709            ),
710        )),
711        None => Err(KernelRefusal::input(
712            KernelStage::Collect,
713            "no_faces",
714            "delete_face_and_heal: solid has no faces",
715        )),
716    }
717}
718