Skip to main content

sim_lib_scene/
build.rs

1//! Scene construction helpers.
2//!
3//! Re-exports the `sim-value` builders and adds a few common scene node shapes
4//! plus a reserved-key guard. The guard turns the `kind` footgun into an
5//! immediate, clear failure: `kind` is the scene-node tag, so a plain data map
6//! must not carry a `kind` key (use a different field name). [`data_map`]
7//! debug-asserts that.
8
9use sim_kernel::{Error, Expr, Result, Symbol};
10use sim_value::access;
11
12pub use sim_value::build::{float, int, list, map, sym, text, uint, vector};
13
14use crate::model::node;
15
16/// Keys reserved for scene-node structure; plain data maps must not use them.
17pub const RESERVED_DATA_KEYS: &[&str] = &["kind"];
18
19/// A `scene/stack` node with a direction and children.
20pub fn stack(dir: &str, children: Vec<Expr>) -> Expr {
21    node(
22        "stack",
23        vec![("dir", sym(dir)), ("children", list(children))],
24    )
25}
26
27/// A `scene/box` node with a role and children.
28pub fn box_(role: &str, children: Vec<Expr>) -> Expr {
29    node(
30        "box",
31        vec![("role", sym(role)), ("children", list(children))],
32    )
33}
34
35/// A `scene/badge` node. Status carries a text token, never color alone.
36pub fn badge(status: &str, label: &str) -> Expr {
37    node(
38        "badge",
39        vec![("status", sym(status)), ("label", text(label))],
40    )
41}
42
43/// A `scene/badge-cluster` node containing visible status badges.
44pub fn badge_cluster(badges: Vec<Expr>) -> Expr {
45    node("badge-cluster", vec![("badges", list(badges))])
46}
47
48/// A `scene/text` node.
49pub fn text_node(content: impl Into<String>) -> Expr {
50    node("text", vec![("text", text(content.into()))])
51}
52
53/// The stable anchor spaces used by pose-free spatial scene nodes.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum AnchorSpace {
56    /// Head-relative content, such as a near cursor plane.
57    Head,
58    /// World-relative content, such as a pinned panel or plane.
59    World,
60    /// Screen-relative content, such as a fixed overlay.
61    Screen,
62    /// Body-relative content, such as a chest-anchored toolbelt.
63    Body,
64    /// Device-relative content, such as an accessory-aligned panel.
65    Device,
66}
67
68impl AnchorSpace {
69    /// Returns the anchor-space token used in Scene data.
70    pub fn as_name(self) -> &'static str {
71        match self {
72            Self::Head => "head",
73            Self::World => "world",
74            Self::Screen => "screen",
75            Self::Body => "body",
76            Self::Device => "device",
77        }
78    }
79
80    /// Encodes the anchor space as a bare symbol.
81    pub fn to_expr(self) -> Expr {
82        sym(self.as_name())
83    }
84
85    /// Reads an anchor-space token from a Scene expression.
86    pub fn from_expr(expr: &Expr) -> Result<Self> {
87        let Expr::Symbol(symbol) = expr else {
88            return Err(Error::Eval(
89                "scene/anchor space must be a symbol".to_owned(),
90            ));
91        };
92        Self::from_symbol(symbol)
93    }
94
95    fn from_symbol(symbol: &Symbol) -> Result<Self> {
96        if symbol.namespace.is_some() {
97            return Err(Error::Eval(format!(
98                "scene/anchor space must be unqualified, got {symbol}"
99            )));
100        }
101        match symbol.name.as_ref() {
102            "head" => Ok(Self::Head),
103            "world" => Ok(Self::World),
104            "screen" => Ok(Self::Screen),
105            "body" => Ok(Self::Body),
106            "device" => Ok(Self::Device),
107            other => Err(Error::Eval(format!("unknown scene/anchor space {other}"))),
108        }
109    }
110}
111
112/// A named pose-free anchor for spatial scene nodes.
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub struct Anchor {
115    /// The coordinate space the target belongs to.
116    pub space: AnchorSpace,
117    /// Stable target id in that space.
118    pub target: String,
119}
120
121impl Anchor {
122    /// Builds a named anchor in the given space.
123    pub fn new(space: AnchorSpace, target: impl Into<String>) -> Self {
124        Self {
125            space,
126            target: target.into(),
127        }
128    }
129
130    /// Encodes the anchor as a `scene/anchor` node.
131    pub fn to_expr(&self) -> Expr {
132        node(
133            "anchor",
134            vec![
135                ("space", self.space.to_expr()),
136                ("target", text(self.target.clone())),
137            ],
138        )
139    }
140
141    /// Reads a `scene/anchor` node.
142    pub fn from_expr(expr: &Expr) -> Result<Self> {
143        expect_kind(expr, "anchor")?;
144        Ok(Self {
145            space: AnchorSpace::from_expr(access::required(expr, "space", "scene/anchor")?)?,
146            target: access::required_str(expr, "target", "scene/anchor")?.to_owned(),
147        })
148    }
149}
150
151/// A static transform attached to spatial content before device pose is known.
152#[derive(Clone, Debug, PartialEq)]
153pub struct Transform3 {
154    /// Translation in meters.
155    pub translate_m: [f64; 3],
156    /// Rotation quaternion in `[x, y, z, w]` order.
157    pub rotate_xyzw: [f64; 4],
158    /// Scale on each axis.
159    pub scale: [f64; 3],
160}
161
162impl Transform3 {
163    /// Builds a transform from explicit translation, rotation, and scale.
164    pub fn new(translate_m: [f64; 3], rotate_xyzw: [f64; 4], scale: [f64; 3]) -> Self {
165        Self {
166            translate_m,
167            rotate_xyzw,
168            scale,
169        }
170    }
171
172    /// Returns the identity transform.
173    pub fn identity() -> Self {
174        Self::new([0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0])
175    }
176
177    /// Encodes the transform as pose-free Scene data.
178    pub fn to_expr(&self) -> Expr {
179        map(vec![
180            ("translate-m", f64_vector(self.translate_m)),
181            ("rotate-xyzw", f64_vector(self.rotate_xyzw)),
182            ("scale", f64_vector(self.scale)),
183        ])
184    }
185
186    /// Reads a transform from pose-free Scene data.
187    pub fn from_expr(expr: &Expr) -> Result<Self> {
188        let transform = Self {
189            translate_m: read_f64_vector(expr, "translate-m", "scene/Transform3")?,
190            rotate_xyzw: read_f64_vector(expr, "rotate-xyzw", "scene/Transform3")?,
191            scale: read_f64_vector(expr, "scale", "scene/Transform3")?,
192        };
193        if transform.rotate_xyzw.iter().all(|value| *value == 0.0) {
194            return Err(Error::Eval(
195                "scene/Transform3 rotation must not be the zero quaternion".to_owned(),
196            ));
197        }
198        if transform.scale.contains(&0.0) {
199            return Err(Error::Eval(
200                "scene/Transform3 scale entries must be nonzero".to_owned(),
201            ));
202        }
203        Ok(transform)
204    }
205}
206
207/// Builds a `scene/spatial` root with pose-free spatial children.
208pub fn spatial(children: Vec<Expr>) -> Expr {
209    node("spatial", vec![("children", list(children))])
210}
211
212/// Builds a `scene/stereo` root with left and right eye payloads.
213pub fn stereo(left_eye: Expr, right_eye: Expr, predict_ms: u64) -> Expr {
214    node(
215        "stereo",
216        vec![
217            ("left-eye", left_eye),
218            ("right-eye", right_eye),
219            ("predict-ms", uint(predict_ms)),
220        ],
221    )
222}
223
224/// Builds a `scene/anchor` node.
225pub fn anchor(space: AnchorSpace, target: impl Into<String>) -> Expr {
226    Anchor::new(space, target).to_expr()
227}
228
229/// Builds a pose-free `scene/panel` node.
230pub fn panel(id: impl Into<String>, body: Expr, anchor: Anchor, transform: Transform3) -> Expr {
231    node(
232        "panel",
233        vec![
234            ("id", text(id.into())),
235            ("body", body),
236            ("anchor", anchor.to_expr()),
237            ("transform", transform.to_expr()),
238        ],
239    )
240}
241
242/// Builds a pose-free `scene/gaze-cursor` node.
243pub fn gaze_cursor(anchor: Anchor, transform: Transform3) -> Expr {
244    node(
245        "gaze-cursor",
246        vec![
247            ("anchor", anchor.to_expr()),
248            ("transform", transform.to_expr()),
249        ],
250    )
251}
252
253/// Builds a pose-free `scene/hand-ray` node.
254pub fn hand_ray(hand: &str, anchor: Anchor, transform: Transform3) -> Expr {
255    node(
256        "hand-ray",
257        vec![
258            ("hand", sym(hand)),
259            ("anchor", anchor.to_expr()),
260            ("transform", transform.to_expr()),
261        ],
262    )
263}
264
265/// Builds a pose-free `scene/world-plane` node.
266pub fn world_plane(
267    id: impl Into<String>,
268    anchor: Anchor,
269    transform: Transform3,
270    size_m: [f64; 2],
271) -> Expr {
272    node(
273        "world-plane",
274        vec![
275            ("id", text(id.into())),
276            ("anchor", anchor.to_expr()),
277            ("transform", transform.to_expr()),
278            ("size-m", f64_vector(size_m)),
279        ],
280    )
281}
282
283/// Build a plain data map, asserting (in debug) that it carries no reserved
284/// scene-node key.
285pub fn data_map(entries: Vec<(&str, Expr)>) -> Expr {
286    debug_assert!(
287        entries
288            .iter()
289            .all(|(key, _)| !RESERVED_DATA_KEYS.contains(key)),
290        "data_map: a plain data map must not use a reserved scene-node key (e.g. 'kind'); \
291         rename the field"
292    );
293    map(entries)
294}
295
296fn expect_kind(expr: &Expr, expected: &str) -> Result<()> {
297    match crate::model::node_kind(expr) {
298        Some(kind)
299            if kind.namespace.as_deref() == Some(crate::kinds::SCENE_NAMESPACE)
300                && kind.name.as_ref() == expected =>
301        {
302            Ok(())
303        }
304        _ => Err(Error::Eval(format!("expected scene/{expected} node"))),
305    }
306}
307
308fn f64_vector<const N: usize>(values: [f64; N]) -> Expr {
309    vector(values.into_iter().map(float).collect())
310}
311
312fn read_f64_vector<const N: usize>(expr: &Expr, name: &str, context: &str) -> Result<[f64; N]> {
313    let Expr::Vector(items) = access::required(expr, name, context)? else {
314        return Err(Error::Eval(format!(
315            "{context} field {name} is not a vector"
316        )));
317    };
318    if items.len() != N {
319        return Err(Error::Eval(format!(
320            "{context} field {name} must contain {N} numbers"
321        )));
322    }
323    let mut out = [0.0; N];
324    for (index, item) in items.iter().enumerate() {
325        let value = access::as_f64(item)
326            .ok_or_else(|| Error::Eval(format!("{context} field {name}[{index}] is not f64")))?;
327        if !value.is_finite() {
328            return Err(Error::Eval(format!(
329                "{context} field {name}[{index}] is not finite"
330            )));
331        }
332        out[index] = value;
333    }
334    Ok(out)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn scene_shape_helpers_validate() {
343        let scene = stack(
344            "column",
345            vec![box_(
346                "summary",
347                vec![text_node("hi"), badge_cluster(vec![badge("ok", "done")])],
348            )],
349        );
350        crate::model::validate_scene(&scene).expect("helper scenes validate");
351    }
352
353    #[test]
354    fn data_map_allows_non_reserved_keys() {
355        let value = data_map(vec![("style", sym("line")), ("at", int(3))]);
356        assert!(matches!(value, Expr::Map(_)));
357    }
358
359    #[test]
360    #[should_panic(expected = "reserved scene-node key")]
361    fn data_map_rejects_a_reserved_key_in_debug() {
362        let _ = data_map(vec![("kind", sym("line"))]);
363    }
364}