Skip to main content

spatialrust_web/
state.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4use spatialrust_viewer::{InputAction, ViewerController, ViewerState, ViewportSize};
5use spatialrust_viz::{Camera, LayerId};
6
7use crate::{WebError, WebResult};
8
9/// Current portable Web viewer envelope version.
10pub const WEB_VIEWER_STATE_VERSION: u32 = 1;
11
12/// Versioned viewer state shared by native Rust, WASM, Python, and notebooks.
13#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct WebViewerState {
16    /// Web envelope version.
17    pub version: u32,
18    /// Monotonic state revision.
19    pub revision: u64,
20    /// Portable viewer state.
21    pub viewer: ViewerState,
22}
23
24impl WebViewerState {
25    /// Wraps and validates a viewer state.
26    pub fn try_new(viewer: ViewerState) -> WebResult<Self> {
27        let state = Self { version: WEB_VIEWER_STATE_VERSION, revision: 0, viewer };
28        state.validate()?;
29        Ok(state)
30    }
31
32    /// Strict JSON serialization.
33    pub fn to_json(&self) -> WebResult<String> {
34        self.validate()?;
35        serde_json::to_string(self).map_err(|error| WebError::InvalidState(error.to_string()))
36    }
37
38    /// Strict JSON deserialization with schema and invariant validation.
39    pub fn from_json(json: &str) -> WebResult<Self> {
40        let state: Self = serde_json::from_str(json)
41            .map_err(|error| WebError::InvalidState(error.to_string()))?;
42        state.validate()?;
43        Ok(state)
44    }
45
46    /// Applies one portable browser input and increments the revision exactly once.
47    pub fn apply(&mut self, input: BrowserInput) -> WebResult<()> {
48        self.validate()?;
49        let action = input.into_action()?;
50        let next_revision = self
51            .revision
52            .checked_add(1)
53            .ok_or_else(|| WebError::InvalidState("Web viewer revision overflow".into()))?;
54        let mut next_viewer = self.viewer.clone();
55        ViewerController::default()
56            .apply(&mut next_viewer, action)
57            .map_err(|error| WebError::Viewer(error.to_string()))?;
58        self.viewer = next_viewer;
59        self.revision = next_revision;
60        Ok(())
61    }
62
63    /// Validates versions, camera, viewport, unique layers, and selection.
64    pub fn validate(&self) -> WebResult<()> {
65        if self.version != WEB_VIEWER_STATE_VERSION {
66            return Err(WebError::InvalidState(format!(
67                "unsupported Web viewer state version {}",
68                self.version
69            )));
70        }
71        Camera::try_new(
72            self.viewer.camera.eye,
73            self.viewer.camera.target,
74            self.viewer.camera.up,
75            self.viewer.camera.projection,
76        )
77        .map_err(|error| WebError::InvalidState(error.to_string()))?;
78        ViewportSize::try_new(self.viewer.viewport.width, self.viewer.viewport.height)
79            .map_err(|error| WebError::InvalidState(error.to_string()))?;
80        let mut ids = BTreeSet::new();
81        for layer in &self.viewer.layers {
82            if !ids.insert(layer.id.as_str()) {
83                return Err(WebError::InvalidState(format!(
84                    "duplicate layer `{}`",
85                    layer.id.as_str()
86                )));
87            }
88        }
89        if let Some(selected) = &self.viewer.selected_layer {
90            if !ids.contains(selected.as_str()) {
91                return Err(WebError::InvalidState(
92                    "selected layer is absent from portable layers".into(),
93                ));
94            }
95        }
96        Ok(())
97    }
98}
99
100/// JSON-friendly browser interaction event.
101#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
102#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
103pub enum BrowserInput {
104    /// Orbit by logical-pixel deltas.
105    Orbit {
106        /// Horizontal delta.
107        delta_x: f32,
108        /// Vertical delta.
109        delta_y: f32,
110    },
111    /// Pan by logical-pixel deltas.
112    Pan {
113        /// Horizontal delta.
114        delta_x: f32,
115        /// Vertical delta.
116        delta_y: f32,
117    },
118    /// Signed wheel/gesture zoom delta.
119    Zoom {
120        /// Zoom delta.
121        delta: f32,
122    },
123    /// Resize the logical viewport.
124    Resize {
125        /// Width.
126        width: u32,
127        /// Height.
128        height: u32,
129    },
130    /// Toggle one stable layer.
131    ToggleLayer {
132        /// Layer identity.
133        layer_id: String,
134    },
135    /// Select or clear a layer.
136    SelectLayer {
137        /// Optional layer identity.
138        layer_id: Option<String>,
139    },
140    /// Queue a dropped file name/path.
141    DropFile {
142        /// Browser-provided name/path.
143        path: String,
144    },
145}
146
147impl BrowserInput {
148    fn into_action(self) -> WebResult<InputAction> {
149        Ok(match self {
150            Self::Orbit { delta_x, delta_y } => InputAction::Orbit { delta_x, delta_y },
151            Self::Pan { delta_x, delta_y } => InputAction::Pan { delta_x, delta_y },
152            Self::Zoom { delta } => InputAction::Zoom(delta),
153            Self::Resize { width, height } => InputAction::Resize(
154                ViewportSize::try_new(width, height)
155                    .map_err(|error| WebError::Viewer(error.to_string()))?,
156            ),
157            Self::ToggleLayer { layer_id } => InputAction::ToggleLayer(
158                LayerId::try_new(layer_id).map_err(|error| WebError::Viewer(error.to_string()))?,
159            ),
160            Self::SelectLayer { layer_id } => InputAction::SelectLayer(
161                layer_id
162                    .map(LayerId::try_new)
163                    .transpose()
164                    .map_err(|error| WebError::Viewer(error.to_string()))?,
165            ),
166            Self::DropFile { path } => InputAction::FileDropped(path),
167        })
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use spatialrust_math::Vec3;
174    use spatialrust_viewer::{ViewerState, ViewportSize};
175    use spatialrust_viz::{Camera, Projection};
176
177    use super::{BrowserInput, WebViewerState};
178
179    fn state() -> WebViewerState {
180        WebViewerState::try_new(
181            ViewerState::try_new(
182                Camera::try_new(
183                    Vec3::new(0.0, 0.0, 5.0),
184                    Vec3::new(0.0, 0.0, 0.0),
185                    Vec3::new(0.0, 1.0, 0.0),
186                    Projection::Perspective { vertical_fov_radians: 1.0, near: 0.1, far: 100.0 },
187                )
188                .unwrap(),
189                ViewportSize::try_new(800, 600).unwrap(),
190            )
191            .unwrap(),
192        )
193        .unwrap()
194    }
195
196    #[test]
197    fn state_json_roundtrip_and_input_revision_are_exact() {
198        let mut state = state();
199        let json = state.to_json().unwrap();
200        assert_eq!(WebViewerState::from_json(&json).unwrap(), state);
201        state.apply(BrowserInput::Zoom { delta: 1.0 }).unwrap();
202        state.apply(BrowserInput::Resize { width: 1920, height: 1080 }).unwrap();
203        assert_eq!(state.revision, 2);
204        assert_eq!(state.viewer.viewport.width, 1920);
205    }
206
207    #[test]
208    fn unknown_fields_versions_and_invalid_input_fail_closed() {
209        let mut value: serde_json::Value =
210            serde_json::from_str(&state().to_json().unwrap()).unwrap();
211        value["extra"] = serde_json::json!(true);
212        assert!(WebViewerState::from_json(&value.to_string()).is_err());
213
214        let mut invalid_state = state();
215        invalid_state.version = 99;
216        assert!(invalid_state.validate().is_err());
217        assert!(invalid_state.apply(BrowserInput::Resize { width: 0, height: 1 }).is_err());
218        assert_eq!(invalid_state.revision, 0);
219
220        let mut state = state();
221        state.revision = u64::MAX;
222        let before = state.viewer.clone();
223        assert!(state.apply(BrowserInput::Zoom { delta: 1.0 }).is_err());
224        assert_eq!(state.viewer, before);
225        assert_eq!(state.revision, u64::MAX);
226    }
227}