Skip to main content

sim_lib_view_spatial/
layout.rs

1//! Pose-free spatial layout parsing and Scene builders.
2
3use sim_kernel::{Error, Expr, Result, Symbol};
4use sim_lib_scene::{Anchor, AnchorSpace, Transform3};
5use sim_table_core::{TableOp, TablePath};
6use sim_value::{access, build};
7
8/// Namespace for persisted glasses workspace layout records.
9pub const WORKSPACE_LAYOUT_NAMESPACE: &str = "workspace";
10
11/// Kind name for persisted glasses workspace layout records.
12pub const WORKSPACE_LAYOUT_KIND: &str = "layout";
13
14/// Namespace used for table keys that store glasses workspace layouts.
15pub const WORKSPACE_LAYOUT_TABLE_NAMESPACE: &str = "workspace-layout";
16
17const DEFAULT_LAYOUT_KEY: &str = "default";
18const DEFAULT_WORLD_ANCHOR: &str = "workspace";
19
20/// A spatial layout made of anchored panels.
21#[derive(Clone, Debug, PartialEq)]
22pub struct SpatialLayout {
23    panels: Vec<PanelLayout>,
24}
25
26impl SpatialLayout {
27    /// Builds the default single-panel layout.
28    pub fn single_panel() -> Self {
29        Self {
30            panels: vec![PanelLayout::default()],
31        }
32    }
33
34    /// Builds the default Viture workspace arc.
35    pub fn default_arc() -> Self {
36        WorkspaceLayout::default_arc().to_spatial_layout()
37    }
38
39    /// Parses a layout expression.
40    ///
41    /// A layout is a map with an optional `panels` list. Each panel may carry
42    /// `id`, `anchor`, and `transform` fields. Missing fields use stable
43    /// defaults so a value can be encoded spatially without layout metadata.
44    pub fn from_expr(expr: &Expr) -> Result<Self> {
45        reject_runtime_fields(expr)?;
46        let Some(Expr::List(items) | Expr::Vector(items)) = access::field(expr, "panels") else {
47            return Ok(Self::single_panel());
48        };
49        if items.is_empty() {
50            return Ok(Self::single_panel());
51        }
52        let panels = items
53            .iter()
54            .enumerate()
55            .map(|(index, item)| PanelLayout::from_expr(index, item))
56            .collect::<Result<Vec<_>>>()?;
57        Ok(Self { panels })
58    }
59
60    /// Returns the panel layouts in order.
61    pub fn panels(&self) -> &[PanelLayout] {
62        &self.panels
63    }
64}
65
66/// Persisted co-use layout shared by the Viture workspace and Halo glance path.
67#[derive(Clone, Debug, PartialEq)]
68pub struct WorkspaceLayout {
69    panels: Vec<PanelPlacement>,
70    glance: GlancePreference,
71}
72
73impl WorkspaceLayout {
74    /// Builds the default one-panel Viture arc with urgency-first Halo glance.
75    pub fn default_arc() -> Self {
76        Self {
77            panels: vec![
78                PanelPlacement::new(
79                    Symbol::new("main"),
80                    AnchorSpace::World,
81                    Transform3::new([0.0, 1.2, -1.6], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0]),
82                )
83                .with_world_anchor(Symbol::new(DEFAULT_WORLD_ANCHOR)),
84            ],
85            glance: GlancePreference::default(),
86        }
87    }
88
89    /// Builds a workspace layout from explicit placements and glance preference.
90    pub fn new(panels: Vec<PanelPlacement>, glance: GlancePreference) -> Result<Self> {
91        if panels.is_empty() {
92            return Err(Error::HostError(
93                "workspace/layout must contain at least one panel".to_owned(),
94            ));
95        }
96        Ok(Self { panels, glance })
97    }
98
99    /// Returns Viture panel placements in render order.
100    pub fn panels(&self) -> &[PanelPlacement] {
101        &self.panels
102    }
103
104    /// Returns the Halo glance preference stored with the workspace layout.
105    pub fn glance(&self) -> &GlancePreference {
106        &self.glance
107    }
108
109    /// Encodes the layout as a portable SIM `Expr`.
110    pub fn to_expr(&self) -> Expr {
111        build::map(vec![
112            (
113                "kind",
114                Expr::Symbol(Symbol::qualified(
115                    WORKSPACE_LAYOUT_NAMESPACE,
116                    WORKSPACE_LAYOUT_KIND,
117                )),
118            ),
119            (
120                "panels",
121                build::list(self.panels.iter().map(PanelPlacement::to_expr).collect()),
122            ),
123            ("glance", self.glance.to_expr()),
124        ])
125    }
126
127    /// Decodes a portable SIM `Expr` into a workspace layout.
128    pub fn from_expr(expr: &Expr) -> Result<Self> {
129        reject_runtime_fields(expr)?;
130        expect_kind(expr, WORKSPACE_LAYOUT_KIND, "workspace/layout")?;
131        let panels_expr = access::required(expr, "panels", "workspace/layout")?;
132        let panels = match panels_expr {
133            Expr::List(items) | Expr::Vector(items) => items
134                .iter()
135                .map(PanelPlacement::from_expr)
136                .collect::<Result<Vec<_>>>()?,
137            _ => {
138                return Err(Error::HostError(
139                    "workspace/layout panels field must be a list".to_owned(),
140                ));
141            }
142        };
143        let glance = access::field(expr, "glance")
144            .map(GlancePreference::from_expr)
145            .transpose()?
146            .unwrap_or_default();
147        Self::new(panels, glance)
148    }
149
150    fn to_spatial_layout(&self) -> SpatialLayout {
151        SpatialLayout {
152            panels: self
153                .panels
154                .iter()
155                .map(PanelPlacement::to_panel_layout)
156                .collect(),
157        }
158    }
159}
160
161/// A persisted Viture panel placement.
162#[derive(Clone, Debug, PartialEq)]
163pub struct PanelPlacement {
164    /// Stable panel id.
165    pub panel_id: Symbol,
166    /// Pose-free coordinate space for the panel.
167    pub space: AnchorSpace,
168    /// Static transform applied before device pose.
169    pub transform: Transform3,
170    /// Optional stable world anchor id.
171    pub world_anchor: Option<Symbol>,
172}
173
174impl PanelPlacement {
175    /// Builds a placement without a world anchor.
176    pub fn new(panel_id: Symbol, space: AnchorSpace, transform: Transform3) -> Self {
177        Self {
178            panel_id,
179            space,
180            transform,
181            world_anchor: None,
182        }
183    }
184
185    /// Attaches a stable world anchor id.
186    pub fn with_world_anchor(mut self, world_anchor: Symbol) -> Self {
187        self.world_anchor = Some(world_anchor);
188        self
189    }
190
191    /// Encodes the placement as portable workspace layout data.
192    pub fn to_expr(&self) -> Expr {
193        let mut fields = vec![
194            (
195                "kind",
196                Expr::Symbol(Symbol::qualified(
197                    WORKSPACE_LAYOUT_NAMESPACE,
198                    "panel-placement",
199                )),
200            ),
201            ("panel-id", Expr::Symbol(self.panel_id.clone())),
202            ("space", self.space.to_expr()),
203            ("transform", self.transform.to_expr()),
204        ];
205        if let Some(anchor) = &self.world_anchor {
206            fields.push(("world-anchor", Expr::Symbol(anchor.clone())));
207        }
208        build::map(fields)
209    }
210
211    /// Decodes one persisted panel placement.
212    pub fn from_expr(expr: &Expr) -> Result<Self> {
213        reject_runtime_fields(expr)?;
214        expect_kind(expr, "panel-placement", "workspace/panel-placement")?;
215        let panel_id = access::required_sym(expr, "panel-id", "workspace/panel-placement")?;
216        let space = AnchorSpace::from_expr(access::required(
217            expr,
218            "space",
219            "workspace/panel-placement",
220        )?)?;
221        let transform = Transform3::from_expr(access::required(
222            expr,
223            "transform",
224            "workspace/panel-placement",
225        )?)?;
226        Ok(Self {
227            panel_id,
228            space,
229            transform,
230            world_anchor: access::field_sym(expr, "world-anchor"),
231        })
232    }
233
234    fn to_panel_layout(&self) -> PanelLayout {
235        let target = self
236            .world_anchor
237            .as_ref()
238            .map(Symbol::as_qualified_str)
239            .unwrap_or_else(|| DEFAULT_WORLD_ANCHOR.to_owned());
240        PanelLayout {
241            id: self.panel_id.as_qualified_str(),
242            anchor: Anchor::new(self.space, target),
243            transform: self.transform.clone(),
244        }
245    }
246}
247
248/// Halo glance selection policy stored with a workspace layout.
249#[derive(Clone, Debug, Default, PartialEq, Eq)]
250pub enum GlancePreference {
251    /// Keep the shared glance reducer's urgency-first selection.
252    #[default]
253    UrgencyFirst,
254    /// Prefer a particular item class when the host reducer supports it.
255    ItemClass(Symbol),
256}
257
258impl GlancePreference {
259    /// Builds a preference for an item class.
260    pub fn item_class(class: Symbol) -> Self {
261        Self::ItemClass(class)
262    }
263
264    /// Returns the preferred item class, if one is set.
265    pub fn preferred_item_class(&self) -> Option<&Symbol> {
266        match self {
267            Self::UrgencyFirst => None,
268            Self::ItemClass(class) => Some(class),
269        }
270    }
271
272    /// Encodes the preference as portable workspace layout data.
273    pub fn to_expr(&self) -> Expr {
274        let mut fields = vec![
275            (
276                "kind",
277                Expr::Symbol(Symbol::qualified(
278                    WORKSPACE_LAYOUT_NAMESPACE,
279                    "glance-preference",
280                )),
281            ),
282            (
283                "mode",
284                build::sym(match self {
285                    Self::UrgencyFirst => "urgency-first",
286                    Self::ItemClass(_) => "item-class",
287                }),
288            ),
289        ];
290        if let Self::ItemClass(class) = self {
291            fields.push(("item-class", Expr::Symbol(class.clone())));
292        }
293        build::map(fields)
294    }
295
296    /// Decodes a Halo glance preference.
297    pub fn from_expr(expr: &Expr) -> Result<Self> {
298        reject_runtime_fields(expr)?;
299        expect_kind(expr, "glance-preference", "workspace/glance-preference")?;
300        let mode = access::required_sym(expr, "mode", "workspace/glance-preference")?;
301        if mode.namespace.is_some() {
302            return Err(Error::HostError(
303                "workspace/glance-preference mode must be unqualified".to_owned(),
304            ));
305        }
306        match mode.name.as_ref() {
307            "urgency-first" => Ok(Self::UrgencyFirst),
308            "item-class" => Ok(Self::ItemClass(access::required_sym(
309                expr,
310                "item-class",
311                "workspace/glance-preference",
312            )?)),
313            other => Err(Error::HostError(format!(
314                "unknown workspace/glance-preference mode {other}"
315            ))),
316        }
317    }
318}
319
320/// A single anchored panel in a spatial layout.
321#[derive(Clone, Debug, PartialEq)]
322pub struct PanelLayout {
323    /// Stable panel id.
324    pub id: String,
325    /// Pose-free anchor for the panel.
326    pub anchor: Anchor,
327    /// Static transform applied at render time.
328    pub transform: Transform3,
329}
330
331impl Default for PanelLayout {
332    fn default() -> Self {
333        Self {
334            id: "panel-0".to_owned(),
335            anchor: Anchor::new(AnchorSpace::World, "workspace"),
336            transform: Transform3::identity(),
337        }
338    }
339}
340
341impl PanelLayout {
342    fn from_expr(index: usize, expr: &Expr) -> Result<Self> {
343        reject_runtime_fields(expr)?;
344        let default = Self {
345            id: format!("panel-{index}"),
346            ..Self::default()
347        };
348        Ok(Self {
349            id: access::field_str(expr, "id")
350                .map(str::to_owned)
351                .unwrap_or(default.id),
352            anchor: access::field(expr, "anchor")
353                .map(Anchor::from_expr)
354                .transpose()?
355                .unwrap_or(default.anchor),
356            transform: access::field(expr, "transform")
357                .map(Transform3::from_expr)
358                .transpose()?
359                .unwrap_or(default.transform),
360        })
361    }
362}
363
364/// Returns optional spatial layout metadata embedded in a value.
365pub fn layout_expr(value: &Expr) -> Option<&Expr> {
366    access::field(value, "workspace-layout")
367        .or_else(|| access::field(value, "spatial-workspace-layout"))
368        .or_else(|| access::field(value, "spatial-layout"))
369        .or_else(|| access::field(value, "layout"))
370}
371
372/// Wraps a flat Scene in a pose-free `scene/spatial` panel layout.
373pub fn arrange_spatial_panels(scene: Expr, layout: Option<&Expr>) -> Result<Expr> {
374    let layout = layout
375        .map(spatial_layout_from_expr)
376        .transpose()?
377        .unwrap_or_else(SpatialLayout::default_arc);
378    let panels = layout
379        .panels()
380        .iter()
381        .map(|panel| {
382            sim_lib_scene::panel(
383                panel.id.clone(),
384                scene.clone(),
385                panel.anchor.clone(),
386                panel.transform.clone(),
387            )
388        })
389        .collect();
390    Ok(sim_lib_scene::spatial(panels))
391}
392
393/// Builds the table key used for persisted workspace layouts.
394pub fn layout_table_key(path: &TablePath) -> Symbol {
395    let key = if path.segments().is_empty() {
396        DEFAULT_LAYOUT_KEY.to_owned()
397    } else {
398        path.segments().join(".")
399    };
400    Symbol::qualified(WORKSPACE_LAYOUT_TABLE_NAMESPACE, key)
401}
402
403/// Builds a `table/set` operation storing `layout` at `path`.
404pub fn layout_save_op(path: &TablePath, layout: &WorkspaceLayout) -> TableOp {
405    TableOp::Set(layout_table_key(path), layout.to_expr())
406}
407
408/// Builds a `table/get` operation loading the layout stored at `path`.
409pub fn layout_load_op(path: &TablePath) -> TableOp {
410    TableOp::Get(layout_table_key(path))
411}
412
413fn spatial_layout_from_expr(expr: &Expr) -> Result<SpatialLayout> {
414    if access::field(expr, "kind").is_some() {
415        WorkspaceLayout::from_expr(expr).map(|layout| layout.to_spatial_layout())
416    } else {
417        SpatialLayout::from_expr(expr)
418    }
419}
420
421fn expect_kind(expr: &Expr, name: &str, context: &str) -> Result<()> {
422    let kind = access::required_sym(expr, "kind", context)?;
423    if kind.namespace.as_deref() == Some(WORKSPACE_LAYOUT_NAMESPACE) && kind.name.as_ref() == name {
424        Ok(())
425    } else {
426        Err(Error::HostError(format!(
427            "{context} kind must be {WORKSPACE_LAYOUT_NAMESPACE}/{name}"
428        )))
429    }
430}
431
432fn reject_runtime_fields(expr: &Expr) -> Result<()> {
433    match expr {
434        Expr::Map(entries) => {
435            for (key, value) in entries {
436                if is_runtime_key(key) {
437                    return Err(Error::HostError(
438                        "spatial layout must not carry runtime tracking fields".to_owned(),
439                    ));
440                }
441                reject_runtime_fields(value)?;
442            }
443        }
444        Expr::List(items) | Expr::Vector(items) | Expr::Set(items) => {
445            for item in items {
446                reject_runtime_fields(item)?;
447            }
448        }
449        _ => {}
450    }
451    Ok(())
452}
453
454fn is_runtime_key(key: &Expr) -> bool {
455    matches!(
456        key,
457        Expr::Symbol(symbol)
458            if symbol.namespace.is_none() && matches!(symbol.name.as_ref(), "pose" | "tick")
459    )
460}