Skip to main content

spatialrust_viewer/
studio.rs

1//! Portable state for the Spatial Studio multi-panel surface.
2//!
3//! This module intentionally contains no ROS, SQLite, renderer, or GPU types.
4//! Adapters may populate the state from receipts, while native and Web
5//! frontends can render the same source-bound admission decision.
6
7use std::collections::BTreeSet;
8
9use spatialrust_viz::LayerId;
10
11use crate::{ViewerError, ViewerResult, ViewerState};
12
13/// Current serialized Spatial Studio state schema version.
14pub const STUDIO_STATE_VERSION: u32 = 1;
15
16/// Checksummed identity of the source represented by a Studio session.
17#[derive(Clone, Debug, PartialEq, Eq)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
20pub struct StudioSource {
21    /// User-facing source label.
22    pub label: String,
23    /// Source path or URI shown in the Studio header.
24    pub path: String,
25    /// SHA-256 expected by the operation contract.
26    pub expected_sha256: String,
27    /// SHA-256 observed while opening the source.
28    pub observed_sha256: String,
29    /// Whether the expected and observed identities are exactly equal.
30    pub identity_matches: bool,
31}
32
33impl StudioSource {
34    /// Creates a source identity and rejects inconsistent checksum claims.
35    pub fn try_new(
36        label: impl Into<String>,
37        path: impl Into<String>,
38        expected_sha256: impl Into<String>,
39        observed_sha256: impl Into<String>,
40        identity_matches: bool,
41    ) -> ViewerResult<Self> {
42        let source = Self {
43            label: label.into(),
44            path: path.into(),
45            expected_sha256: expected_sha256.into(),
46            observed_sha256: observed_sha256.into(),
47            identity_matches,
48        };
49        source.validate()?;
50        Ok(source)
51    }
52
53    /// Validates source labels, paths, and the identity equality invariant.
54    pub fn validate(&self) -> ViewerResult<()> {
55        if self.label.trim().is_empty() || self.path.trim().is_empty() {
56            return Err(ViewerError::InvalidState(
57                "Studio source label and path must not be empty".into(),
58            ));
59        }
60        validate_sha256("expected source", &self.expected_sha256)?;
61        validate_sha256("observed source", &self.observed_sha256)?;
62        let calculated_match = self.expected_sha256 == self.observed_sha256;
63        if self.identity_matches != calculated_match {
64            return Err(ViewerError::InvalidState(
65                "Studio source identity_matches disagrees with the checksums".into(),
66            ));
67        }
68        Ok(())
69    }
70}
71
72/// Metadata for one point-cloud or derived layer in the Studio panel.
73#[derive(Clone, Debug, PartialEq, Eq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
76pub struct StudioLayer {
77    /// Stable layer identifier shared with renderer controls.
78    pub id: String,
79    /// User-facing display label.
80    pub label: String,
81    /// Logical layer role, such as `point_cloud` or `mesh`.
82    pub role: String,
83    /// Source topic or artifact identifier.
84    pub topic: String,
85    /// Observed frame ID, when the source receipt supplied one.
86    pub frame_id: Option<String>,
87    /// Number of messages represented by the layer.
88    pub message_count: u64,
89    /// Number of points represented by the layer receipt.
90    pub point_count: u64,
91    /// Whether geometry is available to a renderer.
92    pub renderable: bool,
93    /// Current panel visibility.
94    pub visible: bool,
95}
96
97impl StudioLayer {
98    /// Creates and validates one Studio layer descriptor.
99    #[allow(clippy::too_many_arguments)]
100    pub fn try_new(
101        id: impl Into<String>,
102        label: impl Into<String>,
103        role: impl Into<String>,
104        topic: impl Into<String>,
105        frame_id: Option<String>,
106        message_count: u64,
107        point_count: u64,
108        renderable: bool,
109        visible: bool,
110    ) -> ViewerResult<Self> {
111        let layer = Self {
112            id: id.into(),
113            label: label.into(),
114            role: role.into(),
115            topic: topic.into(),
116            frame_id,
117            message_count,
118            point_count,
119            renderable,
120            visible,
121        };
122        layer.validate()?;
123        Ok(layer)
124    }
125
126    /// Validates layer identity and receipt-derived counts.
127    pub fn validate(&self) -> ViewerResult<()> {
128        LayerId::try_new(self.id.clone())?;
129        if self.label.trim().is_empty()
130            || self.role.trim().is_empty()
131            || self.topic.trim().is_empty()
132        {
133            return Err(ViewerError::InvalidState(
134                "Studio layer label, role, and topic must not be empty".into(),
135            ));
136        }
137        if self.renderable && self.point_count == 0 {
138            return Err(ViewerError::InvalidState(format!(
139                "renderable Studio layer `{}` has no points",
140                self.id
141            )));
142        }
143        if let Some(frame_id) = &self.frame_id {
144            if frame_id.trim().is_empty() {
145                return Err(ViewerError::InvalidState(format!(
146                    "Studio layer `{}` has an empty frame ID",
147                    self.id
148                )));
149            }
150        }
151        Ok(())
152    }
153}
154
155/// Bounded timeline information shown by the Studio scrubber.
156#[derive(Clone, Debug, PartialEq, Eq)]
157#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
158#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
159pub struct StudioTimeline {
160    /// First timestamp in the source, when metadata is available.
161    pub start_nanos: Option<u64>,
162    /// Last timestamp bound in the source, when metadata is available.
163    pub end_nanos: Option<u64>,
164    /// Current scrubber position, when metadata is available.
165    pub cursor_nanos: Option<u64>,
166    /// Number of source samples represented by the session.
167    pub sample_count: u64,
168    /// Human-readable timestamp basis.
169    pub time_basis: String,
170    /// Whether an external clock calibration was applied.
171    pub clock_calibrated: bool,
172}
173
174impl StudioTimeline {
175    /// Creates a timeline and enforces complete timestamp bounds.
176    pub fn try_new(
177        start_nanos: Option<u64>,
178        end_nanos: Option<u64>,
179        cursor_nanos: Option<u64>,
180        sample_count: u64,
181        time_basis: impl Into<String>,
182        clock_calibrated: bool,
183    ) -> ViewerResult<Self> {
184        let timeline = Self {
185            start_nanos,
186            end_nanos,
187            cursor_nanos,
188            sample_count,
189            time_basis: time_basis.into(),
190            clock_calibrated,
191        };
192        timeline.validate()?;
193        Ok(timeline)
194    }
195
196    /// Validates timeline ordering and the unavailable-metadata representation.
197    pub fn validate(&self) -> ViewerResult<()> {
198        if self.time_basis.trim().is_empty() {
199            return Err(ViewerError::InvalidState("Studio timeline time basis is empty".into()));
200        }
201        match (self.start_nanos, self.end_nanos, self.cursor_nanos) {
202            (Some(start), Some(end), Some(cursor)) => {
203                if end < start || cursor < start || cursor > end || self.sample_count == 0 {
204                    return Err(ViewerError::InvalidState(
205                        "Studio timeline bounds or sample count are invalid".into(),
206                    ));
207                }
208            }
209            (None, None, None) => {
210                if self.sample_count != 0 || self.clock_calibrated {
211                    return Err(ViewerError::InvalidState(
212                        "unavailable Studio timeline cannot contain samples or calibrated time"
213                            .into(),
214                    ));
215                }
216            }
217            _ => {
218                return Err(ViewerError::InvalidState(
219                    "Studio timeline timestamps must be all present or all absent".into(),
220                ));
221            }
222        }
223        Ok(())
224    }
225}
226
227/// Calibration admission state displayed by the Studio gate panel.
228#[derive(Clone, Debug, PartialEq, Eq)]
229#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
230#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
231pub struct StudioCalibration {
232    /// Whether both required calibration artifacts are registered and ready.
233    pub registration_ready: bool,
234    /// Clock-artifact status, for example `registered` or `not_registered`.
235    pub clock_status: String,
236    /// Frame-artifact status, for example `registered` or `not_registered`.
237    pub frame_status: String,
238    /// Whether the calibration evidence is bound to the displayed source.
239    pub source_bound: bool,
240    /// Fail-closed reasons preventing calibration admission.
241    pub blockers: Vec<String>,
242}
243
244impl StudioCalibration {
245    /// Creates and validates a calibration gate state.
246    pub fn try_new(
247        registration_ready: bool,
248        clock_status: impl Into<String>,
249        frame_status: impl Into<String>,
250        source_bound: bool,
251        blockers: Vec<String>,
252    ) -> ViewerResult<Self> {
253        let calibration = Self {
254            registration_ready,
255            clock_status: clock_status.into(),
256            frame_status: frame_status.into(),
257            source_bound,
258            blockers,
259        };
260        calibration.validate()?;
261        Ok(calibration)
262    }
263
264    /// Validates that ready state has no unresolved blockers.
265    pub fn validate(&self) -> ViewerResult<()> {
266        if self.clock_status.trim().is_empty() || self.frame_status.trim().is_empty() {
267            return Err(ViewerError::InvalidState(
268                "Studio calibration statuses must not be empty".into(),
269            ));
270        }
271        if self.registration_ready {
272            if !self.source_bound || !self.blockers.is_empty() {
273                return Err(ViewerError::InvalidState(
274                    "ready Studio calibration must be source-bound and blocker-free".into(),
275                ));
276            }
277            if self.clock_status != "registered" || self.frame_status != "registered" {
278                return Err(ViewerError::InvalidState(
279                    "ready Studio calibration requires registered clock and frame artifacts".into(),
280                ));
281            }
282        } else if self.blockers.is_empty() {
283            return Err(ViewerError::InvalidState(
284                "blocked Studio calibration must expose at least one blocker".into(),
285            ));
286        }
287        Ok(())
288    }
289}
290
291/// Source-bound frame inventory and composition status.
292#[derive(Clone, Debug, PartialEq, Eq)]
293#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
294#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
295pub struct StudioFrameGraph {
296    /// Frame IDs accepted from the source-bound inventory.
297    pub observed_frames: Vec<String>,
298    /// Number of accepted transform edges.
299    pub edge_count: u64,
300    /// Root selected by a composition operation, when any.
301    pub root_frame: Option<String>,
302    /// Whether transforms have been composed for downstream use.
303    pub composed: bool,
304    /// Whether the inventory belongs to the Studio source identity.
305    pub source_bound: bool,
306}
307
308impl StudioFrameGraph {
309    /// Creates and validates a non-composing or source-bound composed graph.
310    pub fn try_new(
311        observed_frames: Vec<String>,
312        edge_count: u64,
313        root_frame: Option<String>,
314        composed: bool,
315        source_bound: bool,
316    ) -> ViewerResult<Self> {
317        let graph = Self { observed_frames, edge_count, root_frame, composed, source_bound };
318        graph.validate()?;
319        Ok(graph)
320    }
321
322    /// Validates frame uniqueness and rejects unbound transform evidence.
323    pub fn validate(&self) -> ViewerResult<()> {
324        let mut frames = BTreeSet::new();
325        for frame in &self.observed_frames {
326            if frame.trim().is_empty() || !frames.insert(frame) {
327                return Err(ViewerError::InvalidState(
328                    "Studio frame inventory contains an empty or duplicate frame".into(),
329                ));
330            }
331        }
332        if !self.source_bound && (!self.observed_frames.is_empty() || self.edge_count != 0) {
333            return Err(ViewerError::InvalidState(
334                "unbound Studio frame evidence cannot be accepted into the graph".into(),
335            ));
336        }
337        if self.composed
338            && (!self.source_bound || self.edge_count == 0 || self.root_frame.is_none())
339        {
340            return Err(ViewerError::InvalidState(
341                "composed Studio frame graph requires source-bound edges and a root".into(),
342            ));
343        }
344        if let Some(root) = &self.root_frame {
345            if root.trim().is_empty() || !frames.contains(root) {
346                return Err(ViewerError::InvalidState(
347                    "Studio frame graph root must be one of the observed frames".into(),
348                ));
349            }
350        }
351        Ok(())
352    }
353}
354
355/// One measured pipeline stage in the Studio metrics panel.
356#[derive(Clone, Debug, PartialEq, Eq)]
357#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
358#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
359pub struct StudioStageMetric {
360    /// Stable stage name.
361    pub name: String,
362    /// Observed wall-clock duration in nanoseconds.
363    pub wall_ns: u64,
364}
365
366/// Explicit memory and transfer counters for a Studio pipeline.
367#[derive(Clone, Debug, PartialEq, Eq)]
368#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
369#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
370pub struct StudioPerformance {
371    /// Sum or receipt-reported wall-clock duration in nanoseconds.
372    pub observed_pipeline_wall_ns: u64,
373    /// Ordered per-stage timings.
374    pub stages: Vec<StudioStageMetric>,
375    /// Largest observed source allocation in bytes.
376    pub peak_source_bytes: u64,
377    /// Host-to-device bytes explicitly transferred.
378    pub host_to_device_bytes: u64,
379    /// Device-to-host bytes explicitly transferred.
380    pub device_to_host_bytes: u64,
381    /// Copies that were not attributed to an explicit transfer boundary.
382    pub hidden_device_copies: u64,
383}
384
385impl StudioPerformance {
386    /// Creates and validates explicit stage metrics.
387    pub fn try_new(
388        observed_pipeline_wall_ns: u64,
389        stages: Vec<StudioStageMetric>,
390        peak_source_bytes: u64,
391        host_to_device_bytes: u64,
392        device_to_host_bytes: u64,
393        hidden_device_copies: u64,
394    ) -> ViewerResult<Self> {
395        let performance = Self {
396            observed_pipeline_wall_ns,
397            stages,
398            peak_source_bytes,
399            host_to_device_bytes,
400            device_to_host_bytes,
401            hidden_device_copies,
402        };
403        performance.validate()?;
404        Ok(performance)
405    }
406
407    /// Validates unique, named performance stages.
408    pub fn validate(&self) -> ViewerResult<()> {
409        let mut names = BTreeSet::new();
410        for stage in &self.stages {
411            if stage.name.trim().is_empty() || !names.insert(&stage.name) {
412                return Err(ViewerError::InvalidState(
413                    "Studio performance stages must have unique names".into(),
414                ));
415            }
416        }
417        Ok(())
418    }
419}
420
421/// One portable state snapshot for the Spatial Studio surface.
422#[derive(Clone, Debug, PartialEq)]
423#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
424#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
425pub struct StudioState {
426    /// Serialized state schema version.
427    pub version: u32,
428    /// User-facing Studio title.
429    pub title: String,
430    /// Camera, viewport, and renderer-independent controls.
431    pub viewer: ViewerState,
432    /// Checksummed input identity.
433    pub source: StudioSource,
434    /// Point-cloud and derived layer metadata.
435    pub layers: Vec<StudioLayer>,
436    /// Timeline and timestamp basis.
437    pub timeline: StudioTimeline,
438    /// Calibration admission panel state.
439    pub calibration: StudioCalibration,
440    /// Source-bound TF inventory/composition state.
441    pub frame_graph: StudioFrameGraph,
442    /// Pipeline performance and transfer metrics.
443    pub performance: StudioPerformance,
444    /// Aggregated fail-closed reasons shown at the top level.
445    pub blockers: Vec<String>,
446    /// Whether downstream mapping is admitted by every required gate.
447    pub mapping_admitted: bool,
448}
449
450impl StudioState {
451    /// Creates a Studio state and derives its mapping admission decision.
452    #[allow(clippy::too_many_arguments)]
453    pub fn try_new(
454        title: impl Into<String>,
455        viewer: ViewerState,
456        source: StudioSource,
457        layers: Vec<StudioLayer>,
458        timeline: StudioTimeline,
459        calibration: StudioCalibration,
460        frame_graph: StudioFrameGraph,
461        performance: StudioPerformance,
462        blockers: Vec<String>,
463    ) -> ViewerResult<Self> {
464        let mapping_admitted = source.identity_matches
465            && calibration.registration_ready
466            && calibration.source_bound
467            && frame_graph.source_bound
468            && frame_graph.composed
469            && performance.hidden_device_copies == 0;
470        let state = Self {
471            version: STUDIO_STATE_VERSION,
472            title: title.into(),
473            viewer,
474            source,
475            layers,
476            timeline,
477            calibration,
478            frame_graph,
479            performance,
480            blockers,
481            mapping_admitted,
482        };
483        state.validate()?;
484        Ok(state)
485    }
486
487    /// Validates every panel and the cross-panel fail-closed admission rules.
488    pub fn validate(&self) -> ViewerResult<()> {
489        if self.version != STUDIO_STATE_VERSION {
490            return Err(ViewerError::InvalidState(format!(
491                "unsupported Spatial Studio state version {}",
492                self.version
493            )));
494        }
495        if self.title.trim().is_empty() {
496            return Err(ViewerError::InvalidState("Studio title must not be empty".into()));
497        }
498        self.viewer.validate()?;
499        self.source.validate()?;
500        let mut ids = BTreeSet::new();
501        for layer in &self.layers {
502            layer.validate()?;
503            if !ids.insert(&layer.id) {
504                return Err(ViewerError::InvalidState(format!(
505                    "duplicate Studio layer `{}`",
506                    layer.id
507                )));
508            }
509        }
510        self.timeline.validate()?;
511        self.calibration.validate()?;
512        self.frame_graph.validate()?;
513        self.performance.validate()?;
514
515        let calculated_admission = self.source.identity_matches
516            && self.calibration.registration_ready
517            && self.calibration.source_bound
518            && self.frame_graph.source_bound
519            && self.frame_graph.composed
520            && self.performance.hidden_device_copies == 0;
521        if self.mapping_admitted != calculated_admission {
522            return Err(ViewerError::InvalidState(
523                "Studio mapping_admitted disagrees with its source/calibration/frame/performance gates"
524                    .into(),
525            ));
526        }
527        if self.mapping_admitted && !self.blockers.is_empty() {
528            return Err(ViewerError::InvalidState(
529                "admitted Studio mapping cannot contain blockers".into(),
530            ));
531        }
532        if !self.mapping_admitted && self.blockers.is_empty() {
533            return Err(ViewerError::InvalidState(
534                "blocked Studio mapping must expose at least one blocker".into(),
535            ));
536        }
537        if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
538            return Err(ViewerError::InvalidState(
539                "Studio blockers must not contain empty messages".into(),
540            ));
541        }
542        Ok(())
543    }
544}
545
546fn validate_sha256(label: &str, value: &str) -> ViewerResult<()> {
547    if value.len() != 64
548        || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
549        || value.bytes().any(|byte| byte.is_ascii_uppercase())
550    {
551        return Err(ViewerError::InvalidState(format!(
552            "{label} SHA-256 must be 64 lowercase hexadecimal characters"
553        )));
554    }
555    Ok(())
556}
557
558#[cfg(test)]
559mod tests {
560    use spatialrust_math::Vec3;
561    use spatialrust_viz::{Camera, Projection};
562
563    use super::*;
564
565    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
566
567    fn viewer() -> ViewerState {
568        ViewerState::try_new(
569            Camera::try_new(
570                Vec3::new(0.0, 0.0, 5.0),
571                Vec3::new(0.0, 0.0, 0.0),
572                Vec3::new(0.0, 1.0, 0.0),
573                Projection::Perspective { vertical_fov_radians: 1.0, near: 0.1, far: 100.0 },
574            )
575            .unwrap(),
576            crate::ViewportSize::try_new(1280, 720).unwrap(),
577        )
578        .unwrap()
579    }
580
581    fn blocked_state() -> StudioState {
582        StudioState::try_new(
583            "Studio test",
584            viewer(),
585            StudioSource::try_new("bag", "/tmp/bag.db3", SHA, SHA, true).unwrap(),
586            vec![StudioLayer::try_new(
587                "front",
588                "Front lidar",
589                "point_cloud",
590                "/lidar_front/points_raw",
591                Some("lidar_front".into()),
592                2,
593                4,
594                true,
595                true,
596            )
597            .unwrap()],
598            StudioTimeline::try_new(Some(10), Some(20), Some(10), 2, "header stamp", false)
599                .unwrap(),
600            StudioCalibration::try_new(
601                false,
602                "not_registered",
603                "not_registered",
604                false,
605                vec!["calibration is missing".into()],
606            )
607            .unwrap(),
608            StudioFrameGraph::try_new(Vec::new(), 0, None, false, false).unwrap(),
609            StudioPerformance::try_new(10, Vec::new(), 20, 0, 0, 0).unwrap(),
610            vec!["calibration is missing".into()],
611        )
612        .unwrap()
613    }
614
615    #[test]
616    fn blocked_state_roundtrips_when_serde_is_enabled() {
617        let state = blocked_state();
618        assert!(!state.mapping_admitted);
619        assert!(state.validate().is_ok());
620        #[cfg(feature = "serde")]
621        {
622            let json = serde_json::to_string(&state).unwrap();
623            let decoded: StudioState = serde_json::from_str(&json).unwrap();
624            assert_eq!(decoded, state);
625        }
626    }
627
628    #[cfg(feature = "serde")]
629    #[test]
630    fn studio_json_rejects_unknown_fields() {
631        let state = blocked_state();
632        let mut value: serde_json::Value = serde_json::to_value(state).unwrap();
633        value["unexpected"] = serde_json::json!(true);
634        assert!(serde_json::from_value::<StudioState>(value).is_err());
635    }
636
637    #[test]
638    fn source_mismatch_cannot_be_hidden_by_mapping_admission() {
639        let mut state = blocked_state();
640        state.source.observed_sha256 =
641            "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210".into();
642        state.source.identity_matches = false;
643        state.mapping_admitted = true;
644        assert!(state.validate().is_err());
645    }
646
647    #[test]
648    fn partial_timeline_and_unbound_frames_fail_closed() {
649        assert!(StudioTimeline::try_new(Some(1), None, Some(1), 1, "stamp", false).is_err());
650        assert!(StudioFrameGraph::try_new(vec!["map".into()], 1, None, false, false).is_err());
651    }
652}