1use crate::{VizError, VizResult};
2
3#[cfg(feature = "core")]
4use spatialrust_core::HasPositions3;
5
6#[derive(Clone, Copy, Debug, PartialEq)]
8pub struct PositionColumns3<'a> {
9 pub x: &'a [f32],
11 pub y: &'a [f32],
13 pub z: &'a [f32],
15}
16
17impl<'a> PositionColumns3<'a> {
18 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 #[must_use]
30 pub fn len(self) -> usize {
31 self.x.len()
32 }
33
34 #[must_use]
36 pub fn is_empty(self) -> bool {
37 self.x.is_empty()
38 }
39}
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub struct Rgb8Columns<'a> {
44 pub red: &'a [u8],
46 pub green: &'a [u8],
48 pub blue: &'a [u8],
50}
51
52impl<'a> Rgb8Columns<'a> {
53 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#[derive(Clone, Copy, Debug, PartialEq)]
71pub struct ScalarColumn<'a> {
72 pub name: &'a str,
74 pub values: &'a [f32],
76}
77
78impl<'a> ScalarColumn<'a> {
79 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#[derive(Clone, Copy, Debug, PartialEq)]
95pub struct PointCloudView<'a> {
96 pub positions: PositionColumns3<'a>,
98 pub rgb: Option<Rgb8Columns<'a>>,
100 pub scalar: Option<ScalarColumn<'a>>,
102}
103
104impl<'a> PointCloudView<'a> {
105 #[must_use]
107 pub const fn positions_only(positions: PositionColumns3<'a>) -> Self {
108 Self { positions, rgb: None, scalar: None }
109 }
110
111 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 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#[derive(Clone, Copy, Debug, PartialEq)]
136pub struct LineListView<'a> {
137 pub positions_xyz: &'a [f32],
139}
140
141impl<'a> LineListView<'a> {
142 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 #[must_use]
154 pub fn segment_count(self) -> usize {
155 self.positions_xyz.len() / 6
156 }
157}
158
159#[derive(Clone, Copy, Debug, PartialEq)]
161pub struct TriangleMeshView<'a> {
162 pub positions_xyz: &'a [f32],
164 pub indices: &'a [u32],
166}
167
168impl<'a> TriangleMeshView<'a> {
169 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 #[must_use]
185 pub fn vertex_count(self) -> usize {
186 self.positions_xyz.len() / 3
187 }
188
189 #[must_use]
191 pub fn triangle_count(self) -> usize {
192 self.indices.len() / 3
193 }
194}
195
196#[derive(Clone, Copy, Debug, PartialEq)]
198pub enum VisualPrimitive<'a> {
199 Points(PointCloudView<'a>),
201 Lines(LineListView<'a>),
203 Triangles(TriangleMeshView<'a>),
205}
206
207#[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}