Skip to main content

hfx_core/
snap.rs

1//! Snap target domain types.
2
3use crate::area::Weight;
4use crate::geo::{BoundingBox, WkbGeometry};
5use crate::id::{SnapId, UnitId};
6
7/// Errors from constructing snap-target domain values.
8#[derive(Debug, thiserror::Error)]
9pub enum SnapError {
10    /// Returned when a stem role string is not supported by HFX v0.2.1.
11    #[error("unsupported stem role: {value:?}")]
12    UnsupportedStemRole {
13        /// The unsupported raw value.
14        value: String,
15    },
16}
17
18/// Indicates whether a snap target lies on the mainstem channel, a tributary,
19/// a distributary, or an unknown stem role.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum StemRole {
22    /// This feature is on the mainstem channel.
23    Mainstem,
24    /// This feature is on a tributary.
25    Tributary,
26    /// This feature is on a branch diverging at a bifurcation.
27    Distributary,
28    /// The producer does not know or does not declare the stem role.
29    Unknown,
30}
31
32impl std::fmt::Display for StemRole {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            StemRole::Mainstem => write!(f, "mainstem"),
36            StemRole::Tributary => write!(f, "tributary"),
37            StemRole::Distributary => write!(f, "distributary"),
38            StemRole::Unknown => write!(f, "unknown"),
39        }
40    }
41}
42
43impl std::str::FromStr for StemRole {
44    type Err = SnapError;
45
46    fn from_str(s: &str) -> Result<Self, Self::Err> {
47        match s {
48            "mainstem" => Ok(StemRole::Mainstem),
49            "tributary" => Ok(StemRole::Tributary),
50            "distributary" => Ok(StemRole::Distributary),
51            "unknown" => Ok(StemRole::Unknown),
52            _ => Err(SnapError::UnsupportedStemRole {
53                value: s.to_owned(),
54            }),
55        }
56    }
57}
58
59/// A candidate location to which a pour point may be snapped.
60///
61/// Each `SnapTarget` belongs to exactly one drainage unit and carries a
62/// proportional [`Weight`] used when multiple targets compete within the same
63/// unit.
64///
65/// All fields are validated at construction time via their primitive newtypes;
66/// `SnapTarget` itself performs no additional validation.
67#[derive(Debug, Clone, PartialEq)]
68pub struct SnapTarget {
69    id: SnapId,
70    unit_id: UnitId,
71    weight: Weight,
72    stem_role: Option<StemRole>,
73    bbox: Option<BoundingBox>,
74    geometry: WkbGeometry,
75}
76
77impl SnapTarget {
78    /// Construct a `SnapTarget` from its constituent validated fields.
79    ///
80    /// All arguments are already domain-typed, so no further validation is
81    /// performed here — invalid states are unrepresentable by construction.
82    pub fn new(
83        id: SnapId,
84        unit_id: UnitId,
85        weight: Weight,
86        stem_role: Option<StemRole>,
87        bbox: Option<BoundingBox>,
88        geometry: WkbGeometry,
89    ) -> Self {
90        Self {
91            id,
92            unit_id,
93            weight,
94            stem_role,
95            bbox,
96            geometry,
97        }
98    }
99
100    /// Return the snap target's unique identifier.
101    pub fn id(&self) -> SnapId {
102        self.id
103    }
104
105    /// Return the identifier of the drainage unit this target belongs to.
106    pub fn unit_id(&self) -> UnitId {
107        self.unit_id
108    }
109
110    /// Return the proportional weight used for allocation across competing targets.
111    pub fn weight(&self) -> Weight {
112        self.weight
113    }
114
115    /// Return the optional stem role for this target.
116    pub fn stem_role(&self) -> Option<StemRole> {
117        self.stem_role
118    }
119
120    /// Return a reference to the optional axis-aligned bounding box.
121    pub fn bbox(&self) -> Option<&BoundingBox> {
122        self.bbox.as_ref()
123    }
124
125    /// Return a reference to the WKB geometry of this snap target (typically a
126    /// linestring or point).
127    pub fn geometry(&self) -> &WkbGeometry {
128        &self.geometry
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn test_unit_id(raw: i64) -> UnitId {
137        UnitId::new(raw).unwrap()
138    }
139
140    fn test_snap_id(raw: i64) -> SnapId {
141        SnapId::new(raw).unwrap()
142    }
143
144    fn test_bbox() -> BoundingBox {
145        BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap()
146    }
147
148    fn test_wkb() -> WkbGeometry {
149        WkbGeometry::new(vec![0x01, 0x02, 0x03]).unwrap()
150    }
151
152    fn test_weight(raw: f32) -> Weight {
153        Weight::new(raw).unwrap()
154    }
155
156    #[test]
157    fn stem_role_variants_are_not_equal() {
158        assert_ne!(StemRole::Mainstem, StemRole::Tributary);
159        assert_ne!(StemRole::Tributary, StemRole::Distributary);
160        assert_ne!(StemRole::Mainstem, StemRole::Unknown);
161    }
162
163    #[test]
164    fn stem_role_can_be_copied_and_compared() {
165        let status = StemRole::Mainstem;
166        let copy = status;
167        assert_eq!(status, copy);
168
169        let tributary = StemRole::Tributary;
170        let copy2 = tributary;
171        assert_eq!(tributary, copy2);
172    }
173
174    #[test]
175    fn stem_role_parse_accepts_supported_values() {
176        assert_eq!("mainstem".parse::<StemRole>().unwrap(), StemRole::Mainstem);
177        assert_eq!(
178            "tributary".parse::<StemRole>().unwrap(),
179            StemRole::Tributary
180        );
181        assert_eq!(
182            "distributary".parse::<StemRole>().unwrap(),
183            StemRole::Distributary
184        );
185        assert_eq!("unknown".parse::<StemRole>().unwrap(), StemRole::Unknown);
186    }
187
188    #[test]
189    fn stem_role_distributary_roundtrips() {
190        let role: StemRole = "distributary".parse().unwrap();
191        assert_eq!(role, StemRole::Distributary);
192        assert_eq!(role.to_string(), "distributary");
193    }
194
195    #[test]
196    fn stem_role_parse_rejects_unknown_value() {
197        assert!(matches!(
198            "primary".parse::<StemRole>(),
199            Err(SnapError::UnsupportedStemRole { value }) if value == "primary"
200        ));
201    }
202
203    #[test]
204    fn snap_target_getters_return_expected_values() {
205        let snap_id = test_snap_id(7);
206        let unit_id = test_unit_id(3);
207        let weight = test_weight(0.75);
208        let stem_role = Some(StemRole::Mainstem);
209        let bbox = test_bbox();
210        let geometry = test_wkb();
211
212        let target = SnapTarget::new(
213            snap_id,
214            unit_id,
215            weight,
216            stem_role,
217            Some(bbox),
218            geometry.clone(),
219        );
220
221        assert_eq!(target.id(), snap_id);
222        assert_eq!(target.unit_id(), unit_id);
223        assert_eq!(target.weight(), weight);
224        assert_eq!(target.stem_role(), Some(StemRole::Mainstem));
225        assert_eq!(target.bbox(), Some(&bbox));
226        assert_eq!(target.geometry(), &geometry);
227    }
228
229    #[test]
230    fn unit_id_returns_unit_id_passed_to_constructor() {
231        let unit_id = test_unit_id(99);
232        let target = SnapTarget::new(
233            test_snap_id(1),
234            unit_id,
235            test_weight(1.0),
236            Some(StemRole::Tributary),
237            None,
238            test_wkb(),
239        );
240
241        assert_eq!(target.unit_id(), unit_id);
242        assert_eq!(target.bbox(), None);
243    }
244}