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/// Golovanov §6.12 — delete a transition face and heal the hole by extending
113/// and re-intersecting its immediate neighbours. See the module docs for the
114/// covered vs deferred cases. Returns a fresh solid that is guaranteed to
115/// `validate()` AND to be sound, or a clear `Err` describing why the heal was
116/// refused.
117///
118/// `validate()` is an incidence test and cannot see a body passing through
119/// itself, which a re-intersected rim is exactly the kind of thing to produce:
120/// a fold or a crossing changes the AREA where it does not change the volume,
121/// so no closed-form oracle downstream notices. [`crate::accept_sound`] is
122/// that floor, and it runs on the way out of the public entry only — the
123/// set driver's chain calls `delete_face_and_heal_impl` so a ten-face
124/// selection pays for one scan and not ten.
125pub fn delete_face_and_heal(solid: &BrepSolid, face_id: u64) -> Result<BrepSolid, KernelRefusal> {
126    crate::accept_sound(delete_face_and_heal_impl(solid, face_id)?, "deleteFace")
127}
128
129/// The heal itself, without the soundness floor: see [`delete_face_and_heal`].
130pub(super) fn delete_face_and_heal_impl(
131    solid: &BrepSolid,
132    face_id: u64,
133) -> Result<BrepSolid, KernelRefusal> {
134    let mut solid = solid.clone();
135    let scale = solid_model_scale(&solid);
136    let tolerance = (scale * 1e-7).max(1e-9);
137
138    let (shell_index, face_index) = find_face(&solid, face_id).ok_or_else(|| {
139        KernelRefusal::input(
140            KernelStage::Collect,
141            "face_id",
142            format!("delete_face_and_heal: no face with id {face_id}"),
143        )
144    })?;
145
146    // --- Read the transition face's boundary (immutable snapshot) ---------
147    let boundary: Vec<(u64, bool)> = {
148        let face = &solid.shells[shell_index].faces[face_index];
149        if face.loops.len() != 1 {
150            return Err(KernelRefusal::unsupported(
151                KernelStage::Classify,
152                "multi_loop",
153                format!(
154                    "delete_face_and_heal: face {face_id} has {} loops; only a simple \
155                     single-loop transition face is supported",
156                    face.loops.len()
157                ),
158            ));
159        }
160        face.loops[0]
161            .coedges
162            .iter()
163            .map(|coedge| (coedge.edge_id, coedge.forward))
164            .collect()
165    };
166    // A degenerate pole (an exact octant's, a runout's) is no side: a face
167    // whose other edges do not number four is refused by its real side count.
168    let degenerate = |edge_id: u64| solid.edges.iter().any(|edge| edge.id == edge_id && edge.degenerate);
169    let sides = boundary.iter().filter(|(edge_id, _)| !degenerate(*edge_id)).count();
170    if boundary.len() != 4 || (sides != boundary.len() && sides != 4) {
171        return Err(KernelRefusal::unsupported(
172            KernelStage::Classify,
173            "side_count",
174            format!(
175                "delete_face_and_heal: face {face_id} has {} boundary edges; only 4-sided \
176                 transition faces (a single chamfer/fillet edge) are supported \
177                 (deferred: multi-face gaps)",
178                if sides != boundary.len() { sides } else { boundary.len() }
179            ),
180        ));
181    }
182    let boundary_edge_ids: HashSet<u64> = boundary.iter().map(|(edge_id, _)| *edge_id).collect();
183    if boundary_edge_ids.len() == 3 {
184        // A CLOSED transition strip (a fillet/chamfer around a full rim):
185        // loop = [seam+, rim_a, seam-, rim_b] — the seam doubled, two closed
186        // rims. Heals by re-intersecting the two neighbours analytically.
187        return heal_closed_transition(&solid, shell_index, face_index, &boundary)
188            ;
189    }
190    if boundary_edge_ids.len() != 4 {
191        return Err(KernelRefusal::unsupported(
192            KernelStage::Classify,
193            "repeated_edge",
194            "delete_face_and_heal: transition face uses an edge more than once \
195                    (deferred: periodic/closed transition)",
196        ));
197    }
198
199    // Neighbour faces (one per boundary edge, in loop order).
200    let mut neighbour_ids = [0u64; 4];
201    for (index, (edge_id, _)) in boundary.iter().enumerate() {
202        neighbour_ids[index] =
203            other_face_of_edge(&solid, *edge_id, face_id)?;
204    }
205    let unique: HashSet<u64> = neighbour_ids.iter().copied().collect();
206    if unique.len() != 4 {
207        return Err(KernelRefusal::unsupported(
208            KernelStage::Classify,
209            "repeated_neighbour",
210            "delete_face_and_heal: the transition face touches a neighbour more \
211                    than once (deferred: periodic/closed transition)",
212        ));
213    }
214
215    // Carriers of the neighbours: all-planar takes the closed-form planar
216    // path below; any curved analytic neighbour routes to the mixed
217    // open-chain heal (plane × cylinder/ruled-revolution re-intersection).
218    let neighbour_planes: [Plane; 4] = {
219        let mut planes: Vec<Option<Plane>> = Vec::with_capacity(4);
220        for &neighbour_id in &neighbour_ids {
221            let (ns, nf) = neighbour_position(&solid, neighbour_id)?;
222            planes.push(
223                plane_of_surface(
224                    &solid.shells[ns].faces[nf].surface,
225                    (scale * 1e-6).max(1e-7),
226                    "delete_face_and_heal",
227                )
228                .ok(),
229            );
230        }
231        if planes.iter().any(Option::is_none) {
232            return heal_open_transition_mixed(
233                &solid,
234                shell_index,
235                face_index,
236                &boundary,
237                &neighbour_ids,
238            )
239            ;
240        }
241        [
242            planes[0].unwrap(),
243            planes[1].unwrap(),
244            planes[2].unwrap(),
245            planes[3].unwrap(),
246        ]
247    };
248
249    // Transition-face region gate (centre + reach), from its loop vertices.
250    let transition_vertices: Vec<u64> = boundary
251        .iter()
252        .map(|(edge_id, forward)| {
253            let edge = solid
254                .edges
255                .iter()
256                .find(|edge| edge.id == *edge_id)
257                .ok_or_else(|| {
258                    KernelRefusal::internal(
259                        KernelStage::Collect,
260                        "missing_edge",
261                        format!("delete_face_and_heal: missing edge {edge_id}"),
262                    )
263                })?;
264            Ok(if *forward {
265                edge.start_vertex_id
266            } else {
267                edge.end_vertex_id
268            })
269        })
270        .collect::<Result<Vec<u64>, KernelRefusal>>()?;
271    if transition_vertices.iter().collect::<HashSet<_>>().len() != 4 {
272        return Err(KernelRefusal::unsupported(
273            KernelStage::Classify,
274            "repeated_corner",
275            "delete_face_and_heal: transition face has repeated corner vertices \
276                    (deferred: degenerate transition)",
277        ));
278    }
279    let mut f_center = Vec3::default();
280    for &vertex_id in &transition_vertices {
281        f_center = f_center.add(edge_point(&solid, vertex_id)?);
282    }
283    f_center = f_center.scale(0.25);
284    let mut f_reach = 0.0f64;
285    for &vertex_id in &transition_vertices {
286        f_reach = f_reach.max(
287            edge_point(&solid, vertex_id)
288                ?
289                .sub(f_center)
290                .length(),
291        );
292    }
293    let f_reach = f_reach * 3.0 + tolerance;
294
295    if let Some(directory) = census_directory() {
296        record_planar_open_heal(&directory, &solid, face_id);
297    }
298    let plan = plan_heal(&neighbour_planes, f_center, f_reach)?;
299
300    // --- New recovered corners (triple points) and the new sharp edge ------
301    let mut next_id = max_topology_id(&solid) + 1;
302    let mut alloc = || {
303        let value = next_id;
304        next_id += 1;
305        value
306    };
307
308    let lateral_a = plan.lateral[0];
309    let lateral_b = plan.lateral[1];
310    let point_a = plan.lateral_point[&lateral_a];
311    let point_b = plan.lateral_point[&lateral_b];
312    if point_a.sub(point_b).length() <= tolerance {
313        return Err(KernelRefusal::unsupported(
314            KernelStage::Classify,
315            "coincident_corners",
316            "delete_face_and_heal: recovered corners coincide — the neighbours \
317                    do not bound a clean edge",
318        ));
319    }
320    let vertex_a = alloc();
321    let vertex_b = alloc();
322    let mut lateral_vertex: HashMap<usize, u64> = HashMap::default();
323    lateral_vertex.insert(lateral_a, vertex_a);
324    lateral_vertex.insert(lateral_b, vertex_b);
325
326    // The new sharp edge S (start = corner A, end = corner B).
327    let sharp_edge_id = alloc();
328    let sharp_edge = EdgeRecord {
329        id: sharp_edge_id,
330        curve: make_line(point_a, point_b).or_refuse(KernelStage::Fragment, "make_line")?,
331        t0: 0.0,
332        t1: 1.0,
333        start_vertex_id: vertex_a,
334        end_vertex_id: vertex_b,
335        degenerate: false,
336        name: None,
337    };
338
339    // --- Collapse each transition corner onto its recovered corner ---------
340    // Corner vertex `transition_vertices[i]` sits between neighbour[(i+3)%4]
341    // and neighbour[i]; exactly one of those two is a lateral face, and the
342    // corner collapses onto that lateral's recovered corner.
343    let lateral_set: HashSet<usize> = plan.lateral.iter().copied().collect();
344    let mut collapse: HashMap<u64, u64> = HashMap::default();
345    for index in 0..4usize {
346        let previous = (index + 3) % 4;
347        let lateral_index = if lateral_set.contains(&previous) {
348            previous
349        } else if lateral_set.contains(&index) {
350            index
351        } else {
352            return Err(KernelRefusal::internal(
353                KernelStage::Classify,
354                "corner_flank",
355                "delete_face_and_heal: transition corner is not flanked by a \
356                        lateral face (unexpected neighbour ordering)",
357            ));
358        };
359        let target = lateral_vertex[&lateral_index];
360        collapse.insert(transition_vertices[index], target);
361    }
362    let new_vertex_points: HashMap<u64, Vec3> = [(vertex_a, point_a), (vertex_b, point_b)]
363        .into_iter()
364        .collect();
365
366    // Relocate every non-transition edge that ends on a collapsed corner.
367    let unmoved = solid.edges.clone();
368    let mut moved: HashSet<u64> = HashSet::default();
369    for edge in &mut solid.edges {
370        if boundary_edge_ids.contains(&edge.id) {
371            continue;
372        }
373        let start_target = collapse.get(&edge.start_vertex_id).copied();
374        let end_target = collapse.get(&edge.end_vertex_id).copied();
375        if start_target.is_none() && end_target.is_none() {
376            continue;
377        }
378        moved.insert(edge.id);
379        if edge.curve.straight_segment(tolerance).is_none() {
380            return Err(KernelRefusal::unsupported(
381                KernelStage::Classify,
382                "curved_side_edge",
383                "delete_face_and_heal: a side edge meeting the transition face is \
384                        not a straight line (deferred: curved neighbour edges)",
385            ));
386        }
387        // The edge's OWN ends, at `t0`/`t1`. `straight_segment` answers "is this
388        // a line" with the first and last control points — the WHOLE curve's ends
389        // — and a side edge is often a subrange of its line: a cap's top edge
390        // already trimmed back by another blend on its far end. Taking the curve's
391        // ends there moved that far end back out to the old sharp corner, so
392        // deleting one of two chamfers rebuilt the cap edge right through the
393        // other one.
394        let mut start_point = edge
395            .curve
396            .evaluate(edge.t0)
397            .or_refuse(KernelStage::Fragment, "evaluate")?;
398        let mut end_point = edge
399            .curve
400            .evaluate(edge.t1)
401            .or_refuse(KernelStage::Fragment, "evaluate")?;
402        if let Some(target) = start_target {
403            edge.start_vertex_id = target;
404            start_point = new_vertex_points[&target];
405        }
406        if let Some(target) = end_target {
407            edge.end_vertex_id = target;
408            end_point = new_vertex_points[&target];
409        }
410        if start_point.sub(end_point).length() <= tolerance {
411            return Err(KernelRefusal::unsupported(
412                KernelStage::Fragment,
413                "collapsed_side_edge",
414                "delete_face_and_heal: healing would collapse a side edge to zero \
415                        length (deferred: degenerate transition)",
416            ));
417        }
418        edge.curve = make_line(start_point, end_point).or_refuse(KernelStage::Fragment, "make_line")?;
419        edge.t0 = 0.0;
420        edge.t1 = 1.0;
421    }
422    // One plane per neighbour, read by the gate and by the re-trim below alike.
423    let retrimmed: HashMap<u64, Plane> =
424        neighbour_ids.iter().copied().zip(neighbour_planes.iter().copied()).collect();
425    moved_edges_lie_on_their_faces(
426        &unmoved,
427        &solid,
428        &moved,
429        &retrimmed,
430        (scale * 1e-6).max(1e-7),
431        "delete_face_and_heal",
432    )
433    ?;
434
435    // Index the (now relocated) edges for loop rewrites and pcurve rebuilds.
436    let mut edges_by_id: HashMap<u64, EdgeRecord> = solid
437        .edges
438        .iter()
439        .map(|edge| (edge.id, edge.clone()))
440        .collect();
441    edges_by_id.insert(sharp_edge_id, sharp_edge.clone());
442
443    // --- Rewrite neighbour loops ------------------------------------------
444    // Primary faces: replace their support coedge (on the deleted face's edge)
445    // with a coedge on the new sharp edge S.
446    for &primary_index in &plan.primary {
447        let primary_edge = boundary[primary_index].0;
448        let neighbour_id = neighbour_ids[primary_index];
449        let (ns, nf) = neighbour_position(&solid, neighbour_id)?;
450        let face = &mut solid.shells[ns].faces[nf];
451        let (loop_index, coedge_index) = locate_coedge(face, primary_edge).ok_or_else(|| {
452            KernelRefusal::internal(
453                KernelStage::Sew,
454                "primary_coedge",
455                format!(
456                    "delete_face_and_heal: neighbour {neighbour_id} does not use edge {primary_edge}"
457                ),
458            )
459        })?;
460        let coedges = &face.loops[loop_index].coedges;
461        let count = coedges.len();
462        let previous = &coedges[(coedge_index + count - 1) % count];
463        let next = &coedges[(coedge_index + 1) % count];
464        let required_from = coedge_to_vertex(previous, &edges_by_id).ok_or_else(|| {
465            KernelRefusal::internal(
466                KernelStage::Sew,
467                "loop_connectivity_from",
468                "delete_face_and_heal: could not resolve loop connectivity",
469            )
470        })?;
471        let required_to = coedge_from_vertex(next, &edges_by_id).ok_or_else(|| {
472            KernelRefusal::internal(
473                KernelStage::Sew,
474                "loop_connectivity_to",
475                "delete_face_and_heal: could not resolve loop connectivity",
476            )
477        })?;
478        let forward = if required_from == vertex_a && required_to == vertex_b {
479            true
480        } else if required_from == vertex_b && required_to == vertex_a {
481            false
482        } else {
483            return Err(KernelRefusal::internal(
484                KernelStage::Sew,
485                "primary_loop_closure",
486                "delete_face_and_heal: new edge does not close the primary loop \
487                        (unexpected connectivity)",
488            ));
489        };
490        let new_coedge = CoedgeRecord {
491            id: alloc(),
492            edge_id: sharp_edge_id,
493            forward,
494            // Placeholder; retrim_planar_face recomputes every pcurve below.
495            pcurve: make_line(Vec3::default(), Vec3::new(1.0, 0.0, 0.0))
496                .or_refuse(KernelStage::Sew, "make_line")?,
497        };
498        face.loops[loop_index].coedges[coedge_index] = new_coedge;
499    }
500
501    // Lateral (cap) faces: drop the cap coedge; its two neighbours already
502    // meet at the recovered corner.
503    for &lateral_index in &plan.lateral {
504        let lateral_edge = boundary[lateral_index].0;
505        let neighbour_id = neighbour_ids[lateral_index];
506        let (ns, nf) = neighbour_position(&solid, neighbour_id)?;
507        let face = &mut solid.shells[ns].faces[nf];
508        let (loop_index, coedge_index) = locate_coedge(face, lateral_edge).ok_or_else(|| {
509            KernelRefusal::internal(
510                KernelStage::Sew,
511                "lateral_coedge",
512                format!(
513                    "delete_face_and_heal: neighbour {neighbour_id} does not use edge {lateral_edge}"
514                ),
515            )
516        })?;
517        face.loops[loop_index].coedges.remove(coedge_index);
518        if face.loops[loop_index].coedges.is_empty() {
519            return Err(KernelRefusal::internal(
520                KernelStage::Sew,
521                "emptied_loop",
522                "delete_face_and_heal: healing emptied a lateral face loop",
523            ));
524        }
525    }
526
527    // --- Prune the deleted face, its edges, and its corner vertices --------
528    solid.shells[shell_index]
529        .faces
530        .retain(|face| face.id != face_id);
531    solid
532        .edges
533        .retain(|edge| !boundary_edge_ids.contains(&edge.id));
534    let removed_vertices: HashSet<u64> = transition_vertices.iter().copied().collect();
535    solid.edges.push(sharp_edge);
536    solid
537        .vertices
538        .retain(|vertex| !removed_vertices.contains(&vertex.id));
539    solid.vertices.push(VertexRecord {
540        id: vertex_a,
541        point: point_a,
542    });
543    solid.vertices.push(VertexRecord {
544        id: vertex_b,
545        point: point_b,
546    });
547
548    // --- Re-trim every affected neighbour on its (extended) carrier --------
549    let final_edges: HashMap<u64, EdgeRecord> = solid
550        .edges
551        .iter()
552        .map(|edge| (edge.id, edge.clone()))
553        .collect();
554    for neighbour_id in neighbour_ids {
555        let plane = &retrimmed[&neighbour_id];
556        let (ns, nf) = neighbour_position(&solid, neighbour_id)?;
557        retrim_planar_face(
558            &mut solid.shells[ns].faces[nf],
559            plane,
560            &final_edges,
561            scale,
562            "delete_face_and_heal",
563        )
564        ?;
565    }
566
567    // Genus is preserved by removing a genus-neutral transition face; the
568    // Euler check inside validate() confirms it.
569    let issues = solid.validate();
570    if !issues.is_empty() {
571        return Err(KernelRefusal::internal(
572            KernelStage::Validate,
573            "validate",
574            format!("delete_face_and_heal: healed solid failed validation: {issues:?}"),
575        ));
576    }
577    Ok(solid)
578}
579
580/// Resolve the id of the face nearest a 3D point (used by the app: the picked
581/// point lies on the selected face). Mirrors `resolve_edge_by_point`.
582pub fn resolve_face_by_point(solid: &BrepSolid, point: Vec3) -> Result<u64, KernelRefusal> {
583    let scale = solid_model_scale(&solid);
584    let mut best: Option<(u64, f64)> = None;
585    for shell in &solid.shells {
586        for face in &shell.faces {
587            let Ok(projection) = crate::project_point_to_surface(&face.surface, point) else {
588                continue;
589            };
590            if best
591                .map(|(_, known)| projection.distance < known)
592                .unwrap_or(true)
593            {
594                best = Some((face.id, projection.distance));
595            }
596        }
597    }
598    match best {
599        Some((face_id, distance)) if distance <= (scale * 1e-3).max(1e-4) => Ok(face_id),
600        Some((_, distance)) => Err(KernelRefusal::input(
601            KernelStage::Collect,
602            "point_off_faces",
603            format!(
604                "delete_face_and_heal: no face within tolerance of the point (nearest {distance:.6})"
605            ),
606        )),
607        None => Err(KernelRefusal::input(
608            KernelStage::Collect,
609            "no_faces",
610            "delete_face_and_heal: solid has no faces",
611        )),
612    }
613}
614