1use 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#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct AttentionBudget {
12 pub foveal_deg: f64,
14 pub peripheral_deg: f64,
16 pub max_lit_panels: usize,
18 pub camera_live: bool,
20}
21
22impl AttentionBudget {
23 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 pub fn spatial_default() -> Self {
35 Self::new(4)
36 }
37
38 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 pub fn with_camera_live(mut self, camera_live: bool) -> Self {
49 self.camera_live = camera_live;
50 self
51 }
52}
53
54pub 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
71pub 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
84pub 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}