Skip to main content

spatialrust_viz/
geometry.rs

1use crate::{VizError, VizResult};
2
3#[cfg(feature = "core")]
4use spatialrust_core::HasPositions3;
5
6/// Borrowed structure-of-arrays XYZ position columns.
7#[derive(Clone, Copy, Debug, PartialEq)]
8pub struct PositionColumns3<'a> {
9    /// X coordinates.
10    pub x: &'a [f32],
11    /// Y coordinates.
12    pub y: &'a [f32],
13    /// Z coordinates.
14    pub z: &'a [f32],
15}
16
17impl<'a> PositionColumns3<'a> {
18    /// Creates a borrowed position view without copying.
19    pub fn try_new(x: &'a [f32], y: &'a [f32], z: &'a [f32]) -> VizResult<Self> {
20        if x.len() != y.len() || x.len() != z.len() {
21            return Err(VizError::InvalidGeometry(
22                "XYZ position columns must have equal lengths".into(),
23            ));
24        }
25        Ok(Self { x, y, z })
26    }
27
28    /// Number of positions.
29    #[must_use]
30    pub fn len(self) -> usize {
31        self.x.len()
32    }
33
34    /// Whether the view contains no positions.
35    #[must_use]
36    pub fn is_empty(self) -> bool {
37        self.x.is_empty()
38    }
39}
40
41/// Borrowed structure-of-arrays RGB color columns.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub struct Rgb8Columns<'a> {
44    /// Red components.
45    pub red: &'a [u8],
46    /// Green components.
47    pub green: &'a [u8],
48    /// Blue components.
49    pub blue: &'a [u8],
50}
51
52impl<'a> Rgb8Columns<'a> {
53    /// Creates validated RGB columns for `point_count` points.
54    pub fn try_new(
55        red: &'a [u8],
56        green: &'a [u8],
57        blue: &'a [u8],
58        point_count: usize,
59    ) -> VizResult<Self> {
60        if red.len() != point_count || green.len() != point_count || blue.len() != point_count {
61            return Err(VizError::InvalidGeometry(
62                "RGB columns must match the position count".into(),
63            ));
64        }
65        Ok(Self { red, green, blue })
66    }
67}
68
69/// Named borrowed scalar values used for point coloring.
70#[derive(Clone, Copy, Debug, PartialEq)]
71pub struct ScalarColumn<'a> {
72    /// Stable attribute name such as `intensity` or `cluster_id`.
73    pub name: &'a str,
74    /// One scalar per point.
75    pub values: &'a [f32],
76}
77
78impl<'a> ScalarColumn<'a> {
79    /// Creates a scalar column matching `point_count`.
80    pub fn try_new(name: &'a str, values: &'a [f32], point_count: usize) -> VizResult<Self> {
81        if name.trim().is_empty() {
82            return Err(VizError::InvalidGeometry("scalar column name must not be empty".into()));
83        }
84        if values.len() != point_count {
85            return Err(VizError::InvalidGeometry(
86                "scalar column must match the position count".into(),
87            ));
88        }
89        Ok(Self { name, values })
90    }
91}
92
93/// Borrowed point-cloud geometry.
94#[derive(Clone, Copy, Debug, PartialEq)]
95pub struct PointCloudView<'a> {
96    /// Point positions.
97    pub positions: PositionColumns3<'a>,
98    /// Optional RGB attributes.
99    pub rgb: Option<Rgb8Columns<'a>>,
100    /// Optional scalar attribute.
101    pub scalar: Option<ScalarColumn<'a>>,
102}
103
104impl<'a> PointCloudView<'a> {
105    /// Creates a position-only point-cloud view.
106    #[must_use]
107    pub const fn positions_only(positions: PositionColumns3<'a>) -> Self {
108        Self { positions, rgb: None, scalar: None }
109    }
110
111    /// Attaches validated RGB attributes.
112    pub fn with_rgb(mut self, rgb: Rgb8Columns<'a>) -> VizResult<Self> {
113        if rgb.red.len() != self.positions.len() {
114            return Err(VizError::InvalidGeometry(
115                "RGB columns must match the position count".into(),
116            ));
117        }
118        self.rgb = Some(rgb);
119        Ok(self)
120    }
121
122    /// Attaches a validated scalar attribute.
123    pub fn with_scalar(mut self, scalar: ScalarColumn<'a>) -> VizResult<Self> {
124        if scalar.values.len() != self.positions.len() {
125            return Err(VizError::InvalidGeometry(
126                "scalar column must match the position count".into(),
127            ));
128        }
129        self.scalar = Some(scalar);
130        Ok(self)
131    }
132}
133
134/// Borrowed pairs of line endpoints stored as interleaved XYZ values.
135#[derive(Clone, Copy, Debug, PartialEq)]
136pub struct LineListView<'a> {
137    /// Interleaved XYZ endpoint data; every six values form one segment.
138    pub positions_xyz: &'a [f32],
139}
140
141impl<'a> LineListView<'a> {
142    /// Creates a validated line-list view.
143    pub fn try_new(positions_xyz: &'a [f32]) -> VizResult<Self> {
144        if positions_xyz.len() % 6 != 0 {
145            return Err(VizError::InvalidGeometry(
146                "line-list positions must contain six values per segment".into(),
147            ));
148        }
149        Ok(Self { positions_xyz })
150    }
151
152    /// Number of line segments.
153    #[must_use]
154    pub fn segment_count(self) -> usize {
155        self.positions_xyz.len() / 6
156    }
157}
158
159/// Borrowed indexed triangle mesh.
160#[derive(Clone, Copy, Debug, PartialEq)]
161pub struct TriangleMeshView<'a> {
162    /// Interleaved XYZ vertex positions.
163    pub positions_xyz: &'a [f32],
164    /// Three vertex indices per triangle.
165    pub indices: &'a [u32],
166}
167
168impl<'a> TriangleMeshView<'a> {
169    /// Creates a validated indexed mesh view.
170    pub fn try_new(positions_xyz: &'a [f32], indices: &'a [u32]) -> VizResult<Self> {
171        if positions_xyz.len() % 3 != 0 || indices.len() % 3 != 0 {
172            return Err(VizError::InvalidGeometry(
173                "mesh positions and indices must contain complete triples".into(),
174            ));
175        }
176        let vertex_count = positions_xyz.len() / 3;
177        if indices.iter().any(|&index| index as usize >= vertex_count) {
178            return Err(VizError::InvalidGeometry("mesh index is out of bounds".into()));
179        }
180        Ok(Self { positions_xyz, indices })
181    }
182
183    /// Number of vertices.
184    #[must_use]
185    pub fn vertex_count(self) -> usize {
186        self.positions_xyz.len() / 3
187    }
188
189    /// Number of triangles.
190    #[must_use]
191    pub fn triangle_count(self) -> usize {
192        self.indices.len() / 3
193    }
194}
195
196/// Backend-independent borrowed visual geometry.
197#[derive(Clone, Copy, Debug, PartialEq)]
198pub enum VisualPrimitive<'a> {
199    /// Point-cloud geometry.
200    Points(PointCloudView<'a>),
201    /// Independent line segments.
202    Lines(LineListView<'a>),
203    /// Indexed triangle mesh.
204    Triangles(TriangleMeshView<'a>),
205}
206
207/// Creates a zero-copy point view from a core position capability.
208///
209/// The returned view borrows the source's structure-of-arrays columns. This
210/// function never interleaves, uploads, or otherwise copies point data.
211#[cfg(feature = "core")]
212pub fn point_cloud_positions<'a>(source: &'a impl HasPositions3) -> VizResult<PointCloudView<'a>> {
213    let (x, y, z) = source
214        .positions3()
215        .map_err(|error| VizError::InvalidGeometry(format!("position capability: {error}")))?;
216    Ok(PointCloudView::positions_only(PositionColumns3::try_new(x, y, z)?))
217}
218
219#[cfg(test)]
220mod tests {
221    use super::{
222        LineListView, PointCloudView, PositionColumns3, Rgb8Columns, ScalarColumn, TriangleMeshView,
223    };
224
225    #[test]
226    fn borrowed_point_view_preserves_source_identity() {
227        let x = [1.0, 2.0];
228        let y = [3.0, 4.0];
229        let z = [5.0, 6.0];
230        let positions = PositionColumns3::try_new(&x, &y, &z).unwrap();
231        let rgb = Rgb8Columns::try_new(&[1, 2], &[3, 4], &[5, 6], 2).unwrap();
232        let scalar = ScalarColumn::try_new("intensity", &[0.1, 0.2], 2).unwrap();
233        let view = PointCloudView::positions_only(positions)
234            .with_rgb(rgb)
235            .unwrap()
236            .with_scalar(scalar)
237            .unwrap();
238
239        assert!(core::ptr::eq(view.positions.x.as_ptr(), x.as_ptr()));
240        assert_eq!(view.scalar.unwrap().name, "intensity");
241    }
242
243    #[test]
244    fn rejects_mismatched_and_invalid_geometry() {
245        assert!(PositionColumns3::try_new(&[0.0], &[], &[0.0]).is_err());
246        assert!(LineListView::try_new(&[0.0; 5]).is_err());
247        assert!(TriangleMeshView::try_new(&[0.0; 9], &[0, 1, 3]).is_err());
248    }
249
250    #[cfg(feature = "core")]
251    #[test]
252    fn core_adapter_borrows_point_cloud_columns() {
253        use spatialrust_core::{HasPositions3, PointCloudBuilder, StandardSchemas};
254
255        let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
256        builder.push_point([1.0, 2.0, 3.0]).unwrap();
257        let cloud = builder.build().unwrap();
258        let (x, _, _) = cloud.positions3().unwrap();
259        let view = super::point_cloud_positions(&cloud).unwrap();
260        assert!(core::ptr::eq(view.positions.x.as_ptr(), x.as_ptr()));
261    }
262}