Skip to main content

spatialrust_interchange/tiles3d/
pnts.rs

1//! OGC 3D Tiles 1.1 point (`pnts`) binary tile codec.
2
3use crate::json::{parse_json, serialize_json, Json};
4use crate::{InterchangeError, InterchangeResult};
5
6const PNTS_MAGIC: &[u8; 4] = b"pnts";
7const PNTS_VERSION: u32 = 1;
8const HEADER_BYTES: usize = 28;
9const POSITION_COMPONENTS: usize = 3;
10const RGB_COMPONENTS: usize = 3;
11
12/// Feature-table data carried by one `pnts` tile.
13///
14/// Positions are stored relative to `rtc_center` when present; this keeps
15/// `f32` precision for tiles far from the coordinate origin.
16#[derive(Clone, Debug, PartialEq)]
17pub struct PntsFeatureTable {
18    /// Interleaved `x,y,z` positions (N*3 values) in the tile local frame.
19    pub positions: Vec<f32>,
20    /// Optional interleaved `r,g,b` bytes (N*3 values, range 0–255).
21    pub rgb: Option<Vec<u8>>,
22    /// Optional relative-to-center vector written to the feature table.
23    pub rtc_center: Option<[f64; 3]>,
24}
25
26impl PntsFeatureTable {
27    /// Returns the number of points in this tile.
28    #[must_use]
29    pub fn point_count(&self) -> usize {
30        self.positions.len() / POSITION_COMPONENTS
31    }
32
33    fn validate(&self) -> InterchangeResult<()> {
34        if self.positions.len() % POSITION_COMPONENTS != 0 {
35            return Err(InterchangeError::InvalidConfiguration(
36                "pnts positions length must be a multiple of 3".into(),
37            ));
38        }
39        if let Some(rgb) = &self.rgb {
40            if rgb.len() != self.point_count() * RGB_COMPONENTS {
41                return Err(InterchangeError::InvalidConfiguration(
42                    "pnts RGB length must equal three times the point count".into(),
43                ));
44            }
45        }
46        if let Some(center) = self.rtc_center {
47            if center.iter().any(|value| !value.is_finite()) {
48                return Err(InterchangeError::InvalidConfiguration(
49                    "pnts RTC_CENTER must contain finite values".into(),
50                ));
51            }
52        }
53        if self.positions.iter().any(|value| !value.is_finite()) {
54            return Err(InterchangeError::InvalidConfiguration(
55                "pnts positions must contain finite values".into(),
56            ));
57        }
58        Ok(())
59    }
60}
61
62/// Encodes a feature table into a complete `pnts` tile (header + JSON + binary).
63pub fn encode_pnts(table: &PntsFeatureTable) -> InterchangeResult<Vec<u8>> {
64    table.validate()?;
65    let point_count = table.point_count();
66    let position_bytes = point_count
67        .checked_mul(POSITION_COMPONENTS)
68        .and_then(|n| n.checked_mul(std::mem::size_of::<f32>()))
69        .ok_or_else(|| {
70            InterchangeError::InvalidConfiguration("pnts position size overflow".into())
71        })?;
72    let rgb_bytes = table.rgb.as_ref().map_or(0, |rgb| rgb.len());
73    let binary_len = position_bytes + rgb_bytes;
74
75    let rgb_offset = position_bytes as u64;
76    let mut members = vec![
77        ("POINTS_LENGTH", Json::Number(point_count.to_string())),
78        ("POSITION", Json::object(vec![("byteOffset", Json::Number("0".into()))])),
79    ];
80    if let Some(center) = table.rtc_center {
81        members.push((
82            "RTC_CENTER",
83            Json::Array(center.iter().map(|v| Json::Number(format_f64(*v))).collect()),
84        ));
85    }
86    if table.rgb.is_some() {
87        members.push((
88            "RGB",
89            Json::object(vec![("byteOffset", Json::Number(rgb_offset.to_string()))]),
90        ));
91    }
92    let mut feature_json = serialize_json(&Json::object(members)).into_bytes();
93    pad_to_8(&mut feature_json);
94
95    let total = HEADER_BYTES
96        .checked_add(feature_json.len())
97        .and_then(|n| n.checked_add(binary_len))
98        .ok_or_else(|| {
99            InterchangeError::InvalidConfiguration("pnts byte length overflow".into())
100        })?;
101    let mut out = Vec::with_capacity(total);
102    out.extend_from_slice(PNTS_MAGIC);
103    out.extend_from_slice(&PNTS_VERSION.to_le_bytes());
104    out.extend_from_slice(&(total as u32).to_le_bytes());
105    out.extend_from_slice(&(feature_json.len() as u32).to_le_bytes());
106    out.extend_from_slice(&(binary_len as u32).to_le_bytes());
107    out.extend_from_slice(&0u32.to_le_bytes()); // batch table JSON length
108    out.extend_from_slice(&0u32.to_le_bytes()); // batch table binary length
109    out.extend_from_slice(&feature_json);
110    for value in &table.positions {
111        out.extend_from_slice(&value.to_le_bytes());
112    }
113    if let Some(rgb) = &table.rgb {
114        out.extend_from_slice(rgb);
115    }
116    Ok(out)
117}
118
119/// Decodes a complete `pnts` tile into its feature table.
120pub fn decode_pnts(bytes: &[u8]) -> InterchangeResult<PntsFeatureTable> {
121    if bytes.len() < HEADER_BYTES {
122        return Err(InterchangeError::InvalidConfiguration(
123            "pnts tile is shorter than its header".into(),
124        ));
125    }
126    if &bytes[0..4] != PNTS_MAGIC {
127        return Err(InterchangeError::InvalidConfiguration("pnts tile has invalid magic".into()));
128    }
129    let version = read_u32(bytes, 4)?;
130    if version != PNTS_VERSION {
131        return Err(InterchangeError::InvalidConfiguration(format!(
132            "unsupported pnts version {version}"
133        )));
134    }
135    let declared_len = read_u32(bytes, 8)? as usize;
136    if declared_len != bytes.len() {
137        return Err(InterchangeError::InvalidConfiguration(
138            "pnts declared byte length does not match the input".into(),
139        ));
140    }
141    let feature_json_len = read_u32(bytes, 12)? as usize;
142    let feature_binary_len = read_u32(bytes, 16)? as usize;
143    let batch_json_len = read_u32(bytes, 20)? as usize;
144    let batch_binary_len = read_u32(bytes, 24)? as usize;
145
146    let json_start = HEADER_BYTES;
147    let json_end = json_start
148        .checked_add(feature_json_len)
149        .ok_or_else(|| InterchangeError::InvalidConfiguration("pnts JSON range overflow".into()))?;
150    let binary_end = json_end.checked_add(feature_binary_len).ok_or_else(|| {
151        InterchangeError::InvalidConfiguration("pnts binary range overflow".into())
152    })?;
153    let batch_end = binary_end
154        .checked_add(batch_json_len)
155        .and_then(|n| n.checked_add(batch_binary_len))
156        .ok_or_else(|| {
157            InterchangeError::InvalidConfiguration("pnts batch range overflow".into())
158        })?;
159    if batch_end > bytes.len() {
160        return Err(InterchangeError::InvalidConfiguration(
161            "pnts lengths exceed the tile byte length".into(),
162        ));
163    }
164
165    let json_text = std::str::from_utf8(&bytes[json_start..json_end])
166        .map_err(|_| InterchangeError::InvalidConfiguration("pnts JSON is not UTF-8".into()))?;
167    let feature = parse_json(json_text)?;
168    let point_count = feature
169        .get("POINTS_LENGTH")
170        .and_then(Json::as_u64)
171        .ok_or_else(|| InterchangeError::InvalidConfiguration("missing POINTS_LENGTH".into()))?
172        as usize;
173
174    let position_offset = feature
175        .get("POSITION")
176        .and_then(|position| position.get("byteOffset"))
177        .and_then(Json::as_u64)
178        .ok_or_else(|| {
179            InterchangeError::InvalidConfiguration("missing POSITION byteOffset".into())
180        })? as usize;
181    let position_bytes = point_count
182        .checked_mul(POSITION_COMPONENTS)
183        .and_then(|n| n.checked_mul(std::mem::size_of::<f32>()))
184        .ok_or_else(|| {
185            InterchangeError::InvalidConfiguration("pnts position size overflow".into())
186        })?;
187    let position_end = position_offset.checked_add(position_bytes).ok_or_else(|| {
188        InterchangeError::InvalidConfiguration("pnts position range overflow".into())
189    })?;
190    if position_end > feature_binary_len {
191        return Err(InterchangeError::InvalidConfiguration(
192            "pnts POSITION range exceeds the feature binary".into(),
193        ));
194    }
195
196    let mut positions = Vec::with_capacity(position_bytes / 4);
197    for chunk in bytes[json_end + position_offset..json_end + position_end].chunks_exact(4) {
198        positions.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
199    }
200
201    let rgb = match feature.get("RGB") {
202        None => None,
203        Some(rgb) => {
204            let rgb_offset = rgb.get("byteOffset").and_then(Json::as_u64).ok_or_else(|| {
205                InterchangeError::InvalidConfiguration("missing RGB byteOffset".into())
206            })? as usize;
207            let rgb_bytes = point_count.checked_mul(RGB_COMPONENTS).ok_or_else(|| {
208                InterchangeError::InvalidConfiguration("pnts RGB size overflow".into())
209            })?;
210            let rgb_end = rgb_offset.checked_add(rgb_bytes).ok_or_else(|| {
211                InterchangeError::InvalidConfiguration("pnts RGB range overflow".into())
212            })?;
213            if rgb_end > feature_binary_len {
214                return Err(InterchangeError::InvalidConfiguration(
215                    "pnts RGB range exceeds the feature binary".into(),
216                ));
217            }
218            Some(bytes[json_end + rgb_offset..json_end + rgb_end].to_vec())
219        }
220    };
221
222    let rtc_center = match feature.get("RTC_CENTER") {
223        None => None,
224        Some(center) => {
225            let values = center.as_array().ok_or_else(|| {
226                InterchangeError::InvalidConfiguration("RTC_CENTER must be an array".into())
227            })?;
228            if values.len() != 3 {
229                return Err(InterchangeError::InvalidConfiguration(
230                    "RTC_CENTER must contain three values".into(),
231                ));
232            }
233            let mut out = [0.0f64; 3];
234            for (index, value) in values.iter().enumerate() {
235                out[index] = value.as_f64().ok_or_else(|| {
236                    InterchangeError::InvalidConfiguration("RTC_CENTER value is not numeric".into())
237                })?;
238            }
239            Some(out)
240        }
241    };
242
243    Ok(PntsFeatureTable { positions, rgb, rtc_center })
244}
245
246fn read_u32(bytes: &[u8], offset: usize) -> InterchangeResult<u32> {
247    let end = offset
248        .checked_add(4)
249        .ok_or_else(|| InterchangeError::InvalidConfiguration("pnts offset overflow".into()))?;
250    let window = bytes
251        .get(offset..end)
252        .ok_or_else(|| InterchangeError::InvalidConfiguration("pnts header is truncated".into()))?;
253    Ok(u32::from_le_bytes([window[0], window[1], window[2], window[3]]))
254}
255
256fn pad_to_8(bytes: &mut Vec<u8>) {
257    while bytes.len() % 8 != 0 {
258        bytes.push(b' ');
259    }
260}
261
262fn format_f64(value: f64) -> String {
263    if value == value.trunc() && value.abs() < 1e15 {
264        format!("{value:.1}")
265    } else {
266        format!("{value}")
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::{decode_pnts, encode_pnts, PntsFeatureTable};
273
274    #[test]
275    fn round_trips_positions_only() {
276        let table = PntsFeatureTable {
277            positions: vec![0.0, 0.0, 0.0, 1.0, 2.0, 3.0],
278            rgb: None,
279            rtc_center: None,
280        };
281        let encoded = encode_pnts(&table).unwrap();
282        let decoded = decode_pnts(&encoded).unwrap();
283        assert_eq!(decoded, table);
284        assert_eq!(decoded.point_count(), 2);
285    }
286
287    #[test]
288    fn round_trips_rgb_and_center() {
289        let table = PntsFeatureTable {
290            positions: vec![1.0, 2.0, 3.0],
291            rgb: Some(vec![10, 20, 30]),
292            rtc_center: Some([1000.0, 2000.0, 3000.0]),
293        };
294        let encoded = encode_pnts(&table).unwrap();
295        let decoded = decode_pnts(&encoded).unwrap();
296        assert_eq!(decoded, table);
297    }
298
299    #[test]
300    fn rejects_invalid_magic() {
301        let mut encoded = encode_pnts(&PntsFeatureTable {
302            positions: vec![0.0, 0.0, 0.0],
303            rgb: None,
304            rtc_center: None,
305        })
306        .unwrap();
307        encoded[0] = b'X';
308        assert!(decode_pnts(&encoded).is_err());
309    }
310
311    #[test]
312    fn rejects_truncated_tile() {
313        let encoded = encode_pnts(&PntsFeatureTable {
314            positions: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
315            rgb: None,
316            rtc_center: None,
317        })
318        .unwrap();
319        assert!(decode_pnts(&encoded[..encoded.len() - 1]).is_err());
320        assert!(decode_pnts(&encoded[..10]).is_err());
321    }
322
323    #[test]
324    fn rejects_mismatched_rgb_length() {
325        let table = PntsFeatureTable {
326            positions: vec![0.0, 0.0, 0.0],
327            rgb: Some(vec![1, 2]),
328            rtc_center: None,
329        };
330        assert!(encode_pnts(&table).is_err());
331    }
332}