Skip to main content

spatialrust_core/
schema.rs

1use crate::{SpatialError, SpatialResult};
2
3/// Supported scalar dtypes for point fields.
4#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
5#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
6pub enum DType {
7    /// 32-bit float.
8    F32,
9    /// 64-bit float.
10    F64,
11    /// 8-bit unsigned integer.
12    U8,
13    /// 16-bit unsigned integer.
14    U16,
15    /// 32-bit unsigned integer.
16    U32,
17    /// 32-bit signed integer.
18    I32,
19    /// 16-bit float.
20    F16,
21}
22
23impl DType {
24    /// Returns the size of one scalar component in bytes.
25    #[must_use]
26    pub const fn size_bytes(self) -> usize {
27        match self {
28            Self::F32 => 4,
29            Self::F64 => 8,
30            Self::U8 => 1,
31            Self::U16 => 2,
32            Self::U32 | Self::I32 => 4,
33            Self::F16 => 2,
34        }
35    }
36}
37
38/// Semantic meaning of a point field.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub enum FieldSemantic {
42    /// Position X coordinate.
43    PositionX,
44    /// Position Y coordinate.
45    PositionY,
46    /// Position Z coordinate.
47    PositionZ,
48    /// Surface normal X component.
49    NormalX,
50    /// Surface normal Y component.
51    NormalY,
52    /// Surface normal Z component.
53    NormalZ,
54    /// LiDAR intensity.
55    Intensity,
56    /// Red color channel.
57    ColorR,
58    /// Green color channel.
59    ColorG,
60    /// Blue color channel.
61    ColorB,
62    /// Estimated curvature.
63    Curvature,
64    /// LiDAR ring index.
65    Ring,
66    /// Per-point time offset.
67    TimeOffset,
68    /// Segmentation or semantic label.
69    Label,
70    /// Learned embedding component.
71    Embedding,
72    /// Unknown or vendor-specific field.
73    Unknown,
74}
75
76/// One column in a point cloud schema.
77#[derive(Clone, Debug, PartialEq, Eq)]
78#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
79pub struct PointField {
80    /// Field name.
81    pub name: String,
82    /// Semantic meaning.
83    pub semantic: FieldSemantic,
84    /// Scalar dtype.
85    pub dtype: DType,
86    /// Number of scalar components.
87    pub components: usize,
88}
89
90impl PointField {
91    /// Creates a scalar field.
92    #[must_use]
93    pub fn scalar(name: impl Into<String>, semantic: FieldSemantic, dtype: DType) -> Self {
94        Self { name: name.into(), semantic, dtype, components: 1 }
95    }
96
97    /// Total byte size of one point value for this field.
98    #[must_use]
99    pub fn byte_size(&self) -> usize {
100        self.dtype.size_bytes() * self.components
101    }
102}
103
104/// Schema describing the columns of a point cloud.
105#[derive(Clone, Debug, Default, PartialEq, Eq)]
106#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
107pub struct PointSchema {
108    fields: Vec<PointField>,
109}
110
111impl PointSchema {
112    /// Creates an empty schema.
113    #[must_use]
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Adds a field to the schema.
119    pub fn with_field(mut self, field: PointField) -> Self {
120        self.fields.push(field);
121        self
122    }
123
124    /// Returns the schema fields.
125    #[must_use]
126    pub fn fields(&self) -> &[PointField] {
127        &self.fields
128    }
129
130    /// Returns the number of fields.
131    #[must_use]
132    pub fn len(&self) -> usize {
133        self.fields.len()
134    }
135
136    /// Returns whether the schema has no fields.
137    #[must_use]
138    pub fn is_empty(&self) -> bool {
139        self.fields.is_empty()
140    }
141
142    /// Finds a field by semantic.
143    #[must_use]
144    pub fn find_semantic(&self, semantic: FieldSemantic) -> Option<&PointField> {
145        self.fields.iter().find(|field| field.semantic == semantic)
146    }
147
148    /// Validates that required position fields exist exactly once.
149    pub fn validate_positions(&self) -> SpatialResult<()> {
150        for semantic in
151            [FieldSemantic::PositionX, FieldSemantic::PositionY, FieldSemantic::PositionZ]
152        {
153            let count = self.fields.iter().filter(|field| field.semantic == semantic).count();
154            if count != 1 {
155                return Err(SpatialError::SchemaValidation(format!(
156                    "expected exactly one {semantic:?} field, found {count}"
157                )));
158            }
159        }
160        Ok(())
161    }
162}
163
164/// Standard schemas used by typed views and IO adapters.
165pub struct StandardSchemas;
166
167impl StandardSchemas {
168    /// `PointXYZ` schema.
169    #[must_use]
170    pub fn point_xyz() -> PointSchema {
171        PointSchema::new()
172            .with_field(PointField::scalar("x", FieldSemantic::PositionX, DType::F32))
173            .with_field(PointField::scalar("y", FieldSemantic::PositionY, DType::F32))
174            .with_field(PointField::scalar("z", FieldSemantic::PositionZ, DType::F32))
175    }
176
177    /// `PointXYZI` schema.
178    #[must_use]
179    pub fn point_xyzi() -> PointSchema {
180        Self::point_xyz().with_field(PointField::scalar(
181            "intensity",
182            FieldSemantic::Intensity,
183            DType::F32,
184        ))
185    }
186
187    /// `PointXYZRGB` schema.
188    #[must_use]
189    pub fn point_xyzrgb() -> PointSchema {
190        Self::point_xyz()
191            .with_field(PointField::scalar("r", FieldSemantic::ColorR, DType::U8))
192            .with_field(PointField::scalar("g", FieldSemantic::ColorG, DType::U8))
193            .with_field(PointField::scalar("b", FieldSemantic::ColorB, DType::U8))
194    }
195
196    /// `PointXYZIRGB` schema (intensity + packed RGB, LAS/PCD composite).
197    #[must_use]
198    pub fn point_xyzirgb() -> PointSchema {
199        Self::point_xyzi()
200            .with_field(PointField::scalar("r", FieldSemantic::ColorR, DType::U8))
201            .with_field(PointField::scalar("g", FieldSemantic::ColorG, DType::U8))
202            .with_field(PointField::scalar("b", FieldSemantic::ColorB, DType::U8))
203    }
204
205    /// `PointXYZINormal` schema.
206    #[must_use]
207    pub fn point_xyzinormal() -> PointSchema {
208        Self::point_xyzi()
209            .with_field(PointField::scalar("normal_x", FieldSemantic::NormalX, DType::F32))
210            .with_field(PointField::scalar("normal_y", FieldSemantic::NormalY, DType::F32))
211            .with_field(PointField::scalar("normal_z", FieldSemantic::NormalZ, DType::F32))
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::{FieldSemantic, StandardSchemas};
218
219    #[test]
220    fn standard_xyz_schema_validates() {
221        let schema = StandardSchemas::point_xyz();
222        schema.validate_positions().unwrap();
223        assert_eq!(schema.len(), 3);
224        assert!(schema.find_semantic(FieldSemantic::PositionX).is_some());
225    }
226
227    #[test]
228    fn standard_xyzirgb_has_intensity_and_color() {
229        let schema = StandardSchemas::point_xyzirgb();
230        assert!(schema.find_semantic(FieldSemantic::Intensity).is_some());
231        assert!(schema.find_semantic(FieldSemantic::ColorR).is_some());
232        assert_eq!(schema.len(), 7);
233    }
234}