1use std::collections::BTreeSet;
8
9use spatialrust_viz::LayerId;
10
11use crate::{ViewerError, ViewerResult, ViewerState};
12
13pub const STUDIO_STATE_VERSION: u32 = 1;
15
16#[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 pub label: String,
23 pub path: String,
25 pub expected_sha256: String,
27 pub observed_sha256: String,
29 pub identity_matches: bool,
31}
32
33impl StudioSource {
34 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 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#[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 pub id: String,
79 pub label: String,
81 pub role: String,
83 pub topic: String,
85 pub frame_id: Option<String>,
87 pub message_count: u64,
89 pub point_count: u64,
91 pub renderable: bool,
93 pub visible: bool,
95}
96
97impl StudioLayer {
98 #[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 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#[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 pub start_nanos: Option<u64>,
162 pub end_nanos: Option<u64>,
164 pub cursor_nanos: Option<u64>,
166 pub sample_count: u64,
168 pub time_basis: String,
170 pub clock_calibrated: bool,
172}
173
174impl StudioTimeline {
175 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 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#[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 pub registration_ready: bool,
234 pub clock_status: String,
236 pub frame_status: String,
238 pub source_bound: bool,
240 pub blockers: Vec<String>,
242}
243
244impl StudioCalibration {
245 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 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#[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 pub observed_frames: Vec<String>,
298 pub edge_count: u64,
300 pub root_frame: Option<String>,
302 pub composed: bool,
304 pub source_bound: bool,
306}
307
308impl StudioFrameGraph {
309 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 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#[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 pub name: String,
362 pub wall_ns: u64,
364}
365
366#[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 pub observed_pipeline_wall_ns: u64,
373 pub stages: Vec<StudioStageMetric>,
375 pub peak_source_bytes: u64,
377 pub host_to_device_bytes: u64,
379 pub device_to_host_bytes: u64,
381 pub hidden_device_copies: u64,
383}
384
385impl StudioPerformance {
386 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 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#[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 pub version: u32,
428 pub title: String,
430 pub viewer: ViewerState,
432 pub source: StudioSource,
434 pub layers: Vec<StudioLayer>,
436 pub timeline: StudioTimeline,
438 pub calibration: StudioCalibration,
440 pub frame_graph: StudioFrameGraph,
442 pub performance: StudioPerformance,
444 pub blockers: Vec<String>,
446 pub mapping_admitted: bool,
448}
449
450impl StudioState {
451 #[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 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}