Skip to main content

spatialrust_viewer/
state.rs

1use std::collections::BTreeSet;
2
3use spatialrust_viz::{Camera, LayerId, PointColor, VisualPrimitive, VisualScene, VisualStyle};
4
5use crate::{ViewerError, ViewerResult};
6
7/// Current serialized viewer-state schema version.
8pub const VIEWER_STATE_VERSION: u32 = 1;
9
10/// Positive viewport dimensions in logical pixels.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub struct ViewportSize {
14    /// Logical width.
15    pub width: u32,
16    /// Logical height.
17    pub height: u32,
18}
19
20impl ViewportSize {
21    /// Creates validated non-zero viewport dimensions.
22    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    /// Width divided by height.
30    #[must_use]
31    pub fn aspect(self) -> f32 {
32        self.width as f32 / self.height as f32
33    }
34}
35
36/// One attribute exposed by the point attribute inspector.
37#[derive(Clone, Debug, PartialEq, Eq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub struct AttributeSummary {
40    /// Stable attribute name.
41    pub name: String,
42    /// Scalar representation displayed to the user.
43    pub data_type: String,
44    /// Number of values.
45    pub len: usize,
46}
47
48/// Owned presentation state for one borrowed scene layer.
49#[derive(Clone, Debug, PartialEq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51pub struct LayerPresentation {
52    /// Stable layer identity.
53    pub id: LayerId,
54    /// User-facing layer name.
55    pub label: String,
56    /// Current visibility.
57    pub visible: bool,
58    /// Editable visual style.
59    pub style: VisualStyle,
60    /// Number of logical primitives or points.
61    pub element_count: usize,
62    /// Attributes available for inspection.
63    pub attributes: Vec<AttributeSummary>,
64}
65
66/// Current selection exposed by the inspector panel.
67#[derive(Clone, Debug, PartialEq)]
68pub struct InspectorSelection<'a> {
69    /// Selected layer.
70    pub layer: &'a LayerPresentation,
71    /// Active point-color attribute, when any.
72    pub active_attribute: Option<&'a str>,
73}
74
75/// Portable state shared by native, Web, Python, and notebook surfaces.
76#[derive(Clone, Debug, PartialEq)]
77#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78pub struct ViewerState {
79    /// Schema version, currently [`VIEWER_STATE_VERSION`].
80    pub version: u32,
81    /// Active camera.
82    pub camera: Camera,
83    /// Logical viewport dimensions.
84    pub viewport: ViewportSize,
85    /// Ordered layer presentation state.
86    pub layers: Vec<LayerPresentation>,
87    /// Selected layer identity.
88    pub selected_layer: Option<LayerId>,
89    /// Validated data files queued by native drag/drop.
90    pub pending_files: Vec<String>,
91}
92
93impl ViewerState {
94    /// Creates an empty viewer state.
95    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    /// Validates the complete portable state before it is handed to a
110    /// renderer, serialized, or embedded in a larger Studio state.
111    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    /// Synchronizes layer metadata from a borrowed visual scene.
151    ///
152    /// Existing visibility and style edits are retained for matching stable IDs.
153    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    /// Changes a layer's visibility.
178    pub fn set_layer_visible(&mut self, id: &LayerId, visible: bool) -> ViewerResult<()> {
179        self.layer_mut(id)?.visible = visible;
180        Ok(())
181    }
182
183    /// Selects a layer for attribute inspection.
184    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    /// Replaces the visual style after validating attribute compatibility.
197    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    /// Returns the selected layer and active point attribute.
205    #[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    /// Queues a supported point-cloud file from drag/drop.
225    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}