Skip to main content

brepkit_operations/
query.rs

1//! Shape query utilities.
2
3use std::collections::{HashMap, HashSet};
4
5use brepkit_topology::Topology;
6use brepkit_topology::edge::EdgeId;
7use brepkit_topology::face::{FaceId, FaceSurface};
8use brepkit_topology::solid::SolidId;
9
10use crate::OperationsError;
11
12/// Filter edges to only those shared by two planar faces in a solid.
13///
14/// Given a solid and a set of edge IDs, returns only the edges
15/// where both adjacent faces have a planar surface.
16///
17/// # Errors
18///
19/// Returns `OperationsError::Topology` if any entity ID is invalid.
20pub fn filter_planar_edges(
21    topo: &Topology,
22    solid_id: SolidId,
23    edge_ids: &[EdgeId],
24) -> Result<Vec<EdgeId>, OperationsError> {
25    let solid_data = topo.solid(solid_id)?;
26    let shell = topo.shell(solid_data.outer_shell())?;
27
28    let mut edge_faces: HashMap<usize, Vec<FaceId>> = HashMap::new();
29    for &fid in shell.faces() {
30        let face = topo.face(fid)?;
31        let wire = topo.wire(face.outer_wire())?;
32        for oe in wire.edges() {
33            edge_faces.entry(oe.edge().index()).or_default().push(fid);
34        }
35    }
36
37    let mut result = Vec::new();
38    for &eid in edge_ids {
39        if let Some(adj_faces) = edge_faces.get(&eid.index()) {
40            let all_planar = adj_faces.iter().all(|&fid| {
41                topo.face(fid)
42                    .map(|f| matches!(f.surface(), FaceSurface::Plane { .. }))
43                    .unwrap_or(false)
44            });
45            if all_planar {
46                result.push(eid);
47            }
48        }
49    }
50    Ok(result)
51}
52
53/// Filter edges to only those the blend engine can fillet: manifold edges
54/// (shared by exactly two distinct faces) that meet at a real (non-tangent)
55/// angle.
56///
57/// Edges bordering a curved neighbour — including a previous fillet's NURBS
58/// blend face — ARE filletable: the rolling-ball engine solves the true
59/// ball-tangent contacts against any surface. The cases that genuinely have no
60/// fillet are **tangent / G1** edges (the two faces meet smoothly, e.g. a
61/// fillet face's contact line with its planar neighbour) and degenerate folds;
62/// those are excluded here so callers never feed them to the engine.
63///
64/// `try_fillet` additionally guards each result with a manifold check, so a
65/// permissive filter here cannot let a malformed solid through.
66///
67/// # Errors
68///
69/// Returns `OperationsError::Topology` if any entity ID is invalid.
70pub fn filter_filletable_edges(
71    topo: &Topology,
72    solid_id: SolidId,
73    edge_ids: &[EdgeId],
74) -> Result<Vec<EdgeId>, OperationsError> {
75    let solid_data = topo.solid(solid_id)?;
76    let shell = topo.shell(solid_data.outer_shell())?;
77
78    // Map each edge to its set of *distinct* adjacent faces, walking both outer
79    // and inner (hole-boundary) wires — the same adjacency the fillet engine
80    // sees. The set dedups a seam edge that a single face's wire lists twice.
81    let mut edge_faces: HashMap<usize, HashSet<FaceId>> = HashMap::new();
82    for &fid in shell.faces() {
83        let face = topo.face(fid)?;
84        let mut wires = vec![face.outer_wire()];
85        wires.extend(face.inner_wires().iter().copied());
86        for wid in wires {
87            for oe in topo.wire(wid)?.edges() {
88                edge_faces.entry(oe.edge().index()).or_default().insert(fid);
89            }
90        }
91    }
92
93    let mut result = Vec::new();
94    for &eid in edge_ids {
95        let Some(adj_faces) = edge_faces.get(&eid.index()) else {
96            continue;
97        };
98        if adj_faces.len() != 2 {
99            continue;
100        }
101        if edge_is_tangent(topo, eid, adj_faces)? {
102            continue;
103        }
104        result.push(eid);
105    }
106    Ok(result)
107}
108
109/// Whether the two faces of `eid` meet tangentially (G1) — their effective
110/// outward normals are (anti)parallel at the edge midpoint, so there is no
111/// real dihedral to round. Returns `true` for the degenerate cases the fillet
112/// engine cannot blend.
113fn edge_is_tangent(
114    topo: &Topology,
115    eid: EdgeId,
116    faces: &HashSet<FaceId>,
117) -> Result<bool, OperationsError> {
118    let mut it = faces.iter().copied();
119    let (Some(f1), Some(f2)) = (it.next(), it.next()) else {
120        return Ok(true);
121    };
122    let edge = topo.edge(eid)?;
123    let a = topo.vertex(edge.start())?.point();
124    let b = topo.vertex(edge.end())?.point();
125    let mid = a + (b - a) * 0.5;
126
127    let normal = |fid: FaceId| -> Option<brepkit_math::vec::Vec3> {
128        let face = topo.face(fid).ok()?;
129        let n = match face.surface() {
130            FaceSurface::Plane { normal, .. } => *normal,
131            other => {
132                let (u, v) = other.project_point(mid)?;
133                other.normal(u, v)
134            }
135        };
136        let n = if face.is_reversed() { -n } else { n };
137        n.normalize().ok()
138    };
139
140    match (normal(f1), normal(f2)) {
141        (Some(n1), Some(n2)) => {
142            let cos = n1.dot(n2).clamp(-1.0, 1.0);
143            // Tangent within ~5°: |angle| < 5° (cos > 0.9962) or > 175°.
144            Ok(cos.abs() > 0.9962)
145        }
146        // Can't determine a normal — don't exclude; the engine + manifold guard
147        // will decide.
148        _ => Ok(false),
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    #![allow(clippy::unwrap_used, clippy::expect_used, deprecated)]
155
156    use brepkit_topology::explorer::solid_edges;
157
158    use super::*;
159
160    #[test]
161    fn filletable_edges_all_planar_box() {
162        let mut topo = Topology::new();
163        let cube = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
164        let edges = solid_edges(&topo, cube).unwrap();
165        let filletable = filter_filletable_edges(&topo, cube, &edges).unwrap();
166        assert_eq!(
167            filletable.len(),
168            edges.len(),
169            "every box edge is plane↔plane and filletable"
170        );
171        assert_eq!(edges.len(), 12);
172    }
173
174    #[test]
175    fn filletable_edges_keep_nontangent_blend_edges_drop_tangent() {
176        // A single rolling-ball fillet makes a watertight solid with a NURBS
177        // blend face. Its NURBS-blend-border edges split into tangent/G1 contact
178        // lines (degenerate → excluded) and real-angle end-caps (→ kept).
179        let mut topo = Topology::new();
180        let cube = crate::primitives::make_box(&mut topo, 10.0, 10.0, 10.0).unwrap();
181        let edges = solid_edges(&topo, cube).unwrap();
182        let filleted =
183            crate::fillet::fillet_rolling_ball(&mut topo, cube, &[edges[0]], 1.0).unwrap();
184        let r_edges = solid_edges(&topo, filleted).unwrap();
185        let filletable: HashSet<usize> = filter_filletable_edges(&topo, filleted, &r_edges)
186            .unwrap()
187            .iter()
188            .map(|e| e.index())
189            .collect();
190
191        let sh = topo
192            .shell(topo.solid(filleted).unwrap().outer_shell())
193            .unwrap();
194        // The blend face, whatever surface type it carries. A straight box
195        // edge blends to an exact cylinder; only curved neighbours give NURBS.
196        let blend_faces: HashSet<usize> = sh
197            .faces()
198            .iter()
199            .filter(|&&f| !topo.face(f).unwrap().surface().is_planar())
200            .map(|f| f.index())
201            .collect();
202        assert!(
203            !blend_faces.is_empty(),
204            "first fillet must create a blend face"
205        );
206
207        let mut ef: HashMap<usize, HashSet<FaceId>> = HashMap::new();
208        for &fid in sh.faces() {
209            for oe in topo
210                .wire(topo.face(fid).unwrap().outer_wire())
211                .unwrap()
212                .edges()
213            {
214                ef.entry(oe.edge().index()).or_default().insert(fid);
215            }
216        }
217
218        let (mut saw_kept, mut saw_dropped_tangent) = (false, false);
219        for &e in &r_edges {
220            let Some(fs) = ef.get(&e.index()) else {
221                continue;
222            };
223            if fs.len() != 2 || !fs.iter().any(|f| blend_faces.contains(&f.index())) {
224                continue;
225            }
226            if edge_is_tangent(&topo, e, fs).unwrap() {
227                assert!(
228                    !filletable.contains(&e.index()),
229                    "tangent blend-contact edge {} must be excluded",
230                    e.index()
231                );
232                saw_dropped_tangent = true;
233            } else {
234                assert!(
235                    filletable.contains(&e.index()),
236                    "non-tangent blend-adjacent edge {} must stay filletable",
237                    e.index()
238                );
239                saw_kept = true;
240            }
241        }
242        assert!(saw_kept, "expected a kept non-tangent NURBS-blend edge");
243        assert!(
244            saw_dropped_tangent,
245            "expected an excluded tangent contact edge"
246        );
247    }
248}