1use std::collections::BTreeSet;
8
9use crate::{ReplayArtifact, StudioSource, ViewerError, ViewerResult};
10
11pub const DIGITAL_TWIN_STATE_VERSION: u32 = 1;
13
14#[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 pub id: String,
21 pub format: String,
23 pub artifact: ReplayArtifact,
25 pub frame_id: String,
27 pub vertex_count: u64,
29 pub triangle_count: u64,
31 pub identity_preserved: bool,
34 pub geometry_mode: String,
36}
37
38impl DigitalTwinAsset {
39 #[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 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#[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 pub source_vertex_count: u64,
92 pub source_triangle_count: u64,
94 pub gltf_vertex_count: u64,
96 pub gltf_triangle_count: u64,
98 pub usd_vertex_count: u64,
100 pub usd_triangle_count: u64,
102 pub asset_count: u64,
104 pub semantic_layer_present: bool,
106 pub source_identity_match: bool,
108 pub frame_identity_match: bool,
110 pub geometry_identity_preserved: bool,
112 pub calibration_applied: bool,
114}
115
116impl DigitalTwinSummary {
117 #[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 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#[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 pub version: u32,
185 pub title: String,
187 pub source: StudioSource,
189 pub frame_id: String,
191 pub expected_frame_id: String,
193 pub time_basis: String,
195 pub assets: Vec<DigitalTwinAsset>,
197 pub semantic_layer: Option<ReplayArtifact>,
199 pub summary: DigitalTwinSummary,
201 pub twin_ready: bool,
203 pub mapping_admitted: bool,
205 pub blockers: Vec<String>,
207}
208
209impl DigitalTwinState {
210 #[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 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}