Skip to main content

spatialrust_viz/
layer.rs

1use crate::{PointColor, VisualPrimitive, VisualStyle, VizError, VizResult};
2
3/// Stable, non-empty layer identifier.
4#[derive(Clone, Debug, PartialEq, Eq, Hash)]
5#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
6pub struct LayerId(String);
7
8impl LayerId {
9    /// Creates a validated layer identifier.
10    pub fn try_new(value: impl Into<String>) -> VizResult<Self> {
11        let value = value.into();
12        if value.trim().is_empty() {
13            return Err(VizError::InvalidLayer("layer identifier must not be empty".into()));
14        }
15        Ok(Self(value))
16    }
17
18    /// Returns the identifier text.
19    #[must_use]
20    pub fn as_str(&self) -> &str {
21        &self.0
22    }
23}
24
25/// One named visual primitive and its presentation state.
26#[derive(Clone, Debug, PartialEq)]
27pub struct VisualLayer<'a> {
28    /// Stable layer identifier.
29    pub id: LayerId,
30    /// Human-readable label.
31    pub label: String,
32    /// Whether the layer should be rendered.
33    pub visible: bool,
34    /// Borrowed geometry.
35    pub primitive: VisualPrimitive<'a>,
36    /// Primitive style.
37    pub style: VisualStyle,
38}
39
40impl<'a> VisualLayer<'a> {
41    /// Creates a visible layer and validates style/geometry compatibility.
42    pub fn try_new(
43        id: LayerId,
44        label: impl Into<String>,
45        primitive: VisualPrimitive<'a>,
46        style: VisualStyle,
47    ) -> VizResult<Self> {
48        validate_compatibility(&primitive, &style)?;
49        Ok(Self { id, label: label.into(), visible: true, primitive, style })
50    }
51}
52
53fn validate_compatibility(primitive: &VisualPrimitive<'_>, style: &VisualStyle) -> VizResult<()> {
54    match (primitive, style) {
55        (VisualPrimitive::Points(points), VisualStyle::Points(point_style)) => {
56            match &point_style.color {
57                PointColor::Rgb if points.rgb.is_none() => Err(VizError::InvalidStyle(
58                    "RGB point style requires borrowed RGB columns".into(),
59                )),
60                PointColor::Scalar { .. } if points.scalar.is_none() => {
61                    Err(VizError::InvalidStyle(
62                        "scalar point style requires a borrowed scalar column".into(),
63                    ))
64                }
65                _ => Ok(()),
66            }
67        }
68        (VisualPrimitive::Points(_), VisualStyle::Uniform(_)) => Ok(()),
69        (_, VisualStyle::Points(_)) => {
70            Err(VizError::InvalidStyle("point style can only be applied to point geometry".into()))
71        }
72        (_, VisualStyle::Uniform(_)) => Ok(()),
73    }
74}
75
76/// Ordered collection of uniquely identified visual layers.
77#[derive(Clone, Debug, Default, PartialEq)]
78pub struct VisualScene<'a> {
79    layers: Vec<VisualLayer<'a>>,
80}
81
82impl<'a> VisualScene<'a> {
83    /// Creates an empty scene.
84    #[must_use]
85    pub const fn new() -> Self {
86        Self { layers: Vec::new() }
87    }
88
89    /// Adds a layer, rejecting duplicate identifiers.
90    pub fn add_layer(&mut self, layer: VisualLayer<'a>) -> VizResult<()> {
91        if self.layers.iter().any(|current| current.id == layer.id) {
92            return Err(VizError::InvalidLayer(format!(
93                "duplicate layer identifier `{}`",
94                layer.id.as_str()
95            )));
96        }
97        self.layers.push(layer);
98        Ok(())
99    }
100
101    /// Returns layers in deterministic insertion order.
102    #[must_use]
103    pub fn layers(&self) -> &[VisualLayer<'a>] {
104        &self.layers
105    }
106
107    /// Finds a layer by identifier.
108    #[must_use]
109    pub fn layer(&self, id: &LayerId) -> Option<&VisualLayer<'a>> {
110        self.layers.iter().find(|layer| &layer.id == id)
111    }
112
113    /// Removes and returns a layer while preserving the order of remaining layers.
114    pub fn remove_layer(&mut self, id: &LayerId) -> Option<VisualLayer<'a>> {
115        let index = self.layers.iter().position(|layer| &layer.id == id)?;
116        Some(self.layers.remove(index))
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use crate::{
123        LayerId, LineListView, LinearRgba, VisualLayer, VisualPrimitive, VisualScene, VisualStyle,
124    };
125
126    fn layer<'a>(id: &str, positions: &'a [f32]) -> VisualLayer<'a> {
127        VisualLayer::try_new(
128            LayerId::try_new(id).unwrap(),
129            id,
130            VisualPrimitive::Lines(LineListView::try_new(positions).unwrap()),
131            VisualStyle::Uniform(LinearRgba::WHITE),
132        )
133        .unwrap()
134    }
135
136    #[test]
137    fn scene_rejects_duplicate_ids_and_preserves_order() {
138        let positions = [0.0; 6];
139        let mut scene = VisualScene::new();
140        scene.add_layer(layer("first", &positions)).unwrap();
141        scene.add_layer(layer("second", &positions)).unwrap();
142        assert!(scene.add_layer(layer("first", &positions)).is_err());
143        assert_eq!(scene.layers()[0].id.as_str(), "first");
144
145        let first = LayerId::try_new("first").unwrap();
146        scene.remove_layer(&first).unwrap();
147        assert_eq!(scene.layers()[0].id.as_str(), "second");
148    }
149
150    #[test]
151    fn layer_rejects_style_without_required_attributes() {
152        use crate::{ColorMap, PointCloudView, PointColor, PointStyle, PositionColumns3};
153
154        let positions = PositionColumns3::try_new(&[0.0], &[0.0], &[0.0]).unwrap();
155        let primitive = VisualPrimitive::Points(PointCloudView::positions_only(positions));
156        let rgb_style = VisualStyle::Points(PointStyle::try_new(1.0, PointColor::Rgb).unwrap());
157        assert!(VisualLayer::try_new(
158            LayerId::try_new("points").unwrap(),
159            "points",
160            primitive,
161            rgb_style,
162        )
163        .is_err());
164
165        let scalar_style = VisualStyle::Points(
166            PointStyle::try_new(
167                1.0,
168                PointColor::Scalar { min: 0.0, max: 1.0, map: ColorMap::Viridis },
169            )
170            .unwrap(),
171        );
172        assert!(VisualLayer::try_new(
173            LayerId::try_new("scalar").unwrap(),
174            "scalar",
175            primitive,
176            scalar_style,
177        )
178        .is_err());
179    }
180}