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