Skip to main content

sim_lib_web_bridge/
glasses.rs

1//! Browser/native clients for glasses Scene adaptation.
2
3use std::rc::Rc;
4
5use sim_kernel::{Error, Expr, Result};
6use sim_lib_scene::GlanceCard;
7use sim_lib_view::SurfaceCaps;
8use sim_lib_view_device::{
9    DeviceProfile, DeviceSurfaceCapsExt, EncodedScene, GlanceState, GlassesClass, LocalAdapter,
10    glasses_class,
11};
12use sim_lib_view_spatial::{ClampedReprojector, PoseView, halo_glance_config};
13use sim_value::{access, build};
14
15/// Side-by-side viewport dimensions advertised by glasses surface capabilities.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub struct GlassesViewport {
18    per_eye_px: [u32; 2],
19}
20
21impl GlassesViewport {
22    /// Reads per-eye dimensions from a glasses `SurfaceCaps` display map.
23    ///
24    /// # Errors
25    ///
26    /// Returns an error when `per-eye-px` is absent or malformed.
27    pub fn from_caps(caps: &SurfaceCaps) -> Result<Self> {
28        let values = match access::required(&caps.display, "per-eye-px", "glasses display caps")? {
29            Expr::List(values) if values.len() == 2 => values,
30            _ => {
31                return Err(Error::HostError(
32                    "glasses display per-eye-px must contain width and height".to_owned(),
33                ));
34            }
35        };
36        Ok(Self {
37            per_eye_px: [read_px(&values[0])?, read_px(&values[1])?],
38        })
39    }
40
41    /// Returns `[width, height]` for one eye.
42    pub fn per_eye_px(self) -> [u32; 2] {
43        self.per_eye_px
44    }
45
46    /// Returns `[width, height]` for the side-by-side frame.
47    pub fn frame_px(self) -> [u32; 2] {
48        [self.per_eye_px[0].saturating_mul(2), self.per_eye_px[1]]
49    }
50}
51
52/// Native Viture client that retains one content Scene across device-rate frames.
53///
54/// Rich profiles reuse the shared clamp-aware spatial reprojector. Display-only
55/// profiles return the retained `scene/spatial` packet unchanged for mirroring.
56#[derive(Debug)]
57pub struct VitureSceneClient {
58    profile: DeviceProfile,
59    viewport: GlassesViewport,
60    reprojector: ClampedReprojector,
61    scene: Option<EncodedScene>,
62    content_receipts: u64,
63}
64
65impl VitureSceneClient {
66    /// Builds a client from open surface capabilities.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error unless the caps describe stereo 6DoF or display-only
71    /// glasses with valid per-eye dimensions.
72    pub fn new(caps: &SurfaceCaps, max_predict_ms: u64) -> Result<Self> {
73        let profile = caps.device_profile();
74        match glasses_class(&profile) {
75            Some(GlassesClass::Stereo6Dof | GlassesClass::DisplayOnly) => {}
76            _ => {
77                return Err(Error::HostError(
78                    "Viture client requires stereo or display-only glasses caps".to_owned(),
79                ));
80            }
81        }
82        Ok(Self {
83            profile,
84            viewport: GlassesViewport::from_caps(caps)?,
85            reprojector: ClampedReprojector::new(max_predict_ms),
86            scene: None,
87            content_receipts: 0,
88        })
89    }
90
91    /// Retains a new content-rate `scene/spatial` packet.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error when `scene` is not a spatial Scene root.
96    pub fn receive(&mut self, scene: Expr) -> Result<()> {
97        expect_scene_kind(&scene, "spatial")?;
98        self.scene = Some(EncodedScene::new(scene));
99        self.content_receipts = self.content_receipts.saturating_add(1);
100        Ok(())
101    }
102
103    /// Adapts the retained Scene for one local pose sample.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error before the first content receipt or when reprojection
108    /// rejects malformed spatial content.
109    pub fn frame(&self, pose: &PoseView) -> Result<Rc<Expr>> {
110        let scene = self
111            .scene
112            .as_ref()
113            .ok_or_else(|| Error::HostError("Viture client has no content Scene".to_owned()))?;
114        let frame = self.reprojector.adapt(scene, pose, &self.profile)?;
115        if glasses_class(&self.profile) == Some(GlassesClass::DisplayOnly) {
116            return Ok(frame);
117        }
118        Ok(Rc::new(with_stereo_viewport(frame.as_ref(), self.viewport)))
119    }
120
121    /// Returns the number of content-rate Scene packets received.
122    pub fn content_receipts(&self) -> u64 {
123        self.content_receipts
124    }
125
126    /// Returns the side-by-side viewport dimensions.
127    pub fn viewport(&self) -> GlassesViewport {
128        self.viewport
129    }
130
131    /// Returns whether this client is using display-only mirroring.
132    pub fn is_mirror(&self) -> bool {
133        glasses_class(&self.profile) == Some(GlassesClass::DisplayOnly)
134    }
135}
136
137/// Native Halo preview client over the shared one-card glance adapter.
138#[derive(Debug)]
139pub struct HaloPreviewClient {
140    profile: DeviceProfile,
141    scene: Option<EncodedScene>,
142    content_receipts: u64,
143}
144
145impl HaloPreviewClient {
146    /// Builds a preview client from mono-HUD surface capabilities.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error unless the caps resolve to mono-HUD glasses.
151    pub fn new(caps: &SurfaceCaps) -> Result<Self> {
152        let profile = caps.device_profile();
153        if glasses_class(&profile) != Some(GlassesClass::MonoHud) {
154            return Err(Error::HostError(
155                "Halo preview requires mono-HUD glasses caps".to_owned(),
156            ));
157        }
158        Ok(Self {
159            profile,
160            scene: None,
161            content_receipts: 0,
162        })
163    }
164
165    /// Retains a new content-rate `scene/glance` card.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error when the Scene is not one valid glance card.
170    pub fn receive(&mut self, scene: Expr) -> Result<()> {
171        GlanceCard::from_scene(&scene)?;
172        self.scene = Some(EncodedScene::new(scene));
173        self.content_receipts = self.content_receipts.saturating_add(1);
174        Ok(())
175    }
176
177    /// Fits the retained card to the Halo budget for one local input state.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error before the first content receipt or when the shared
182    /// glance adapter rejects the card.
183    pub fn frame(&self, state: &GlanceState) -> Result<Rc<Expr>> {
184        let scene = self
185            .scene
186            .as_ref()
187            .ok_or_else(|| Error::HostError("Halo preview has no glance Scene".to_owned()))?;
188        halo_glance_config().adapt(scene, state, &self.profile)
189    }
190
191    /// Returns the number of content-rate cards received.
192    pub fn content_receipts(&self) -> u64 {
193        self.content_receipts
194    }
195}
196
197fn read_px(expr: &Expr) -> Result<u32> {
198    let Expr::Number(number) = expr else {
199        return Err(Error::HostError(
200            "glasses viewport dimensions must be numbers".to_owned(),
201        ));
202    };
203    number
204        .canonical
205        .parse::<u32>()
206        .ok()
207        .filter(|value| *value > 0)
208        .ok_or_else(|| Error::HostError("glasses viewport dimensions must be positive".to_owned()))
209}
210
211fn with_stereo_viewport(scene: &Expr, viewport: GlassesViewport) -> Expr {
212    let per_eye = viewport.per_eye_px();
213    let frame = viewport.frame_px();
214    let scene = access::set(scene, "layout", build::sym("side-by-side"));
215    let scene = access::set(
216        &scene,
217        "eye-px",
218        build::list(vec![
219            build::uint(per_eye[0].into()),
220            build::uint(per_eye[1].into()),
221        ]),
222    );
223    access::set(
224        &scene,
225        "frame-px",
226        build::list(vec![
227            build::uint(frame[0].into()),
228            build::uint(frame[1].into()),
229        ]),
230    )
231}
232
233fn expect_scene_kind(scene: &Expr, expected: &str) -> Result<()> {
234    let matches = sim_lib_scene::node_kind(scene).is_some_and(|kind| {
235        kind.namespace.as_deref() == Some(sim_lib_scene::SCENE_NAMESPACE)
236            && kind.name.as_ref() == expected
237    });
238    if matches {
239        Ok(())
240    } else {
241        Err(Error::HostError(format!("expected scene/{expected}")))
242    }
243}