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