Skip to main content

sim_lib_view_spatial/
rank.rs

1//! Attention ranking for spatial glasses projection.
2
3use sim_kernel::{Error, Expr, Result, Symbol};
4use sim_lib_view_device::{DeviceProfile, GlassesClass, glasses_class};
5use sim_value::{access, build};
6
7const DEFAULT_GAZE: [f64; 3] = [0.0, 0.0, -1.0];
8
9/// Content-rate attention budget for spatial glasses.
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct AttentionBudget {
12    /// Maximum gaze angle for foveal detail.
13    pub foveal_deg: f64,
14    /// Maximum gaze angle for peripheral detail.
15    pub peripheral_deg: f64,
16    /// Maximum non-critical panels lit at once.
17    pub max_lit_panels: usize,
18    /// Whether a camera stream is live and should raise the privacy shade.
19    pub camera_live: bool,
20}
21
22impl AttentionBudget {
23    /// Builds a budget with stable spatial-glasses defaults.
24    pub fn new(max_lit_panels: usize) -> Self {
25        Self {
26            foveal_deg: 12.0,
27            peripheral_deg: 55.0,
28            max_lit_panels,
29            camera_live: false,
30        }
31    }
32
33    /// Returns the default Viture-style spatial budget.
34    pub fn spatial_default() -> Self {
35        Self::new(4)
36    }
37
38    /// Builds a content-rate budget from a device profile.
39    pub fn for_profile(profile: &DeviceProfile) -> Self {
40        Self {
41            camera_live: has_symbol(&profile.input, "camera")
42                || has_symbol(&profile.streams, "camera"),
43            ..Self::spatial_default()
44        }
45    }
46
47    /// Sets whether the privacy shade is raised.
48    pub fn with_camera_live(mut self, camera_live: bool) -> Self {
49        self.camera_live = camera_live;
50        self
51    }
52}
53
54/// Ranks a glasses scene according to the selected glasses path.
55///
56/// Stereo 6DoF scenes are spatially ranked. Mono HUD and display-only scenes are
57/// returned unchanged because their budget is owned by the DEVICE_3 glance or
58/// mirror paths.
59pub fn rank_glasses(
60    scene: &Expr,
61    class: GlassesClass,
62    gaze: [f64; 3],
63    budget: &AttentionBudget,
64) -> Result<Expr> {
65    match class {
66        GlassesClass::Stereo6Dof => rank_spatial(scene, gaze, budget),
67        GlassesClass::MonoHud | GlassesClass::DisplayOnly => Ok(scene.clone()),
68    }
69}
70
71/// Ranks a scene for an already-derived profile using the default forward gaze.
72pub fn rank_for_profile(scene: &Expr, profile: &DeviceProfile) -> Result<Expr> {
73    match glasses_class(profile) {
74        Some(class) => rank_glasses(
75            scene,
76            class,
77            DEFAULT_GAZE,
78            &AttentionBudget::for_profile(profile),
79        ),
80        None => Ok(scene.clone()),
81    }
82}
83
84/// Applies foveal, peripheral, budget, and privacy-shade metadata to a spatial scene.
85pub fn rank_spatial(scene: &Expr, gaze: [f64; 3], budget: &AttentionBudget) -> Result<Expr> {
86    expect_scene_kind(scene, "spatial")?;
87    let children = spatial_children(scene)?;
88    let seeds = children
89        .iter()
90        .enumerate()
91        .filter(|(_, child)| matches!(scene_kind(child).as_deref(), Some("panel")))
92        .map(|(index, child)| panel_seed(index, child, gaze, budget))
93        .collect::<Result<Vec<_>>>()?;
94    let lit = lit_panels(&seeds, budget.max_lit_panels);
95    let ranked_children = children
96        .iter()
97        .enumerate()
98        .map(|(index, child)| {
99            seeds
100                .iter()
101                .find(|seed| seed.index == index)
102                .map(|seed| annotate_panel(child, seed, lit[index]))
103                .unwrap_or_else(|| child.clone())
104        })
105        .collect();
106    let mut ranked = access::set(scene, "children", build::list(ranked_children));
107    ranked = access::set(&ranked, "privacy-shade", Expr::Bool(budget.camera_live));
108    if budget.camera_live {
109        ranked = access::set(&ranked, "privacy-reason", build::sym("camera-live"));
110    }
111    Ok(ranked)
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115enum AttentionDetail {
116    Foveal,
117    Peripheral,
118    Hidden,
119}
120
121impl AttentionDetail {
122    fn token(self) -> &'static str {
123        match self {
124            Self::Foveal => "foveal",
125            Self::Peripheral => "peripheral",
126            Self::Hidden => "hidden",
127        }
128    }
129
130    fn order(self) -> u8 {
131        match self {
132            Self::Foveal => 0,
133            Self::Peripheral => 1,
134            Self::Hidden => 2,
135        }
136    }
137}
138
139#[derive(Clone, Debug)]
140struct PanelSeed {
141    index: usize,
142    angle_deg: f64,
143    detail: AttentionDetail,
144    pinned: bool,
145}
146
147impl PanelSeed {
148    fn eligible(&self) -> bool {
149        self.pinned || self.detail != AttentionDetail::Hidden
150    }
151}
152
153fn panel_seed(
154    index: usize,
155    panel: &Expr,
156    gaze: [f64; 3],
157    budget: &AttentionBudget,
158) -> Result<PanelSeed> {
159    let angle_deg = angle_deg(gaze, panel_direction(panel)?);
160    let pinned = has_warrant_or_error(panel);
161    let mut detail = if angle_deg <= budget.foveal_deg {
162        AttentionDetail::Foveal
163    } else if angle_deg <= budget.peripheral_deg {
164        AttentionDetail::Peripheral
165    } else {
166        AttentionDetail::Hidden
167    };
168    if pinned && detail == AttentionDetail::Hidden {
169        detail = AttentionDetail::Peripheral;
170    }
171    Ok(PanelSeed {
172        index,
173        angle_deg,
174        detail,
175        pinned,
176    })
177}
178
179fn lit_panels(seeds: &[PanelSeed], max_lit_panels: usize) -> Vec<bool> {
180    let mut lit = vec![false; seeds.iter().map(|seed| seed.index).max().unwrap_or(0) + 1];
181    for seed in seeds {
182        if seed.pinned {
183            lit[seed.index] = true;
184        }
185    }
186    let mut normal = seeds
187        .iter()
188        .filter(|seed| !seed.pinned && seed.eligible())
189        .collect::<Vec<_>>();
190    normal.sort_by(|a, b| {
191        a.detail
192            .order()
193            .cmp(&b.detail.order())
194            .then_with(|| a.angle_deg.total_cmp(&b.angle_deg))
195            .then_with(|| a.index.cmp(&b.index))
196    });
197    for seed in normal.into_iter().take(max_lit_panels) {
198        lit[seed.index] = true;
199    }
200    lit
201}
202
203fn annotate_panel(panel: &Expr, seed: &PanelSeed, lit: bool) -> Expr {
204    let detail = if lit {
205        seed.detail
206    } else {
207        AttentionDetail::Hidden
208    };
209    let reason = if seed.pinned {
210        "pinned"
211    } else if !seed.eligible() || lit {
212        "gaze"
213    } else {
214        "budget"
215    };
216    let rank = if lit { 1000.0 - seed.angle_deg } else { 0.0 };
217    let mut out = access::set(panel, "attention-detail", build::sym(detail.token()));
218    out = access::set(&out, "attention-angle-deg", build::float(seed.angle_deg));
219    out = access::set(&out, "attention-rank", build::float(rank));
220    out = access::set(&out, "attention-lit", Expr::Bool(lit));
221    out = access::set(&out, "attention-pinned", Expr::Bool(seed.pinned));
222    access::set(&out, "attention-reason", build::sym(reason))
223}
224
225fn panel_direction(panel: &Expr) -> Result<[f64; 3]> {
226    let Some(transform) = access::field(panel, "transform") else {
227        return Ok(DEFAULT_GAZE);
228    };
229    let Some(value) = access::field(transform, "translate-m") else {
230        return Ok(DEFAULT_GAZE);
231    };
232    let Expr::Vector(items) = value else {
233        return Err(Error::HostError(
234            "scene/panel transform translate-m must be a vector".to_owned(),
235        ));
236    };
237    if items.len() != 3 {
238        return Err(Error::HostError(
239            "scene/panel transform translate-m must contain 3 numbers".to_owned(),
240        ));
241    }
242    let mut out = [0.0; 3];
243    for (index, item) in items.iter().enumerate() {
244        let value = access::as_f64(item).ok_or_else(|| {
245            Error::HostError(format!(
246                "scene/panel transform translate-m[{index}] must be numeric"
247            ))
248        })?;
249        if !value.is_finite() {
250            return Err(Error::HostError(format!(
251                "scene/panel transform translate-m[{index}] must be finite"
252            )));
253        }
254        out[index] = value;
255    }
256    if magnitude(out) == 0.0 {
257        Ok(DEFAULT_GAZE)
258    } else {
259        Ok(out)
260    }
261}
262
263fn angle_deg(gaze: [f64; 3], direction: [f64; 3]) -> f64 {
264    let gaze = normalize_or_default(gaze);
265    let direction = normalize_or_default(direction);
266    dot(gaze, direction).clamp(-1.0, 1.0).acos().to_degrees()
267}
268
269fn normalize_or_default(vector: [f64; 3]) -> [f64; 3] {
270    let magnitude = magnitude(vector);
271    if magnitude == 0.0 || !magnitude.is_finite() {
272        DEFAULT_GAZE
273    } else {
274        [
275            vector[0] / magnitude,
276            vector[1] / magnitude,
277            vector[2] / magnitude,
278        ]
279    }
280}
281
282fn magnitude(vector: [f64; 3]) -> f64 {
283    dot(vector, vector).sqrt()
284}
285
286fn dot(left: [f64; 3], right: [f64; 3]) -> f64 {
287    left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
288}
289
290fn has_warrant_or_error(expr: &Expr) -> bool {
291    field_has_critical_symbol(expr, "status")
292        || field_has_critical_symbol(expr, "urgency")
293        || access::field_bool(expr, "warrant").unwrap_or(false)
294        || scene_kind(expr).as_deref() == Some("warrant")
295        || children(expr).any(has_warrant_or_error)
296}
297
298fn field_has_critical_symbol(expr: &Expr, name: &str) -> bool {
299    access::field_sym(expr, name).is_some_and(|symbol| {
300        matches!(
301            symbol.name.as_ref(),
302            "error" | "critical" | "warrant" | "warn"
303        )
304    })
305}
306
307fn children(expr: &Expr) -> impl Iterator<Item = &Expr> {
308    match expr {
309        Expr::Map(entries) => entries.iter().map(|(_, value)| value).collect::<Vec<_>>(),
310        Expr::List(items) | Expr::Vector(items) | Expr::Set(items) => items.iter().collect(),
311        _ => Vec::new(),
312    }
313    .into_iter()
314}
315
316fn spatial_children(scene: &Expr) -> Result<&[Expr]> {
317    match access::required(scene, "children", "scene/spatial")? {
318        Expr::List(children) => Ok(children),
319        _ => Err(Error::HostError(
320            "scene/spatial children must be a list".to_owned(),
321        )),
322    }
323}
324
325fn expect_scene_kind(scene: &Expr, expected: &str) -> Result<()> {
326    match scene_kind(scene).as_deref() {
327        Some(kind) if kind == expected => Ok(()),
328        _ => Err(Error::HostError(format!("expected scene/{expected}"))),
329    }
330}
331
332fn scene_kind(expr: &Expr) -> Option<String> {
333    let kind = sim_lib_scene::node_kind(expr)?;
334    (kind.namespace.as_deref() == Some(sim_lib_scene::SCENE_NAMESPACE))
335        .then(|| kind.name.to_string())
336}
337
338fn has_symbol(symbols: &[Symbol], name: &str) -> bool {
339    symbols
340        .iter()
341        .any(|symbol| symbol.namespace.is_none() && symbol.name.as_ref() == name)
342}