1use spatialrust_core::{
10 HasNormals3, HasPositions3, PointBuffer, PointBufferSet, PointCloud, SpatialError,
11 SpatialResult,
12};
13use spatialrust_math::Vec3;
14use spatialrust_search::{KdTree, RadiusSearchIndex};
15
16#[derive(Clone, Copy, Debug, PartialEq)]
18pub struct BoundaryConfig {
19 pub search_radius: f32,
21 pub angle_threshold: f32,
23 pub min_neighbors: usize,
25}
26
27impl Default for BoundaryConfig {
28 fn default() -> Self {
29 Self { search_radius: 0.1, angle_threshold: std::f32::consts::FRAC_PI_2, min_neighbors: 5 }
31 }
32}
33
34impl BoundaryConfig {
35 #[must_use]
37 pub fn with_radius(search_radius: f32) -> Self {
38 Self { search_radius, ..Self::default() }
39 }
40}
41
42#[derive(Clone, Debug, PartialEq)]
44pub struct BoundaryResult {
45 pub indices: Vec<usize>,
47 pub boundary: PointCloud,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq)]
53pub struct BoundaryDetector {
54 config: BoundaryConfig,
55}
56
57impl BoundaryDetector {
58 #[must_use]
60 pub const fn new(config: BoundaryConfig) -> Self {
61 Self { config }
62 }
63
64 #[must_use]
66 pub const fn config(&self) -> BoundaryConfig {
67 self.config
68 }
69
70 pub fn boundary_mask(&self, input: &PointCloud) -> SpatialResult<Vec<bool>> {
72 if self.config.search_radius <= 0.0 || self.config.search_radius.is_nan() {
73 return Err(SpatialError::InvalidArgument("search_radius must be positive".to_owned()));
74 }
75 let len = input.len();
76 if len == 0 {
77 return Ok(Vec::new());
78 }
79
80 let (x, y, z) = input.positions3()?;
81 let (nx, ny, nz) = input.normals3()?;
82 let tree = KdTree::from_slices(x, y, z);
83
84 let mut mask = vec![false; len];
85 let mut angles: Vec<f32> = Vec::new();
86 for i in 0..len {
87 let p = Vec3::new(x[i], y[i], z[i]);
88 let normal = Vec3::new(nx[i], ny[i], nz[i]);
89 let (u, v) = tangent_basis(normal);
90
91 let neighbors = tree.radius_search(p.x, p.y, p.z, self.config.search_radius);
92 angles.clear();
93 for neighbor in &neighbors {
94 let j = neighbor.index;
95 if j == i {
96 continue;
97 }
98 let d = Vec3::new(x[j] - p.x, y[j] - p.y, z[j] - p.z);
99 let (du, dv) = (d.dot(u), d.dot(v));
100 if du * du + dv * dv > 1e-12 {
101 angles.push(dv.atan2(du));
102 }
103 }
104 if angles.len() < self.config.min_neighbors {
105 continue;
106 }
107 mask[i] = max_angle_gap(&mut angles) > self.config.angle_threshold;
108 }
109 Ok(mask)
110 }
111
112 pub fn detect(&self, input: &PointCloud) -> SpatialResult<BoundaryResult> {
114 let mask = self.boundary_mask(input)?;
115 let indices: Vec<usize> =
116 mask.iter().enumerate().filter_map(|(i, &b)| b.then_some(i)).collect();
117 let boundary = gather_indices(input, &indices)?;
118 Ok(BoundaryResult { indices, boundary })
119 }
120}
121
122fn tangent_basis(n: Vec3<f32>) -> (Vec3<f32>, Vec3<f32>) {
124 let helper = if n.x.abs() < 0.9 { Vec3::new(1.0, 0.0, 0.0) } else { Vec3::new(0.0, 1.0, 0.0) };
125 let u = n.cross(helper).normalize();
126 let v = n.cross(u);
127 (u, v)
128}
129
130fn max_angle_gap(angles: &mut [f32]) -> f32 {
132 if angles.is_empty() {
135 return 0.0;
136 }
137 angles.sort_by(f32::total_cmp);
138 let mut max_gap = 0.0_f32;
139 for w in angles.windows(2) {
140 max_gap = max_gap.max(w[1] - w[0]);
141 }
142 let wrap = (angles[0] + std::f32::consts::TAU) - angles[angles.len() - 1];
144 max_gap.max(wrap)
145}
146
147fn gather_indices(input: &PointCloud, indices: &[usize]) -> SpatialResult<PointCloud> {
148 let mut buffers = PointBufferSet::new();
149 for field in input.schema().fields() {
150 let source = input.field(&field.name)?;
151 buffers.insert(field.name.clone(), gather_buffer(source, indices));
152 }
153 PointCloud::try_from_parts(input.schema().clone(), buffers, input.metadata().clone())
154}
155
156fn gather_buffer(source: &PointBuffer, indices: &[usize]) -> PointBuffer {
157 match source {
158 PointBuffer::F32(v) => PointBuffer::from_f32(indices.iter().map(|&i| v[i]).collect()),
159 PointBuffer::F64(v) => PointBuffer::F64(indices.iter().map(|&i| v[i]).collect()),
160 PointBuffer::U8(v) => PointBuffer::U8(indices.iter().map(|&i| v[i]).collect()),
161 PointBuffer::U16(v) => PointBuffer::U16(indices.iter().map(|&i| v[i]).collect()),
162 PointBuffer::U32(v) => PointBuffer::U32(indices.iter().map(|&i| v[i]).collect()),
163 PointBuffer::I32(v) => PointBuffer::I32(indices.iter().map(|&i| v[i]).collect()),
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::{BoundaryConfig, BoundaryDetector};
170 use spatialrust_core::{DType, FieldSemantic, PointCloudBuilder, PointField, PointSchema};
171
172 fn schema() -> PointSchema {
173 PointSchema::new()
174 .with_field(PointField::scalar("x", FieldSemantic::PositionX, DType::F32))
175 .with_field(PointField::scalar("y", FieldSemantic::PositionY, DType::F32))
176 .with_field(PointField::scalar("z", FieldSemantic::PositionZ, DType::F32))
177 .with_field(PointField::scalar("normal_x", FieldSemantic::NormalX, DType::F32))
178 .with_field(PointField::scalar("normal_y", FieldSemantic::NormalY, DType::F32))
179 .with_field(PointField::scalar("normal_z", FieldSemantic::NormalZ, DType::F32))
180 }
181
182 fn square_patch(n: usize) -> spatialrust_core::PointCloud {
185 let mut builder = PointCloudBuilder::new(schema());
186 for i in 0..n {
187 for j in 0..n {
188 builder.push_point([i as f32 * 0.1, j as f32 * 0.1, 0.0, 0.0, 0.0, 1.0]).unwrap();
189 }
190 }
191 builder.build().unwrap()
192 }
193
194 #[test]
195 fn detects_patch_rim_not_interior() {
196 let n = 12;
197 let cloud = square_patch(n);
198 let config = BoundaryConfig { min_neighbors: 3, ..BoundaryConfig::with_radius(0.18) };
201 let result = BoundaryDetector::new(config).detect(&cloud).unwrap();
202 let mask = BoundaryDetector::new(config).boundary_mask(&cloud).unwrap();
203
204 let center = (n / 2) * n + (n / 2);
206 let corner = 0;
207 assert!(!mask[center], "interior point flagged as boundary");
208 assert!(mask[corner], "corner not flagged as boundary");
209 assert!(!result.indices.is_empty());
211 assert!(result.indices.len() < cloud.len() / 2);
212 assert_eq!(result.boundary.len(), result.indices.len());
213 }
214
215 #[test]
216 fn rejects_bad_radius() {
217 let cloud = square_patch(5);
218 assert!(BoundaryDetector::new(BoundaryConfig::with_radius(0.0)).detect(&cloud).is_err());
219 }
220
221 #[test]
222 fn handles_isolated_point_with_zero_min_neighbors() {
223 let mut builder = PointCloudBuilder::new(schema());
227 builder.push_point([0.0, 0.0, 0.0, 0.0, 0.0, 1.0]).unwrap();
228 builder.push_point([100.0, 100.0, 100.0, 0.0, 0.0, 1.0]).unwrap();
229 let cloud = builder.build().unwrap();
230
231 let config = BoundaryConfig { min_neighbors: 0, ..BoundaryConfig::with_radius(0.1) };
232 let mask = BoundaryDetector::new(config).boundary_mask(&cloud).unwrap();
233 assert_eq!(mask, vec![false, false]);
235 }
236}