oxiproj_core/operation.rs
1//! Core operation contract for OxiProj, ported from PROJ.
2//!
3//! PROJ models every coordinate operation as a C `struct PJ` (see
4//! `src/proj_internal.h`) that carries a set of forward/inverse function
5//! pointers — `fwd`, `inv`, `fwd3d`, `inv3d`, `fwd4d`, `inv4d` — together with
6//! the input/output unit enumeration `enum pj_io_units`. The dispatch logic in
7//! `src/fwd.cpp` (`pj_fwd`, `pj_fwd3d`, `pj_fwd4d`) and `src/inv.cpp`
8//! (`pj_inv`, `pj_inv3d`, `pj_inv4d`) implements an *arity cascade*: a higher
9//! dimensional call falls back to the lower dimensional function pointer when
10//! the higher one is absent, passing the extra coordinate slots through
11//! unchanged.
12//!
13//! This module replaces the raw function pointers with the safe, object-safe
14//! [`Operation`] trait. Its default method bodies reproduce PROJ's arity
15//! cascade (`forward_4d` → `forward_3d` → `forward_2d`, and the inverse
16//! mirror), and [`IoUnits`] mirrors `enum pj_io_units`.
17
18use crate::coord::{Coord, Lp, Lpz, Xy, Xyz};
19use crate::error::{ProjError, ProjResult};
20
21/// Input/output unit kind of an operation.
22///
23/// Ported from src/proj_internal.h (enum pj_io_units). The discriminants match
24/// PROJ's enumeration values so the two can be compared directly.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27#[repr(i32)]
28pub enum IoUnits {
29 /// Doesn't matter (or depends on pipeline neighbours)
30 Whatever = 0,
31 /// Scaled meters (right), projected system
32 Classic = 1,
33 /// Meters, projected system
34 Projected = 2,
35 /// Meters, 3D cartesian system
36 Cartesian = 3,
37 /// Radians
38 Radians = 4,
39 /// Degrees
40 Degrees = 5,
41}
42
43/// The core operation contract: a single coordinate operation that can be
44/// applied forward and (optionally) inverse, at 2D, 3D, or 4D arity.
45///
46/// This is the OxiProj equivalent of the forward/inverse function pointers on
47/// PROJ's `struct PJ` (`fwd`, `inv`, `fwd3d`, `inv3d`, `fwd4d`, `inv4d`), as
48/// declared in src/proj_internal.h and dispatched by src/fwd.cpp / src/inv.cpp.
49///
50/// The trait requires `Debug + Send + Sync` so that a boxed operation,
51/// `Box<dyn Operation>`, is itself `Debug + Send + Sync`: the transformation
52/// engine stores operations as boxed trait objects and must be able to print
53/// and share them across threads.
54///
55/// The trait is object-safe: every method takes `&self` and none is generic,
56/// so `Box<dyn Operation>` (and `&dyn Operation`) are valid types.
57pub trait Operation: core::fmt::Debug + Send + Sync {
58 /// Apply the 2D forward operation, mapping geodetic [`Lp`] to projected [`Xy`].
59 ///
60 /// The default returns `Err(ProjError::InvalidOp)`, meaning "this operation
61 /// does not provide a 2D forward".
62 ///
63 /// Rationale for choosing [`ProjError::InvalidOp`] rather than
64 /// [`ProjError::NoInverseOp`]: `NoInverseOp` (PROJ's
65 /// `PROJ_ERR_OTHER_NO_INVERSE_OP`) is semantically about *inverse*
66 /// operations, so it is the wrong signal for a missing *forward* direction.
67 /// `InvalidOp` (PROJ's `PROJ_ERR_INVALID_OP`) is the correct "no operation
68 /// of this kind is set up" indication, matching how PROJ's `pj_fwd` treats a
69 /// null forward function pointer as a setup/operation error rather than an
70 /// inverse error.
71 fn forward_2d(&self, lp: Lp) -> ProjResult<Xy> {
72 let _ = lp;
73 Err(ProjError::InvalidOp)
74 }
75
76 /// Apply the 2D inverse operation, mapping projected [`Xy`] back to geodetic [`Lp`].
77 ///
78 /// The default returns `Err(ProjError::NoInverseOp)`, meaning no inverse is
79 /// available for this operation.
80 fn inverse_2d(&self, xy: Xy) -> ProjResult<Lp> {
81 let _ = xy;
82 Err(ProjError::NoInverseOp)
83 }
84
85 /// Apply the 3D forward operation, mapping geodetic [`Lpz`] to Cartesian [`Xyz`].
86 ///
87 /// The default delegates to [`forward_2d`](Operation::forward_2d) on the
88 /// horizontal components and passes the vertical `z` slot through
89 /// unchanged, mirroring PROJ's `pj_fwd3d` cascade in src/fwd.cpp.
90 fn forward_3d(&self, lpz: Lpz) -> ProjResult<Xyz> {
91 let xy = self.forward_2d(Lp::new(lpz.lam, lpz.phi))?;
92 Ok(Xyz::new(xy.x, xy.y, lpz.z))
93 }
94
95 /// Apply the 3D inverse operation, mapping Cartesian [`Xyz`] back to geodetic [`Lpz`].
96 ///
97 /// The default delegates to [`inverse_2d`](Operation::inverse_2d) on the
98 /// horizontal components and passes the vertical `z` slot through
99 /// unchanged, mirroring PROJ's `pj_inv3d` cascade in src/inv.cpp.
100 fn inverse_3d(&self, xyz: Xyz) -> ProjResult<Lpz> {
101 let lp = self.inverse_2d(Xy::new(xyz.x, xyz.y))?;
102 Ok(Lpz::new(lp.lam, lp.phi, xyz.z))
103 }
104
105 /// Apply the 4D forward operation on a full [`Coord`] (with time).
106 ///
107 /// The default delegates to [`forward_3d`](Operation::forward_3d) on the
108 /// spatial components and passes the time `t` slot through unchanged,
109 /// mirroring PROJ's `pj_fwd4d` cascade in src/fwd.cpp.
110 fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
111 let lpz = c.lpz();
112 let t = c.v()[3];
113 let xyz = self.forward_3d(lpz)?;
114 Ok(Coord::new(xyz.x, xyz.y, xyz.z, t))
115 }
116
117 /// Apply the 4D inverse operation on a full [`Coord`] (with time).
118 ///
119 /// The default delegates to [`inverse_3d`](Operation::inverse_3d) on the
120 /// spatial components and passes the time `t` slot through unchanged,
121 /// mirroring PROJ's `pj_inv4d` cascade in src/inv.cpp.
122 fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
123 let xyz = c.xyz();
124 let t = c.v()[3];
125 let lpz = self.inverse_3d(xyz)?;
126 Ok(Coord::new(lpz.lam, lpz.phi, lpz.z, t))
127 }
128
129 /// Report whether an inverse operation is available (default `true`).
130 fn has_inverse(&self) -> bool {
131 true
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 #[cfg(feature = "no_std")]
139 use alloc::boxed::Box;
140
141 fn approx(a: f64, b: f64) -> bool {
142 (a - b).abs() < 1e-12
143 }
144
145 #[derive(Debug)]
146 struct Shift;
147
148 impl Operation for Shift {
149 fn forward_2d(&self, lp: Lp) -> ProjResult<Xy> {
150 Ok(Xy::new(lp.lam + 1.0, lp.phi + 2.0))
151 }
152 fn inverse_2d(&self, xy: Xy) -> ProjResult<Lp> {
153 Ok(Lp::new(xy.x - 1.0, xy.y - 2.0))
154 }
155 }
156
157 #[test]
158 fn shift_forward_3d_passes_z_through() -> ProjResult<()> {
159 let op = Shift;
160 let result = op.forward_3d(Lpz::new(0.1, 0.2, 99.0))?;
161 assert!(approx(result.z, 99.0));
162 assert!(approx(result.x, 1.1));
163 assert!(approx(result.y, 2.2));
164 Ok(())
165 }
166
167 #[test]
168 fn shift_forward_4d_passes_z_and_t_through() -> ProjResult<()> {
169 let op = Shift;
170 let result = op.forward_4d(Coord::new(0.1, 0.2, 99.0, 2020.0))?;
171 assert!(approx(result.v()[2], 99.0));
172 assert!(approx(result.v()[3], 2020.0));
173 assert!(approx(result.v()[0], 1.1));
174 assert!(approx(result.v()[1], 2.2));
175 Ok(())
176 }
177
178 #[test]
179 fn shift_inverse_4d_round_trips() -> ProjResult<()> {
180 let op = Shift;
181 let c = Coord::new(0.1, 0.2, 99.0, 2020.0);
182 let f = op.forward_4d(c)?;
183 let r = op.inverse_4d(f)?;
184 assert!(approx(r.v()[0], 0.1));
185 assert!(approx(r.v()[1], 0.2));
186 assert!(approx(r.v()[2], 99.0));
187 assert!(approx(r.v()[3], 2020.0));
188 Ok(())
189 }
190
191 #[derive(Debug)]
192 struct Identity4d;
193
194 impl Operation for Identity4d {
195 fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
196 Ok(c)
197 }
198 fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
199 Ok(c)
200 }
201 fn has_inverse(&self) -> bool {
202 true
203 }
204 }
205
206 #[test]
207 fn identity4d_forward_works_directly() -> ProjResult<()> {
208 let op = Identity4d;
209 let result = op.forward_4d(Coord::new(5.0, 6.0, 7.0, 8.0))?;
210 assert!(approx(result.v()[0], 5.0));
211 assert!(approx(result.v()[1], 6.0));
212 assert!(approx(result.v()[2], 7.0));
213 assert!(approx(result.v()[3], 8.0));
214 Ok(())
215 }
216
217 #[test]
218 fn identity4d_forward_2d_returns_default_invalid_op() {
219 let op = Identity4d;
220 assert!(op.forward_2d(Lp::new(0.0, 0.0)) == Err(ProjError::InvalidOp));
221 }
222
223 #[test]
224 fn identity4d_has_inverse_true() {
225 let op = Identity4d;
226 assert!(op.has_inverse());
227 }
228
229 #[derive(Debug)]
230 struct NoInverse;
231
232 impl Operation for NoInverse {
233 fn forward_2d(&self, lp: Lp) -> ProjResult<Xy> {
234 Ok(Xy::new(lp.lam, lp.phi))
235 }
236 fn has_inverse(&self) -> bool {
237 false
238 }
239 }
240
241 #[test]
242 fn no_inverse_reports_false() {
243 assert!(!NoInverse.has_inverse());
244 assert!(NoInverse.inverse_2d(Xy::new(0.0, 0.0)) == Err(ProjError::NoInverseOp));
245 }
246
247 #[test]
248 fn io_units_discriminants() {
249 assert!(IoUnits::Whatever as i32 == 0);
250 assert!(IoUnits::Classic as i32 == 1);
251 assert!(IoUnits::Projected as i32 == 2);
252 assert!(IoUnits::Cartesian as i32 == 3);
253 assert!(IoUnits::Radians as i32 == 4);
254 assert!(IoUnits::Degrees as i32 == 5);
255 }
256
257 #[test]
258 fn operation_is_object_safe() -> ProjResult<()> {
259 let b: Box<dyn Operation> = Box::new(Shift);
260 let xy = b.forward_2d(Lp::new(0.0, 0.0))?;
261 assert!(approx(xy.x, 1.0));
262 assert!(approx(xy.y, 2.0));
263 Ok(())
264 }
265}