1use std::collections::BTreeSet;
2
3use spatialrust_viz::{Camera, LayerId, PointColor, VisualPrimitive, VisualScene, VisualStyle};
4
5use crate::{ViewerError, ViewerResult};
6
7pub const VIEWER_STATE_VERSION: u32 = 1;
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub struct ViewportSize {
14 pub width: u32,
16 pub height: u32,
18}
19
20impl ViewportSize {
21 pub fn try_new(width: u32, height: u32) -> ViewerResult<Self> {
23 if width == 0 || height == 0 {
24 return Err(ViewerError::InvalidState("viewport dimensions must be non-zero".into()));
25 }
26 Ok(Self { width, height })
27 }
28
29 #[must_use]
31 pub fn aspect(self) -> f32 {
32 self.width as f32 / self.height as f32
33 }
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub struct AttributeSummary {
40 pub name: String,
42 pub data_type: String,
44 pub len: usize,
46}
47
48#[derive(Clone, Debug, PartialEq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51pub struct LayerPresentation {
52 pub id: LayerId,
54 pub label: String,
56 pub visible: bool,
58 pub style: VisualStyle,
60 pub element_count: usize,
62 pub attributes: Vec<AttributeSummary>,
64}
65
66#[derive(Clone, Debug, PartialEq)]
68pub struct InspectorSelection<'a> {
69 pub layer: &'a LayerPresentation,
71 pub active_attribute: Option<&'a str>,
73}
74
75#[derive(Clone, Debug, PartialEq)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78pub struct ViewerState {
79 pub version: u32,
81 pub camera: Camera,
83 pub viewport: ViewportSize,
85 pub layers: Vec<LayerPresentation>,
87 pub selected_layer: Option<LayerId>,
89 pub pending_files: Vec<String>,
91}
92
93impl ViewerState {
94 pub fn try_new(camera: Camera, viewport: ViewportSize) -> ViewerResult<Self> {
96 validate_camera(camera)?;
97 let state = Self {
98 version: VIEWER_STATE_VERSION,
99 camera,
100 viewport,
101 layers: Vec::new(),
102 selected_layer: None,
103 pending_files: Vec::new(),
104 };
105 state.validate()?;
106 Ok(state)
107 }
108
109 pub fn validate(&self) -> ViewerResult<()> {
112 if self.version != VIEWER_STATE_VERSION {
113 return Err(ViewerError::InvalidState(format!(
114 "unsupported viewer state version {}",
115 self.version
116 )));
117 }
118 validate_camera(self.camera)?;
119 ViewportSize::try_new(self.viewport.width, self.viewport.height)?;
120
121 let mut ids = BTreeSet::new();
122 for layer in &self.layers {
123 LayerId::try_new(layer.id.as_str().to_owned())?;
124 if !ids.insert(layer.id.as_str()) {
125 return Err(ViewerError::InvalidState(format!(
126 "duplicate viewer layer `{}`",
127 layer.id.as_str()
128 )));
129 }
130 validate_style_attributes(layer, &layer.style)?;
131 }
132 if let Some(selected) = &self.selected_layer {
133 if !ids.contains(selected.as_str()) {
134 return Err(ViewerError::InvalidState(
135 "selected viewer layer is absent from layers".into(),
136 ));
137 }
138 }
139 for path in &self.pending_files {
140 let extension = path.rsplit('.').next().unwrap_or_default().to_ascii_lowercase();
141 if !matches!(extension.as_str(), "pcd" | "ply" | "las" | "laz" | "copc" | "e57") {
142 return Err(ViewerError::InvalidState(format!(
143 "unsupported pending file extension in `{path}`"
144 )));
145 }
146 }
147 Ok(())
148 }
149
150 pub fn sync_scene(&mut self, scene: &VisualScene<'_>) {
154 let mut next = Vec::with_capacity(scene.layers().len());
155 for layer in scene.layers() {
156 let existing = self.layers.iter().find(|entry| entry.id == layer.id);
157 let (element_count, attributes) = describe_primitive(layer.primitive);
158 next.push(LayerPresentation {
159 id: layer.id.clone(),
160 label: layer.label.clone(),
161 visible: existing.map_or(layer.visible, |entry| entry.visible),
162 style: existing.map_or_else(|| layer.style.clone(), |entry| entry.style.clone()),
163 element_count,
164 attributes,
165 });
166 }
167 self.layers = next;
168 if self
169 .selected_layer
170 .as_ref()
171 .is_some_and(|id| !self.layers.iter().any(|layer| &layer.id == id))
172 {
173 self.selected_layer = None;
174 }
175 }
176
177 pub fn set_layer_visible(&mut self, id: &LayerId, visible: bool) -> ViewerResult<()> {
179 self.layer_mut(id)?.visible = visible;
180 Ok(())
181 }
182
183 pub fn select_layer(&mut self, id: Option<&LayerId>) -> ViewerResult<()> {
185 if let Some(id) = id {
186 if !self.layers.iter().any(|layer| &layer.id == id) {
187 return Err(ViewerError::UnknownLayer(id.as_str().into()));
188 }
189 self.selected_layer = Some(id.clone());
190 } else {
191 self.selected_layer = None;
192 }
193 Ok(())
194 }
195
196 pub fn set_layer_style(&mut self, id: &LayerId, style: VisualStyle) -> ViewerResult<()> {
198 let layer = self.layer_mut(id)?;
199 validate_style_attributes(layer, &style)?;
200 layer.style = style;
201 Ok(())
202 }
203
204 #[must_use]
206 pub fn inspector(&self) -> Option<InspectorSelection<'_>> {
207 let id = self.selected_layer.as_ref()?;
208 let layer = self.layers.iter().find(|layer| &layer.id == id)?;
209 let active_attribute = match &layer.style {
210 VisualStyle::Points(style) => match &style.color {
211 PointColor::Rgb => Some("rgb"),
212 PointColor::Scalar { .. } => layer
213 .attributes
214 .iter()
215 .find(|attribute| attribute.data_type == "f32")
216 .map(|attribute| attribute.name.as_str()),
217 PointColor::Uniform(_) => None,
218 },
219 VisualStyle::Uniform(_) => None,
220 };
221 Some(InspectorSelection { layer, active_attribute })
222 }
223
224 pub fn queue_dropped_file(&mut self, path: impl Into<String>) -> ViewerResult<()> {
226 let path = path.into();
227 let extension = path.rsplit('.').next().unwrap_or_default().to_ascii_lowercase();
228 if !matches!(extension.as_str(), "pcd" | "ply" | "las" | "laz" | "copc" | "e57") {
229 return Err(ViewerError::InvalidState(format!(
230 "unsupported dropped file extension in `{path}`"
231 )));
232 }
233 self.pending_files.push(path);
234 Ok(())
235 }
236
237 fn layer_mut(&mut self, id: &LayerId) -> ViewerResult<&mut LayerPresentation> {
238 self.layers
239 .iter_mut()
240 .find(|layer| &layer.id == id)
241 .ok_or_else(|| ViewerError::UnknownLayer(id.as_str().into()))
242 }
243}
244
245fn validate_camera(camera: Camera) -> ViewerResult<()> {
246 Camera::try_new(camera.eye, camera.target, camera.up, camera.projection)?;
247 Ok(())
248}
249
250fn describe_primitive(primitive: VisualPrimitive<'_>) -> (usize, Vec<AttributeSummary>) {
251 match primitive {
252 VisualPrimitive::Points(points) => {
253 let mut attributes = vec![AttributeSummary {
254 name: "position".into(),
255 data_type: "vec3<f32>".into(),
256 len: points.positions.len(),
257 }];
258 if points.rgb.is_some() {
259 attributes.push(AttributeSummary {
260 name: "rgb".into(),
261 data_type: "rgb8".into(),
262 len: points.positions.len(),
263 });
264 }
265 if let Some(scalar) = points.scalar {
266 attributes.push(AttributeSummary {
267 name: scalar.name.into(),
268 data_type: "f32".into(),
269 len: scalar.values.len(),
270 });
271 }
272 (points.positions.len(), attributes)
273 }
274 VisualPrimitive::Lines(lines) => (lines.segment_count(), Vec::new()),
275 VisualPrimitive::Triangles(mesh) => (mesh.triangle_count(), Vec::new()),
276 }
277}
278
279fn validate_style_attributes(layer: &LayerPresentation, style: &VisualStyle) -> ViewerResult<()> {
280 if let VisualStyle::Points(point_style) = style {
281 match point_style.color {
282 PointColor::Rgb
283 if !layer.attributes.iter().any(|attribute| attribute.name == "rgb") =>
284 {
285 return Err(ViewerError::InvalidState(
286 "RGB style requires an RGB layer attribute".into(),
287 ));
288 }
289 PointColor::Scalar { .. }
290 if !layer.attributes.iter().any(|attribute| attribute.data_type == "f32") =>
291 {
292 return Err(ViewerError::InvalidState(
293 "scalar style requires an f32 layer attribute".into(),
294 ));
295 }
296 _ => {}
297 }
298 }
299 Ok(())
300}
301
302#[cfg(test)]
303mod tests {
304 use spatialrust_math::Vec3;
305 use spatialrust_viz::{
306 Camera, LayerId, LinearRgba, PointCloudView, PointColor, PointStyle, PositionColumns3,
307 Projection, ScalarColumn, VisualLayer, VisualPrimitive, VisualScene, VisualStyle,
308 };
309
310 use super::{ViewerState, ViewportSize};
311
312 fn camera() -> Camera {
313 Camera::try_new(
314 Vec3::new(0.0, 0.0, 5.0),
315 Vec3::new(0.0, 0.0, 0.0),
316 Vec3::new(0.0, 1.0, 0.0),
317 Projection::Perspective { vertical_fov_radians: 1.0, near: 0.1, far: 100.0 },
318 )
319 .unwrap()
320 }
321
322 #[test]
323 fn scene_sync_preserves_edits_and_inspects_attributes() {
324 let x = [0.0, 1.0];
325 let y = [0.0, 1.0];
326 let z = [0.0, 1.0];
327 let scalar_values = [4.0, 8.0];
328 let positions = PositionColumns3::try_new(&x, &y, &z).unwrap();
329 let points = PointCloudView::positions_only(positions)
330 .with_scalar(ScalarColumn::try_new("intensity", &scalar_values, 2).unwrap())
331 .unwrap();
332 let id = LayerId::try_new("cloud").unwrap();
333 let layer = VisualLayer::try_new(
334 id.clone(),
335 "Cloud",
336 VisualPrimitive::Points(points),
337 VisualStyle::Points(
338 PointStyle::try_new(
339 2.0,
340 PointColor::Scalar {
341 min: 0.0,
342 max: 10.0,
343 map: spatialrust_viz::ColorMap::Viridis,
344 },
345 )
346 .unwrap(),
347 ),
348 )
349 .unwrap();
350 let mut scene = VisualScene::new();
351 scene.add_layer(layer).unwrap();
352
353 let mut state =
354 ViewerState::try_new(camera(), ViewportSize::try_new(800, 600).unwrap()).unwrap();
355 state.sync_scene(&scene);
356 state.set_layer_visible(&id, false).unwrap();
357 state.select_layer(Some(&id)).unwrap();
358 assert_eq!(state.inspector().unwrap().active_attribute, Some("intensity"));
359
360 state.sync_scene(&scene);
361 assert!(!state.layers[0].visible);
362 assert_eq!(state.layers[0].element_count, 2);
363 assert_eq!(state.layers[0].attributes.len(), 2);
364
365 assert!(state
366 .set_layer_style(
367 &id,
368 VisualStyle::Points(PointStyle::try_new(1.0, PointColor::Rgb).unwrap())
369 )
370 .is_err());
371 state.set_layer_style(&id, VisualStyle::Uniform(LinearRgba::WHITE)).unwrap();
372 }
373
374 #[test]
375 fn validates_viewport_selection_and_drop_extensions() {
376 assert!(ViewportSize::try_new(0, 1).is_err());
377 let mut state =
378 ViewerState::try_new(camera(), ViewportSize::try_new(1, 1).unwrap()).unwrap();
379 assert!(state.select_layer(Some(&LayerId::try_new("missing").unwrap())).is_err());
380 assert!(state.queue_dropped_file("scan.txt").is_err());
381 state.queue_dropped_file("SCAN.PCD").unwrap();
382 assert_eq!(state.pending_files, ["SCAN.PCD"]);
383 }
384}