Skip to main content

brep_kernel/meshing/
spherical_union.rs

1//! Recover analytic intersections lost when a triangle-mesh boolean cuts
2//! spherical facets. Cut vertices lie inside their parent facet's sphere;
3//! fitting those vertices, or treating the cut strip as a new surface, loses
4//! the sphere. Use precise, independently segmented carriers as hypotheses
5//! and prove their union against the entire closed source mesh before CSG.
6use crate::{
7    boolean_operation, make_box_brep, make_sphere_brep, transform_brep, AffineTransform,
8    BooleanOperation, BooleanOptions, BrepSolid, MeshSegmentation, RegionCarrier, SegmentOptions,
9    Vec3,
10};
11
12#[derive(Clone)]
13struct Sphere {
14    center: Vec3,
15    radius: f64,
16    // Measured chordal deflection of intact facets, not the user's fit bar.
17    sag: f64,
18}
19
20struct BoxCarrier {
21    axes: [Vec3; 3],
22    low: [f64; 3],
23    high: [f64; 3],
24}
25
26impl BoxCarrier {
27    fn distance(&self, p: Vec3) -> f64 {
28        (0..3)
29            .map(|i| {
30                let h = p.dot(self.axes[i]);
31                (self.low[i] - h).max(h - self.high[i])
32            })
33            .fold(f64::NEG_INFINITY, f64::max)
34    }
35
36    fn point(&self, coordinates: [f64; 3]) -> Vec3 {
37        (0..3).fold(Vec3::default(), |p, i| {
38            p.add(self.axes[i].scale(coordinates[i]))
39        })
40    }
41
42    fn solid(&self) -> Result<BrepSolid, String> {
43        let local = make_box_brep(
44            Vec3::default(),
45            self.high[0] - self.low[0],
46            self.high[1] - self.low[1],
47            self.high[2] - self.low[2],
48        )?;
49        let origin = self.point(self.low);
50        let [x, y, z] = self.axes;
51        let transform = AffineTransform::new([
52            x.x, y.x, z.x, origin.x, x.y, y.y, z.y, origin.y, x.z, y.z, z.z, origin.z, 0., 0., 0.,
53            1.,
54        ])?;
55        transform_brep(&local, transform, transform.determinant3() < 0.)
56    }
57}
58
59fn box_carrier(seg: &MeshSegmentation, precision: f64) -> Option<Option<BoxCarrier>> {
60    let planes: Vec<_> = seg
61        .regions
62        .iter()
63        .filter_map(|r| match r.carrier {
64            RegionCarrier::Plane { origin, normal } => Some((origin, normal)),
65            _ => None,
66        })
67        .collect();
68    if planes.is_empty() {
69        return Some(None);
70    }
71    if planes.len() != 6 {
72        return None;
73    }
74    let mut axes = Vec::<Vec3>::new();
75    let mut ranges = Vec::<[f64; 2]>::new();
76    let mut counts = Vec::<[usize; 2]>::new();
77    for (origin, normal) in planes {
78        let index = axes
79            .iter()
80            .position(|axis| axis.cross(normal).length() < 1e-6);
81        let i = match index {
82            Some(i) => i,
83            None => {
84                if axes.len() == 3 || axes.iter().any(|axis| axis.dot(normal).abs() > 1e-6) {
85                    return None;
86                }
87                axes.push(normal.normalized().ok()?);
88                ranges.push([f64::NEG_INFINITY, f64::INFINITY]);
89                counts.push([0, 0]);
90                axes.len() - 1
91            }
92        };
93        let side = usize::from(axes[i].dot(normal) > 0.);
94        ranges[i][side] = origin.dot(axes[i]);
95        counts[i][side] += 1;
96    }
97    if axes.len() != 3
98        || counts.iter().any(|c| *c != [1, 1])
99        || ranges.iter().any(|r| r[1] - r[0] <= precision)
100    {
101        return None;
102    }
103    Some(Some(BoxCarrier {
104        axes: [axes[0], axes[1], axes[2]],
105        low: [ranges[0][0], ranges[1][0], ranges[2][0]],
106        high: [ranges[0][1], ranges[1][1], ranges[2][1]],
107    }))
108}
109
110fn sphere_distance(sphere: &Sphere, p: Vec3) -> f64 {
111    p.sub(sphere.center).length() - sphere.radius
112}
113
114fn union_distance(spheres: &[Sphere], cube: Option<&BoxCarrier>, p: Vec3) -> f64 {
115    spheres
116        .iter()
117        .map(|s| sphere_distance(s, p))
118        .fold(cube.map_or(f64::INFINITY, |b| b.distance(p)), f64::min)
119}
120
121/// Attempt a small union of outward spheres and, optionally, one orthogonal
122/// box. Returns `None` for unsupported geometry or insufficient evidence.
123///
124/// The caller supplies a consistently wound closed component and its kernel
125/// segmentation. This does not relax segmentation tolerances: carriers need
126/// near-precision support, all source facets must agree with the exposed
127/// union boundary, and that boundary must also be covered by the source.
128/// The distance allowance for cut vertices is bounded by the measured
129/// chordal deflection of intact sphere facets (at most 2% of the radius).
130pub fn reconstruct_spherical_mesh_union(
131    positions: &[f64],
132    indices: &[u32],
133    seg: &MeshSegmentation,
134    options: &SegmentOptions,
135) -> Option<BrepSolid> {
136    let sequential;
137    let indices = if indices.is_empty() {
138        if positions.len() % 9 != 0 || positions.len() / 9 > 10_000 {
139            return None;
140        }
141        sequential = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
142        &sequential
143    } else {
144        indices
145    };
146    // Keep this bounded recovery out of general assemblies and noisy scans.
147    if !options.normal_tolerance_deg.is_finite()
148        || !(0. ..90.).contains(&options.normal_tolerance_deg)
149        || positions.len() % 3 != 0
150        || indices.len() % 3 != 0
151        || indices.len() / 3 > 10_000
152        || seg.regions.len() > 16
153        || seg.triangle_region_ids.len() != indices.len() / 3
154    {
155        return None;
156    }
157    let vertices: Vec<_> = positions
158        .chunks_exact(3)
159        .map(|p| Vec3::new(p[0], p[1], p[2]))
160        .collect();
161    if vertices
162        .iter()
163        .any(|p| !p.x.is_finite() || !p.y.is_finite() || !p.z.is_finite())
164    {
165        return None;
166    }
167    let triangles: Vec<[Vec3; 3]> = indices
168        .chunks_exact(3)
169        .map(|t| {
170            Some([
171                *vertices.get(t[0] as usize)?,
172                *vertices.get(t[1] as usize)?,
173                *vertices.get(t[2] as usize)?,
174            ])
175        })
176        .collect::<Option<_>>()?;
177    // Validate closed, opposite edge incidence here too: this API must never
178    // fill a missing mesh patch merely because its vertices fit a sphere.
179    let mut lo = vertices.first().copied()?;
180    let mut hi = lo;
181    for p in &vertices {
182        lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
183        hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
184    }
185    let weld_tolerance = if options.weld_tolerance > 0. && options.weld_tolerance.is_finite() {
186        options.weld_tolerance
187    } else {
188        (hi.sub(lo).length() * 1e-6).max(1e-12)
189    };
190    let mut welded = Vec::new();
191    let mut buckets = rustc_hash::FxHashMap::default();
192    let vertex_ids: Vec<_> = vertices
193        .iter()
194        .map(|&p| {
195            crate::mesh_weld::weld_vertex(p, weld_tolerance, &mut welded, &mut buckets) as u32
196        })
197        .collect();
198    let mut edges = rustc_hash::FxHashMap::<(u32, u32), (usize, i32)>::default();
199    for t in indices.chunks_exact(3) {
200        for i in 0..3 {
201            let (a, b) = (
202                vertex_ids[t[i] as usize],
203                vertex_ids[t[(i + 1) % 3] as usize],
204            );
205            if a == b {
206                return None;
207            }
208            let entry = edges.entry((a.min(b), a.max(b))).or_default();
209            entry.0 += 1;
210            entry.1 += if a < b { 1 } else { -1 };
211        }
212    }
213    if edges
214        .values()
215        .any(|&(count, sense)| count != 2 || sense != 0)
216    {
217        return None;
218    }
219    let mut spheres = Vec::new();
220    for region in &seg.regions {
221        match region.carrier {
222            RegionCarrier::Sphere {
223                center,
224                radius,
225                sense: 1,
226            } => {
227                let precision = radius * 2e-6;
228                if !radius.is_finite()
229                    || radius <= 0.
230                    || region.triangle_count < 24
231                    || !region.max_deviation.is_finite()
232                    || region.max_deviation > precision
233                {
234                    return None;
235                }
236                let mut sag = 0_f64;
237                for (t, &owner) in triangles.iter().zip(&seg.triangle_region_ids) {
238                    if owner != region.id {
239                        continue;
240                    }
241                    let normal = t[1].sub(t[0]).cross(t[2].sub(t[0])).normalized().ok()?;
242                    let height = t[0].sub(center).dot(normal);
243                    if height <= 0. {
244                        return None;
245                    }
246                    sag = sag.max(radius - height);
247                }
248                if sag <= 0. || sag > 0.02 * radius {
249                    return None;
250                }
251                spheres.push(Sphere {
252                    center,
253                    radius,
254                    sag: sag + precision,
255                });
256            }
257            RegionCarrier::Plane { .. } | RegionCarrier::Freeform => {}
258            _ => return None,
259        }
260    }
261    if spheres.is_empty() || spheres.len() > 2 {
262        return None;
263    }
264    let precision = spheres
265        .iter()
266        .map(|s| s.radius * 2e-6)
267        .fold(0_f64, f64::max);
268    let cube = box_carrier(seg, precision)?;
269    if spheres.len() == 1 && cube.is_none() {
270        return None;
271    }
272    let sag = spheres.iter().map(|s| s.sag).fold(0_f64, f64::max);
273    let cosine = options.normal_tolerance_deg.to_radians().cos();
274    for t in &triangles {
275        let normal = t[1].sub(t[0]).cross(t[2].sub(t[0])).normalized().ok()?;
276        let centroid = t[0].add(t[1]).add(t[2]).scale(1. / 3.);
277        let samples = [
278            t[0],
279            t[1],
280            t[2],
281            centroid,
282            t[0].add(t[1]).scale(0.5),
283            t[1].add(t[2]).scale(0.5),
284            t[2].add(t[0]).scale(0.5),
285        ];
286        if samples.iter().any(|&p| {
287            let d = union_distance(&spheres, cube.as_ref(), p);
288            !d.is_finite() || d < -sag || d > precision
289        }) {
290            return None;
291        }
292        let spherical = spheres.iter().any(|s| {
293            samples
294                .iter()
295                .all(|&p| sphere_distance(s, p).abs() <= s.sag)
296                && centroid
297                    .sub(s.center)
298                    .normalized()
299                    .is_ok_and(|n| n.dot(normal) >= cosine)
300        });
301        let planar = cube.as_ref().is_some_and(|b| {
302            (0..3).any(|i| {
303                [(-1., b.low[i]), (1., b.high[i])].iter().any(|&(sign, h)| {
304                    normal.dot(b.axes[i].scale(sign)) >= cosine
305                        && samples
306                            .iter()
307                            .all(|p| (p.dot(b.axes[i]) - h).abs() <= precision)
308                })
309            })
310        });
311        if !spherical && !planar {
312            return None;
313        }
314    }
315    // Check the reverse direction as well. Source-to-carrier distance alone
316    // would admit an incomplete or differently trimmed primitive arrangement.
317    let covered = |p: Vec3| -> bool {
318        union_distance(&spheres, cube.as_ref(), p) < -precision
319            || triangles
320                .iter()
321                .any(|t| point_triangle_distance(p, *t) <= sag)
322    };
323    for s in &spheres {
324        // Equal-area deterministic samples, including all Cartesian extrema.
325        for i in 0..2048 {
326            let z = 1. - 2. * (i as f64 + 0.5) / 2048.;
327            let angle = i as f64 * std::f64::consts::PI * (3. - 5_f64.sqrt());
328            let r = (1. - z * z).sqrt();
329            if !covered(
330                s.center
331                    .add(Vec3::new(r * angle.cos(), r * angle.sin(), z).scale(s.radius)),
332            ) {
333                return None;
334            }
335        }
336        for axis in [
337            Vec3::new(1., 0., 0.),
338            Vec3::new(0., 1., 0.),
339            Vec3::new(0., 0., 1.),
340        ] {
341            for sign in [-1., 1.] {
342                if !covered(s.center.add(axis.scale(sign * s.radius))) {
343                    return None;
344                }
345            }
346        }
347    }
348    if let Some(b) = &cube {
349        for i in 0..3 {
350            for h in [b.low[i], b.high[i]] {
351                for u in 0..=16 {
352                    for v in 0..=16 {
353                        let mut coordinates = b.low;
354                        coordinates[i] = h;
355                        for (j, fraction) in [((i + 1) % 3, u), ((i + 2) % 3, v)] {
356                            coordinates[j] += (b.high[j] - b.low[j]) * fraction as f64 / 16.;
357                        }
358                        if !covered(b.point(coordinates)) {
359                            return None;
360                        }
361                    }
362                }
363            }
364        }
365    }
366    let mut primitives = Vec::new();
367    if let Some(b) = cube {
368        primitives.push(b.solid().ok()?);
369    }
370    for s in spheres {
371        primitives.push(make_sphere_brep(s.center, s.radius, Vec3::new(0., 0., 1.)).ok()?);
372    }
373    let mut result = primitives.remove(0);
374    let boolean_options = BooleanOptions {
375        tolerance: (precision * 0.05).max(1e-12),
376        ..BooleanOptions::default()
377    };
378    for primitive in primitives {
379        result = boolean_operation(
380            &result,
381            &primitive,
382            BooleanOperation::Union,
383            &boolean_options,
384        )
385        .ok()?;
386    }
387    result.validate().is_empty().then_some(result)
388}
389
390fn point_triangle_distance(p: Vec3, [a, b, c]: [Vec3; 3]) -> f64 {
391    p.sub(crate::closest_point_on_triangle(p, a, b, c)).length()
392}