Skip to main content

spatialrust_viewer/
digital_twin.rs

1//! Portable source-bound state for a glTF/USD digital twin export.
2//!
3//! The state deliberately models a portable glTF asset plus an ASCII USDA
4//! companion layer. It does not pull an OpenUSD runtime into the stable viewer
5//! crate and it never turns a missing calibration artifact into a transform.
6
7use std::collections::BTreeSet;
8
9use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
10
11/// Current serialized Digital Twin state schema version.
12pub const DIGITAL_TWIN_STATE_VERSION: u32 = 1;
13
14/// One format-specific asset in a Digital Twin bundle.
15#[derive(Clone, Debug, PartialEq, Eq)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
18pub struct DigitalTwinAsset {
19    /// Stable asset identifier.
20    pub id: String,
21    /// Portable format name, currently glTF or USDA.
22    pub format: String,
23    /// Checksummed file receipt for the asset.
24    pub artifact: ReplayArtifact,
25    /// Coordinate frame represented by the asset.
26    pub frame_id: String,
27    /// Number of vertices represented by the asset.
28    pub vertex_count: u64,
29    /// Number of triangles represented by the asset.
30    pub triangle_count: u64,
31    /// Whether the asset preserves the source geometry byte-for-byte or by
32    /// explicit reference.
33    pub identity_preserved: bool,
34    /// Human-readable geometry conversion mode.
35    pub geometry_mode: String,
36}
37
38impl DigitalTwinAsset {
39    /// Creates and validates one Digital Twin asset descriptor.
40    #[allow(clippy::too_many_arguments)]
41    pub fn try_new(
42        id: impl Into<String>,
43        format: impl Into<String>,
44        artifact: ReplayArtifact,
45        frame_id: impl Into<String>,
46        vertex_count: u64,
47        triangle_count: u64,
48        identity_preserved: bool,
49        geometry_mode: impl Into<String>,
50    ) -> ViewerResult<Self> {
51        let asset = Self {
52            id: id.into(),
53            format: format.into(),
54            artifact,
55            frame_id: frame_id.into(),
56            vertex_count,
57            triangle_count,
58            identity_preserved,
59            geometry_mode: geometry_mode.into(),
60        };
61        asset.validate()?;
62        Ok(asset)
63    }
64
65    /// Validates asset identity, counts, frame, and artifact receipt.
66    pub fn validate(&self) -> ViewerResult<()> {
67        if self.id.trim().is_empty()
68            || self.format.trim().is_empty()
69            || self.frame_id.trim().is_empty()
70            || self.geometry_mode.trim().is_empty()
71        {
72            return Err(ViewerError::InvalidState(
73                "Digital Twin assets require an ID, format, frame, and geometry mode".into(),
74            ));
75        }
76        if self.vertex_count == 0 || self.triangle_count == 0 {
77            return Err(ViewerError::InvalidState(
78                "Digital Twin assets require non-empty geometry counts".into(),
79            ));
80        }
81        self.artifact.validate()
82    }
83}
84
85/// Aggregate identity and geometry metrics for a Digital Twin bundle.
86#[derive(Clone, Debug, PartialEq, Eq)]
87#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
88#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
89pub struct DigitalTwinSummary {
90    /// Vertex count in the source mesh.
91    pub source_vertex_count: u64,
92    /// Triangle count in the source mesh.
93    pub source_triangle_count: u64,
94    /// Vertex count in the glTF asset.
95    pub gltf_vertex_count: u64,
96    /// Triangle count in the glTF asset.
97    pub gltf_triangle_count: u64,
98    /// Vertex count represented by the USDA companion layer.
99    pub usd_vertex_count: u64,
100    /// Triangle count represented by the USDA companion layer.
101    pub usd_triangle_count: u64,
102    /// Number of format-specific assets in the bundle.
103    pub asset_count: u64,
104    /// Whether a source-bound semantic overlay is attached.
105    pub semantic_layer_present: bool,
106    /// Whether the canonical source checksum matched.
107    pub source_identity_match: bool,
108    /// Whether the expected and observed frames matched.
109    pub frame_identity_match: bool,
110    /// Whether geometry identity was preserved without an unapproved transform.
111    pub geometry_identity_preserved: bool,
112    /// Whether an explicit calibration transform was applied.
113    pub calibration_applied: bool,
114}
115
116impl DigitalTwinSummary {
117    /// Creates and validates aggregate Digital Twin metrics.
118    #[allow(clippy::too_many_arguments)]
119    pub fn try_new(
120        source_vertex_count: u64,
121        source_triangle_count: u64,
122        gltf_vertex_count: u64,
123        gltf_triangle_count: u64,
124        usd_vertex_count: u64,
125        usd_triangle_count: u64,
126        asset_count: u64,
127        semantic_layer_present: bool,
128        source_identity_match: bool,
129        frame_identity_match: bool,
130        geometry_identity_preserved: bool,
131        calibration_applied: bool,
132    ) -> ViewerResult<Self> {
133        let summary = Self {
134            source_vertex_count,
135            source_triangle_count,
136            gltf_vertex_count,
137            gltf_triangle_count,
138            usd_vertex_count,
139            usd_triangle_count,
140            asset_count,
141            semantic_layer_present,
142            source_identity_match,
143            frame_identity_match,
144            geometry_identity_preserved,
145            calibration_applied,
146        };
147        summary.validate()?;
148        Ok(summary)
149    }
150
151    /// Validates source counts and the no-hidden-transform invariant.
152    pub fn validate(&self) -> ViewerResult<()> {
153        if self.source_vertex_count == 0 || self.source_triangle_count == 0 {
154            return Err(ViewerError::InvalidState(
155                "Digital Twin source geometry counts must be non-zero".into(),
156            ));
157        }
158        if self.geometry_identity_preserved
159            && (self.gltf_vertex_count != self.source_vertex_count
160                || self.gltf_triangle_count != self.source_triangle_count
161                || self.usd_vertex_count != self.source_vertex_count
162                || self.usd_triangle_count != self.source_triangle_count)
163        {
164            return Err(ViewerError::InvalidState(
165                "identity-preserving Digital Twin geometry must retain source counts".into(),
166            ));
167        }
168        if self.calibration_applied && (!self.source_identity_match || !self.frame_identity_match) {
169            return Err(ViewerError::InvalidState(
170                "Digital Twin calibration cannot be applied without source and frame identity"
171                    .into(),
172            ));
173        }
174        Ok(())
175    }
176}
177
178/// Portable, fail-closed state for the Digital Twin export.
179#[derive(Clone, Debug, PartialEq, Eq)]
180#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
181#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
182pub struct DigitalTwinState {
183    /// Serialized Digital Twin state schema version.
184    pub version: u32,
185    /// User-facing dashboard title.
186    pub title: String,
187    /// Checksummed canonical source identity.
188    pub source: StudioSource,
189    /// Observed frame represented by the bundle.
190    pub frame_id: String,
191    /// Frame required by the operation contract.
192    pub expected_frame_id: String,
193    /// Human-readable timestamp basis from the source receipt.
194    pub time_basis: String,
195    /// glTF and USDA asset descriptors.
196    pub assets: Vec<DigitalTwinAsset>,
197    /// Optional source-bound semantic overlay artifact.
198    pub semantic_layer: Option<ReplayArtifact>,
199    /// Aggregate geometry and admission metrics.
200    pub summary: DigitalTwinSummary,
201    /// Whether a portable Digital Twin bundle is ready.
202    pub twin_ready: bool,
203    /// Whether downstream mapping is admitted.
204    pub mapping_admitted: bool,
205    /// Fail-closed reasons and inspection-only notices.
206    pub blockers: Vec<String>,
207}
208
209impl DigitalTwinState {
210    /// Creates a Digital Twin state and derives its two admission gates.
211    #[allow(clippy::too_many_arguments)]
212    pub fn try_new(
213        title: impl Into<String>,
214        source: StudioSource,
215        frame_id: impl Into<String>,
216        expected_frame_id: impl Into<String>,
217        time_basis: impl Into<String>,
218        assets: Vec<DigitalTwinAsset>,
219        semantic_layer: Option<ReplayArtifact>,
220        summary: DigitalTwinSummary,
221        blockers: Vec<String>,
222    ) -> ViewerResult<Self> {
223        let has_gltf = assets.iter().any(|asset| asset.format == "gltf");
224        let has_usda = assets.iter().any(|asset| asset.format == "usda");
225        let twin_ready = source.identity_matches
226            && summary.source_identity_match
227            && summary.frame_identity_match
228            && summary.geometry_identity_preserved
229            && has_gltf
230            && has_usda;
231        let mapping_admitted = twin_ready && summary.calibration_applied;
232        let state = Self {
233            version: DIGITAL_TWIN_STATE_VERSION,
234            title: title.into(),
235            source,
236            frame_id: frame_id.into(),
237            expected_frame_id: expected_frame_id.into(),
238            time_basis: time_basis.into(),
239            assets,
240            semantic_layer,
241            summary,
242            twin_ready,
243            mapping_admitted,
244            blockers,
245        };
246        state.validate()?;
247        Ok(state)
248    }
249
250    /// Validates asset identity and both fail-closed admission gates.
251    pub fn validate(&self) -> ViewerResult<()> {
252        if self.version != DIGITAL_TWIN_STATE_VERSION {
253            return Err(ViewerError::InvalidState(format!(
254                "unsupported Digital Twin state version {}",
255                self.version
256            )));
257        }
258        if self.title.trim().is_empty()
259            || self.frame_id.trim().is_empty()
260            || self.expected_frame_id.trim().is_empty()
261            || self.time_basis.trim().is_empty()
262        {
263            return Err(ViewerError::InvalidState(
264                "Digital Twin title, frames, and time basis must not be empty".into(),
265            ));
266        }
267        self.source.validate()?;
268        self.summary.validate()?;
269
270        let mut asset_ids = BTreeSet::new();
271        let mut asset_formats = BTreeSet::new();
272        let mut asset_paths = BTreeSet::new();
273        let mut gltf_count = 0_u64;
274        let mut usda_count = 0_u64;
275        for asset in &self.assets {
276            asset.validate()?;
277            if !asset_ids.insert(&asset.id)
278                || !asset_formats.insert(&asset.format)
279                || !asset_paths.insert(&asset.artifact.path)
280            {
281                return Err(ViewerError::InvalidState(
282                    "Digital Twin assets require unique IDs, formats, and paths".into(),
283                ));
284            }
285            if asset.frame_id != self.frame_id {
286                return Err(ViewerError::InvalidState(
287                    "Digital Twin assets must use the state frame".into(),
288                ));
289            }
290            match asset.format.as_str() {
291                "gltf" => gltf_count = gltf_count.saturating_add(1),
292                "usda" => usda_count = usda_count.saturating_add(1),
293                _ => {
294                    return Err(ViewerError::InvalidState(
295                        "Digital Twin asset format must be gltf or usda".into(),
296                    ));
297                }
298            }
299            if asset.identity_preserved
300                && (asset.vertex_count != self.summary.source_vertex_count
301                    || asset.triangle_count != self.summary.source_triangle_count)
302            {
303                return Err(ViewerError::InvalidState(
304                    "identity-preserving Digital Twin asset counts must match the source".into(),
305                ));
306            }
307        }
308        if self.summary.asset_count != u64::try_from(self.assets.len()).unwrap_or(u64::MAX) {
309            return Err(ViewerError::InvalidState(
310                "Digital Twin summary asset count disagrees with assets".into(),
311            ));
312        }
313        if gltf_count > 1 || usda_count > 1 {
314            return Err(ViewerError::InvalidState(
315                "Digital Twin state permits at most one glTF and one USDA asset".into(),
316            ));
317        }
318
319        if let Some(semantic_layer) = &self.semantic_layer {
320            semantic_layer.validate()?;
321            if !asset_paths.insert(&semantic_layer.path) {
322                return Err(ViewerError::InvalidState(
323                    "semantic Digital Twin layer path overlaps an asset path".into(),
324                ));
325            }
326        }
327        if self.summary.semantic_layer_present != self.semantic_layer.is_some() {
328            return Err(ViewerError::InvalidState(
329                "Digital Twin semantic layer flag disagrees with the artifact".into(),
330            ));
331        }
332
333        let calculated_twin_ready = self.source.identity_matches
334            && self.summary.source_identity_match
335            && self.summary.frame_identity_match
336            && self.summary.geometry_identity_preserved
337            && gltf_count == 1
338            && usda_count == 1;
339        if self.twin_ready != calculated_twin_ready {
340            return Err(ViewerError::InvalidState(
341                "twin_ready disagrees with source, frame, geometry, or asset admission".into(),
342            ));
343        }
344        let calculated_mapping = self.twin_ready && self.summary.calibration_applied;
345        if self.mapping_admitted != calculated_mapping {
346            return Err(ViewerError::InvalidState(
347                "mapping_admitted disagrees with Digital Twin and calibration admission".into(),
348            ));
349        }
350        if self.mapping_admitted && !self.blockers.is_empty() {
351            return Err(ViewerError::InvalidState(
352                "admitted Digital Twin mapping cannot contain blockers".into(),
353            ));
354        }
355        if !self.mapping_admitted && self.blockers.is_empty() {
356            return Err(ViewerError::InvalidState(
357                "blocked Digital Twin mapping must expose at least one blocker".into(),
358            ));
359        }
360        if self.blockers.iter().any(|blocker| blocker.trim().is_empty()) {
361            return Err(ViewerError::InvalidState(
362                "Digital Twin blockers must not contain empty messages".into(),
363            ));
364        }
365        Ok(())
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    const SHA: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
374    const OTHER_SHA: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
375
376    fn source(observed: &str, identity_matches: bool) -> StudioSource {
377        StudioSource::try_new("canonical bag", "/media/input.db3", SHA, observed, identity_matches)
378            .unwrap()
379    }
380
381    fn artifact(role: &str) -> ReplayArtifact {
382        ReplayArtifact::try_new(format!("{role}-asset"), format!("/media/{role}"), 128, SHA)
383            .unwrap()
384    }
385
386    fn asset(id: &str, format: &str) -> DigitalTwinAsset {
387        DigitalTwinAsset::try_new(
388            id,
389            format,
390            artifact(format),
391            "lidar_front",
392            10,
393            4,
394            true,
395            "identity-preserving",
396        )
397        .unwrap()
398    }
399
400    fn summary(source_identity_match: bool, frame_identity_match: bool) -> DigitalTwinSummary {
401        DigitalTwinSummary::try_new(
402            10,
403            4,
404            if source_identity_match { 10 } else { 0 },
405            if source_identity_match { 4 } else { 0 },
406            if source_identity_match { 10 } else { 0 },
407            if source_identity_match { 4 } else { 0 },
408            if source_identity_match { 2 } else { 0 },
409            false,
410            source_identity_match,
411            frame_identity_match,
412            source_identity_match,
413            false,
414        )
415        .unwrap()
416    }
417
418    #[test]
419    fn valid_bundle_is_ready_but_mapping_stays_blocked() {
420        let state = DigitalTwinState::try_new(
421            "Digital Twin",
422            source(SHA, true),
423            "lidar_front",
424            "lidar_front",
425            "PointCloud2 header stamp; no clock calibration applied",
426            vec![asset("mesh-gltf", "gltf"), asset("mesh-usda", "usda")],
427            None,
428            summary(true, true),
429            vec!["clock and TF calibration are not applied".into()],
430        )
431        .unwrap();
432        assert!(state.twin_ready);
433        assert!(!state.mapping_admitted);
434        #[cfg(feature = "serde")]
435        {
436            let json = serde_json::to_string(&state).unwrap();
437            assert_eq!(serde_json::from_str::<DigitalTwinState>(&json).unwrap(), state);
438        }
439    }
440
441    #[test]
442    fn source_mismatch_withholds_the_bundle() {
443        let state = DigitalTwinState::try_new(
444            "Digital Twin",
445            source(OTHER_SHA, false),
446            "lidar_front",
447            "lidar_front",
448            "header stamp",
449            Vec::new(),
450            None,
451            summary(false, true),
452            vec!["input SHA-256 mismatch".into()],
453        )
454        .unwrap();
455        assert!(!state.twin_ready);
456        assert!(!state.mapping_admitted);
457    }
458}