Skip to main content

oxiproj_engine/
pj.rs

1//! The `Pj` operation wrapper: prepare/finalize around an inner operation.
2//!
3//! Ported from PROJ 9.8.0 `src/fwd.cpp` (`fwd_prepare`/`fwd_finalize`) and
4//! `src/inv.cpp` (`inv_prepare`/`inv_finalize`).
5
6use std::sync::Arc;
7
8use oxiproj_core::{
9    Coord, Ellipsoid, IoUnits, Lp, Operation, ProjError, ProjResult, ProjectGenericBox, Xy,
10};
11
12/// Latitude tolerance for the prepare-stage domain check (radians).
13const PJ_EPS_LAT: f64 = 1e-12;
14
15/// A prepared coordinate operation with input/output unit handling.
16///
17/// Wraps an inner [`Operation`] and applies the unit conversions, prime-meridian
18/// and central-meridian shifts, and axis bookkeeping that PROJ performs around
19/// the raw projection math.
20///
21/// # Field visibility and invariants
22///
23/// A `Pj` is a *prepared* operation: its fields are mutually consistent by
24/// construction (only [`crate::create`] and the crate-internal
25/// `registry::build_single_op` produce a valid one). All fields are therefore
26/// `pub(crate)` and reachable
27/// from outside the crate only through the read accessors on [`Pj`]. Flipping,
28/// for example, [`inverted`](Pj::inverted) or `bypass_prepare_finalize` after
29/// construction would silently invalidate the prepare/finalize contract, so
30/// those knobs are deliberately not externally writable.
31///
32/// The operation's *boundary* — its reference ellipsoid and forward-sense
33/// input/output units — is exposed read-only through the
34/// [`ellipsoid`](Pj::ellipsoid), [`left`](Pj::left) and [`right`](Pj::right)
35/// accessors, which downstream crates (the CLI, parity harnesses) depend on.
36/// Their invariant: `left`/`right` are the *forward-sense* input/output units of
37/// the whole operation, and `ellipsoid` is the reference ellipsoid the boundary
38/// math is expressed against. They are read-only; mutating them would not
39/// re-derive any of the dependent fields.
40#[derive(Debug)]
41pub struct Pj {
42    /// The inner operation (projection or conversion) driven via 4D.
43    pub(crate) operation: Box<dyn Operation>,
44    /// The reference ellipsoid.
45    ///
46    /// Stable read-only boundary field (see the type-level invariants);
47    /// read from outside the crate via [`Pj::ellipsoid`].
48    pub(crate) ellipsoid: Ellipsoid,
49    /// Effective squared eccentricity used by the map-scale factor / Tissot
50    /// metric ([`Pj::factors`] / [`Pj::factors_exact`]).
51    ///
52    /// PROJ forces `P->es = 0` in the setup of every sphere-only projection
53    /// (`src/projections/*.cpp`), so its factors are computed on the sphere even
54    /// when the user supplied an ellipsoid. OxiProj mirrors that: for a
55    /// projection whose forward ignores `es` (detected empirically at build
56    /// time), this is `0.0`; for a genuinely ellipsoidal projection it equals
57    /// `ellipsoid.es`. The corresponding `one_es` is `1.0 - factors_es`.
58    pub(crate) factors_es: f64,
59    /// Central meridian (radians).
60    pub(crate) lam0: f64,
61    /// Latitude of origin (radians).
62    pub(crate) phi0: f64,
63    /// False easting (meters, pre-scale).
64    pub(crate) x0: f64,
65    /// False northing (meters, pre-scale).
66    pub(crate) y0: f64,
67    /// False up (meters, pre-scale).
68    pub(crate) z0: f64,
69    /// Scale factor at the central meridian.
70    pub(crate) k0: f64,
71    /// Horizontal input-unit-to-meter factor.
72    pub(crate) to_meter: f64,
73    /// Horizontal meter-to-output-unit factor.
74    pub(crate) fr_meter: f64,
75    /// Vertical input-unit-to-meter factor.
76    pub(crate) vto_meter: f64,
77    /// Vertical meter-to-output-unit factor.
78    pub(crate) vfr_meter: f64,
79    /// Prime-meridian offset from Greenwich (radians).
80    pub(crate) from_greenwich: f64,
81    /// Allow longitudes outside the normal range (`+over`).
82    pub(crate) over: bool,
83    /// Geocentric latitude flag (`+geoc`).
84    pub(crate) geoc: bool,
85    /// Longitude wrap center for `+lon_wrap` (radians), applied to angular
86    /// (`IoUnits::Radians`) output on the operation's own forward pass —
87    /// PROJ's `fwd_finalize` `is_long_wrap_set` branch (`src/fwd.cpp`):
88    /// output longitude is normalized into `[center - pi, center + pi]` via
89    /// `center + adjlon(lam - center)`, taking precedence over the plain
90    /// `+over`-gated `adjlon` wrap. `None` when `+lon_wrap` was not supplied
91    /// (the ordinary `+over` wrap applies instead). PROJ's `inv_finalize`
92    /// never applies this wrap (only `fwd_finalize` does), so it is
93    /// consulted only on the operation's true forward direction — see
94    /// [`Pj::forward`]/[`Pj::inverse`]'s dispatch through `bypass_dispatch`.
95    pub(crate) lon_wrap_center: Option<f64>,
96    /// Whether this is a pass-through geographic (lat/long) operation.
97    pub(crate) is_latlong: bool,
98    /// Input units (forward direction).
99    ///
100    /// Stable read-only boundary field (see the type-level invariants);
101    /// read from outside the crate via [`Pj::left`].
102    pub(crate) left: IoUnits,
103    /// Output units (forward direction).
104    ///
105    /// Stable read-only boundary field (see the type-level invariants);
106    /// read from outside the crate via [`Pj::right`].
107    pub(crate) right: IoUnits,
108    /// Whether the operation runs inverted in its containing pipeline.
109    pub(crate) inverted: bool,
110    /// Skip prepare/finalize entirely (used by the top-level pipeline driver).
111    pub(crate) bypass_prepare_finalize: bool,
112    /// Skip this step when the pipeline runs in the forward direction.
113    pub(crate) omit_fwd: bool,
114    /// Skip this step when the pipeline runs in the inverse direction.
115    pub(crate) omit_inv: bool,
116    /// Optional AD-capable projection for exact Jacobian computation (T7.1).
117    ///
118    /// When `Some`, `factors_exact` uses this to drive forward-mode AD via
119    /// `Dual1<2>`. Set to `None` for projections that do not implement
120    /// `ProjectGeneric` or whose math is not differentiable.
121    pub(crate) ad_proj: Option<Arc<dyn ProjectGenericBox>>,
122    /// The operation name (e.g. `"helmert"`, `"axisswap"`, `"noop"`).
123    ///
124    /// Populated from the `+proj=` key at construction time. Used by the
125    /// pipeline optimizer (`pipeline_opt`) to detect and eliminate
126    /// redundant steps. Empty for the outer pipeline wrapper itself.
127    pub(crate) op_name: String,
128    /// Canonical signed axis permutation, populated only for `axisswap` steps.
129    ///
130    /// Encodes the operation's *non-inverted* forward mapping in PROJ
131    /// `+order=` form: `axisswap_order[i] = sign * (source_axis + 1)`, so entry
132    /// `i` (a value in `±1..=±4`) means output slot `i` takes input slot
133    /// `|value| - 1` multiplied by `signum(value)`. It is derived by probing the
134    /// built operation (see `registry::probe_axisswap_order`) rather than by
135    /// re-parsing parameters, so it always reflects the operation's true
136    /// behaviour. The pipeline optimizer uses this — together with the
137    /// per-step [`inverted`](Pj::inverted) flag — to cancel a pair of adjacent
138    /// `axisswap` steps only when they compose to the exact identity. `None`
139    /// for every non-`axisswap` operation.
140    pub(crate) axisswap_order: Option<[i8; 4]>,
141}
142
143impl Pj {
144    /// The inner operation (projection or conversion) as a trait reference.
145    pub fn operation(&self) -> &dyn Operation {
146        &*self.operation
147    }
148    /// The operation name (e.g. `"merc"`, `"axisswap"`); empty for a pipeline.
149    pub fn op_name(&self) -> &str {
150        &self.op_name
151    }
152    /// Whether this operation runs inverted in its containing pipeline.
153    pub fn inverted(&self) -> bool {
154        self.inverted
155    }
156    /// Whether the wrapped inner operation provides an inverse.
157    ///
158    /// Unlike the [`Operation::has_inverse`] default (which the `Pj` wrapper
159    /// inherits as always-`true`), this reports the *inner* operation's own
160    /// answer — e.g. `false` for a forward-only projection like `urm5` or a
161    /// singular `affine`. The pipeline builder uses it to reject, at creation
162    /// time, a step whose required direction has no implementation, mirroring
163    /// PROJ's `pj_create_pipeline` forward-path check (`src/pipeline.cpp`).
164    pub fn inner_has_inverse(&self) -> bool {
165        self.operation.has_inverse()
166    }
167    /// Whether this is a pass-through geographic (lat/long) operation.
168    pub fn is_latlong(&self) -> bool {
169        self.is_latlong
170    }
171    /// Central meridian (radians).
172    pub fn lam0(&self) -> f64 {
173        self.lam0
174    }
175    /// Latitude of origin (radians).
176    pub fn phi0(&self) -> f64 {
177        self.phi0
178    }
179    /// False easting (meters, pre-scale).
180    pub fn x0(&self) -> f64 {
181        self.x0
182    }
183    /// False northing (meters, pre-scale).
184    pub fn y0(&self) -> f64 {
185        self.y0
186    }
187    /// False up (meters, pre-scale).
188    pub fn z0(&self) -> f64 {
189        self.z0
190    }
191    /// Scale factor at the central meridian.
192    pub fn k0(&self) -> f64 {
193        self.k0
194    }
195    /// Horizontal input-unit-to-meter factor.
196    pub fn to_meter(&self) -> f64 {
197        self.to_meter
198    }
199    /// Horizontal meter-to-output-unit factor.
200    pub fn fr_meter(&self) -> f64 {
201        self.fr_meter
202    }
203    /// Vertical input-unit-to-meter factor.
204    pub fn vto_meter(&self) -> f64 {
205        self.vto_meter
206    }
207    /// Vertical meter-to-output-unit factor.
208    pub fn vfr_meter(&self) -> f64 {
209        self.vfr_meter
210    }
211    /// Prime-meridian offset from Greenwich (radians).
212    pub fn from_greenwich(&self) -> f64 {
213        self.from_greenwich
214    }
215    /// Allow longitudes outside the normal range (`+over`).
216    pub fn over(&self) -> bool {
217        self.over
218    }
219    /// Geocentric latitude flag (`+geoc`).
220    pub fn geoc(&self) -> bool {
221        self.geoc
222    }
223    /// The reference ellipsoid the boundary math is expressed against.
224    pub fn ellipsoid(&self) -> Ellipsoid {
225        self.ellipsoid
226    }
227    /// Input units of the whole operation (forward direction).
228    pub fn left(&self) -> IoUnits {
229        self.left
230    }
231    /// Output units of the whole operation (forward direction).
232    pub fn right(&self) -> IoUnits {
233        self.right
234    }
235
236    /// Forward transform with prepare/finalize.
237    ///
238    /// Ported from `src/fwd.cpp` (`fwd_prepare` -> operation -> `fwd_finalize`).
239    fn forward_impl(&self, c: Coord) -> ProjResult<Coord> {
240        // --- fwd_prepare ---
241        let v = c.v();
242        // Ported from src/fwd.cpp (`fwd_prepare`): `if (HUGE_VAL == coo.v[0] ||
243        // HUGE_VAL == coo.v[1] || HUGE_VAL == coo.v[2])`. PROJ's guard is an
244        // *exact* equality check against the `HUGE_VAL` (+infinity) error
245        // sentinel, not a general non-finite test: NaN input is NOT caught
246        // here and flows through into the operation ("NaN in -> NaN out"),
247        // while a genuine +infinity input short-circuits to the error
248        // coordinate. `t` (`v[3]`) is intentionally excluded, matching PROJ
249        // (its own `t == HUGE_VAL` "unspecified time" sentinel is handled
250        // separately downstream and must not be disturbed by this guard).
251        if v[0] == f64::INFINITY || v[1] == f64::INFINITY || v[2] == f64::INFINITY {
252            return Ok(Coord::error());
253        }
254
255        let prepared = if self.left == IoUnits::Radians {
256            let mut lam = v[0];
257            let mut phi = v[1];
258            let z = v[2];
259            let t = v[3];
260
261            let tt = phi.abs() - oxiproj_core::M_HALFPI;
262            if tt > PJ_EPS_LAT {
263                return Err(ProjError::InvalidCoord);
264            }
265            // Ported from src/fwd.cpp (`fwd_prepare`): `if (coo.lp.lam > 10 ||
266            // coo.lp.lam < -10) error;`. This must be written as a direct `>`
267            // / `<` disjunction, NOT as a negated `RangeInclusive::contains`
268            // check: for NaN, `!(-10.0..=10.0).contains(&lam)` is `true`
269            // (`contains` on NaN is `false`, since NaN fails both internal
270            // comparisons, so the negation flips it to `true`), which would
271            // incorrectly reject a NaN longitude that PROJ's own `lam > 10 ||
272            // lam < -10` lets through (both disjuncts are `false` for NaN).
273            // NOT equivalent to `!(-10.0..=10.0).contains(&lam)` for NaN (see
274            // the comment above) — clippy's suggested rewrite would silently
275            // reintroduce the bug, so it is deliberately overridden here.
276            #[allow(clippy::manual_range_contains)]
277            if lam > 10.0 || lam < -10.0 {
278                return Err(ProjError::InvalidCoord);
279            }
280            // Clamp latitude to ±π/2 (PROJ src/fwd.cpp).
281            phi = phi.clamp(-oxiproj_core::M_HALFPI, oxiproj_core::M_HALFPI);
282
283            // `+geoc`: the input latitude is geocentric, so fwd_prepare converts
284            // it to geographic before the projection. PROJ src/fwd.cpp calls
285            // `pj_geocentric_latitude(P, PJ_INV, ...)`, i.e. the geocentric ->
286            // geographic direction (`rone_es`), NOT the forward `one_es`. The
287            // near-pole guard (last cm) copies the latitude through unchanged,
288            // mirroring `pj_geocentric_latitude` (`src/conversions/geoc.cpp`).
289            if self.geoc && self.ellipsoid.es != 0.0 {
290                let limit = oxiproj_core::M_HALFPI - 1e-9;
291                if phi.abs() < limit {
292                    phi = (self.ellipsoid.rone_es * phi.tan()).atan();
293                }
294            }
295            if !self.over {
296                lam = oxiproj_core::adjlon(lam);
297            }
298            // `+geoidgrids` vertical grid shift is applied as its own injected
299            // `vgridshift` pipeline step (see `create::create_single`), before
300            // the projection and thus before this central-meridian subtraction,
301            // matching PROJ's `P->vgridshift` in fwd_prepare; it is not applied
302            // inline here.
303            lam = (lam - self.from_greenwich) - self.lam0;
304            if !self.over {
305                lam = oxiproj_core::adjlon(lam);
306            }
307            Coord::new(lam, phi, z, t)
308        } else {
309            c
310        };
311
312        // --- operation ---
313        let mid = self.operation.forward_4d(prepared)?;
314
315        // --- fwd_finalize ---
316        let mv = mid.v();
317        let mut x = mv[0];
318        let mut y = mv[1];
319        let mut z = mv[2];
320        let t = mv[3];
321        match self.right {
322            IoUnits::Classic | IoUnits::Projected => {
323                if self.right == IoUnits::Classic {
324                    x *= self.ellipsoid.a;
325                    y *= self.ellipsoid.a;
326                }
327                x = self.fr_meter * (x + self.x0);
328                y = self.fr_meter * (y + self.y0);
329                z = self.vfr_meter * (z + self.z0);
330            }
331            IoUnits::Radians => {
332                z = self.vfr_meter * (z + self.z0);
333                match self.lon_wrap_center {
334                    // `+lon_wrap`: wrap into [center-pi, center+pi],
335                    // unconditionally of `+over` (PROJ `fwd.cpp` has no
336                    // `over` check in the `is_long_wrap_set` branch).
337                    Some(center) => {
338                        x = center + oxiproj_core::adjlon(x - center);
339                    }
340                    None => {
341                        if !self.over {
342                            x = oxiproj_core::adjlon(x);
343                        }
344                    }
345                }
346            }
347            IoUnits::Cartesian => {
348                x *= self.fr_meter;
349                y *= self.fr_meter;
350                z *= self.fr_meter;
351                // PROJ's `is_geocent` branch runs the coordinate through an
352                // internal `cart` op here; OxiProj instead folds that geodetic
353                // <-> geocentric Cartesian math into the `geocent` operation
354                // itself (see oxiproj-transformations `conversions/geocent.rs`),
355                // which runs before this scaling, so no extra step is needed.
356            }
357            IoUnits::Whatever | IoUnits::Degrees => {}
358        }
359        // `+axis` output reordering is applied as its own injected `axisswap`
360        // pipeline step after the projection (see `create::create_single`),
361        // matching PROJ's `P->axisswap` in fwd_finalize; not applied inline.
362        Ok(Coord::new(x, y, z, t))
363    }
364
365    /// Inverse transform with prepare/finalize.
366    ///
367    /// Ported from `src/inv.cpp` (`inv_prepare` -> operation -> `inv_finalize`).
368    fn inverse_impl(&self, c: Coord) -> ProjResult<Coord> {
369        // --- inv_prepare ---
370        let v = c.v();
371        // Ported from src/inv.cpp (`inv_prepare`): `if (coo.v[0] == HUGE_VAL ||
372        // coo.v[1] == HUGE_VAL || coo.v[2] == HUGE_VAL)`. Like `forward_impl`,
373        // this is an exact-equality check against the `HUGE_VAL` (+infinity)
374        // error sentinel, not a general non-finite test — NaN input passes
375        // through to the operation ("NaN in -> NaN out") instead of being
376        // folded into an error here. `t` (`v[3]`) stays excluded, matching
377        // PROJ (its own `t == HUGE_VAL` sentinel is handled separately and
378        // must not be disturbed by this guard).
379        //
380        // PROJ's inv_prepare (src/inv.cpp) sets an errno but still yields the
381        // error coordinate, so pj_inv/pj_inv4d degrade this single point to
382        // `proj_coord_error()` rather than aborting the batch. Mirror the
383        // forward path (`forward_impl`) and return the error coordinate so a
384        // lone +infinity inverse input does not fail a whole array.
385        if v[0] == f64::INFINITY || v[1] == f64::INFINITY || v[2] == f64::INFINITY {
386            return Ok(Coord::error());
387        }
388        // Axisswap on the inverse path is applied as its own injected pipeline
389        // step (see `create::create_single`), mirroring PROJ's `P->axisswap`
390        // in inv_prepare; it is not re-applied inside this inline prepare.
391        let t = v[3];
392        let prepared = match self.right {
393            IoUnits::Whatever | IoUnits::Degrees => c,
394            IoUnits::Cartesian => {
395                let x = self.to_meter * v[0];
396                let y = self.to_meter * v[1];
397                let z = self.to_meter * v[2];
398                // PROJ's `is_geocent` branch runs the coordinate through the
399                // internal `cart` inverse here; OxiProj folds that geocentric
400                // Cartesian <-> geodetic math into the `geocent` operation
401                // itself (see oxiproj-transformations `conversions/geocent.rs`),
402                // which runs after this de-scaling, so no extra step is needed.
403                Coord::new(x, y, z, t)
404            }
405            IoUnits::Projected | IoUnits::Classic => {
406                let mut x = self.to_meter * v[0] - self.x0;
407                let mut y = self.to_meter * v[1] - self.y0;
408                let z = self.vto_meter * v[2] - self.z0;
409                if self.right == IoUnits::Classic {
410                    x *= self.ellipsoid.ra;
411                    y *= self.ellipsoid.ra;
412                }
413                Coord::new(x, y, z, t)
414            }
415            IoUnits::Radians => {
416                let z = self.vto_meter * v[2] - self.z0;
417                Coord::new(v[0], v[1], z, t)
418            }
419        };
420
421        // --- operation ---
422        let mid = self.operation.inverse_4d(prepared)?;
423
424        // --- inv_finalize ---
425        if self.left == IoUnits::Radians {
426            let mv = mid.v();
427            let mut lam = mv[0];
428            let phi = mv[1];
429            let z = mv[2];
430            let t = mv[3];
431            lam = lam + self.from_greenwich + self.lam0;
432            if !self.over {
433                lam = oxiproj_core::adjlon(lam);
434            }
435            // `+geoc`: inv_finalize converts the geographic latitude back to
436            // geocentric. PROJ src/inv.cpp calls
437            // `pj_geocentric_latitude(P, PJ_FWD, ...)`, i.e. the geographic ->
438            // geocentric direction (`one_es`) — the inverse of what fwd_prepare
439            // applied. The near-pole guard copies the latitude through unchanged.
440            let phi = if self.geoc && self.ellipsoid.es != 0.0 {
441                let limit = oxiproj_core::M_HALFPI - 1e-9;
442                if phi.abs() < limit {
443                    (self.ellipsoid.one_es * phi.tan()).atan()
444                } else {
445                    phi
446                }
447            } else {
448                phi
449            };
450            // `+geoidgrids` vgridshift and datum shifts are applied as their
451            // own injected pipeline steps (see `create::create_single`): on the
452            // inverse path the pipeline runs those steps in reverse (vgridshift
453            // INVERSE before the horizontal shift), matching PROJ's
454            // `P->vgridshift`/`P->helmert` order in inv_finalize.
455            Ok(Coord::new(lam, phi, z, t))
456        } else {
457            Ok(mid)
458        }
459    }
460
461    /// Apply the `+lon_wrap` output wrap (PROJ `fwd_finalize`'s
462    /// `is_long_wrap_set` branch) to a coordinate whose first slot is an
463    /// angular longitude, when this operation both carries a wrap center and
464    /// has angular (`Radians`) forward-sense output. A no-op otherwise,
465    /// including for a non-finite (error) longitude — mirrors PROJ's
466    /// `if (coo.lpz.lam != HUGE_VAL)` guard around the wrap.
467    fn apply_lon_wrap(&self, c: Coord) -> Coord {
468        let Some(center) = self.lon_wrap_center else {
469            return c;
470        };
471        if self.right != IoUnits::Radians {
472            return c;
473        }
474        let v = c.v();
475        if !v[0].is_finite() {
476            return c;
477        }
478        Coord::new(
479            center + oxiproj_core::adjlon(v[0] - center),
480            v[1],
481            v[2],
482            v[3],
483        )
484    }
485
486    /// Dispatch a bypassed (prepare/finalize-skipping) operation call.
487    ///
488    /// `run_inner_inverse` selects which of the inner operation's own
489    /// forward/inverse maps actually runs. PROJ's `proj_trans` flips
490    /// `PJ_FWD`/`PJ_INV` at the top level when `P->inverted`, and only the
491    /// `PJ_FWD` path threads through `fwd_finalize`'s `is_long_wrap_set`
492    /// branch (`inv_finalize` never wraps) — so the `+lon_wrap` output wrap
493    /// is applied exactly when `run_inner_inverse` is `false`.
494    fn bypass_dispatch(&self, c: Coord, run_inner_inverse: bool) -> ProjResult<Coord> {
495        if run_inner_inverse {
496            self.operation.inverse_4d(c)
497        } else {
498            let out = self.operation.forward_4d(c)?;
499            Ok(self.apply_lon_wrap(out))
500        }
501    }
502
503    /// Run the operation in the forward direction, honoring `inverted`.
504    pub fn forward(&self, c: Coord) -> ProjResult<Coord> {
505        if self.bypass_prepare_finalize {
506            return self.bypass_dispatch(c, self.inverted);
507        }
508        if self.inverted {
509            self.inverse_impl(c)
510        } else {
511            self.forward_impl(c)
512        }
513    }
514
515    /// Run the operation in the inverse direction, honoring `inverted`.
516    pub fn inverse(&self, c: Coord) -> ProjResult<Coord> {
517        if self.bypass_prepare_finalize {
518            return self.bypass_dispatch(c, !self.inverted);
519        }
520        if self.inverted {
521            self.forward_impl(c)
522        } else {
523            self.inverse_impl(c)
524        }
525    }
526
527    /// Compute map-scale factors (Tissot indicatrix) at a geographic coordinate.
528    ///
529    /// `coord` must be in geographic input units — radians if `self.left == IoUnits::Radians`,
530    /// degrees if `self.left == IoUnits::Degrees`. The coordinate layout is
531    /// `Coord::new(longitude, latitude, z, t)`.
532    ///
533    /// Returns the [`oxiproj_core::Factors`] struct describing Tissot indicatrix parameters
534    /// at the given point.
535    ///
536    /// Ported from PROJ 9.8.0 `src/factors.cpp` (`proj_factors` → `pj_factors` → `pj_deriv`).
537    pub fn factors(&self, coord: Coord) -> ProjResult<oxiproj_core::Factors> {
538        let v = coord.v();
539        // Geographic input must be angular. PROJ's `proj_factors`
540        // (src/factors.cpp) only handles the RADIANS/DEGREES boundary units;
541        // for any other left unit (Cartesian/Whatever — e.g. a pipeline `Pj` or
542        // a transformation) the coordinate is not a lon/lat pair and has no
543        // meaningful map-scale factors, so report the operation as unsupported
544        // rather than silently misinterpreting the input as degrees.
545        let (mut phi, lam_abs) = match self.left {
546            IoUnits::Radians => (v[1], v[0]),
547            IoUnits::Degrees => (v[1].to_radians(), v[0].to_radians()),
548            _ => return Err(ProjError::UnsupportedOperation),
549        };
550
551        // Effective factors eccentricity: `0` for sphere-only projections
552        // (PROJ forces `P->es = 0` in their setup), else the ellipsoid's `es`.
553        let es = self.factors_es;
554        let one_es = 1.0 - es;
555
556        // If the latitude is geocentric (`+geoc`), convert to geographic before
557        // taking the derivative, mirroring `fwd_prepare` and PROJ `pj_factors`
558        // (`if (internal->geoc) lp = pj_geocentric_latitude(internal, PJ_INV, ...)`,
559        // src/factors.cpp:50): PJ_INV is the geocentric -> geographic direction,
560        // i.e. `rone_es`, not the forward `one_es`.
561        if self.geoc && es != 0.0 {
562            phi = (self.ellipsoid.rone_es * phi.tan()).atan();
563        }
564
565        // Longitude relative to central meridian (as done in fwd_prepare)
566        let lam = lam_abs - self.from_greenwich - self.lam0;
567
568        // The closure calls the raw inner operation's forward in normalized space.
569        let op = &*self.operation;
570
571        oxiproj_core::Factors::compute(phi, lam, es, one_es, |lam_f, phi_f| {
572            let c = Coord::new(lam_f, phi_f, 0.0, 0.0);
573            let r = op.forward_4d(c)?;
574            let rv = r.v();
575            Ok((rv[0], rv[1]))
576        })
577    }
578
579    /// Compute exact distortion factors via automatic differentiation.
580    ///
581    /// Returns `Err(ProjError::UnsupportedOperation)` when:
582    /// - `ad_proj` is `None` (no AD-capable projection registered), or
583    /// - the projection's `project_fwd_generic` returns `UnsupportedOperation`.
584    ///
585    /// `coord` must be in the same geographic input units as for [`Pj::factors`].
586    pub fn factors_exact(&self, coord: Coord) -> ProjResult<oxiproj_core::FactorsExact> {
587        use oxiproj_core::autodiff::Dual1;
588
589        let ad = match &self.ad_proj {
590            None => return Err(ProjError::UnsupportedOperation),
591            Some(p) => p,
592        };
593
594        let v = coord.v();
595        // Same angular-input contract as [`Pj::factors`]: only RADIANS/DEGREES
596        // boundary units carry lon/lat; reject anything else.
597        let (mut phi, lam_abs) = match self.left {
598            IoUnits::Radians => (v[1], v[0]),
599            IoUnits::Degrees => (v[1].to_radians(), v[0].to_radians()),
600            _ => return Err(ProjError::UnsupportedOperation),
601        };
602        // Effective factors eccentricity: `0` for sphere-only projections
603        // (PROJ forces `P->es = 0` in their setup), else the ellipsoid's `es`.
604        let es = self.factors_es;
605        // Geocentric-latitude (`+geoc`) adjustment, mirroring `fwd_prepare` /
606        // PROJ `pj_factors` (PJ_INV, geocentric -> geographic, `rone_es`).
607        if self.geoc && es != 0.0 {
608            phi = (self.ellipsoid.rone_es * phi.tan()).atan();
609        }
610        let lam = lam_abs - self.from_greenwich - self.lam0;
611
612        let lam_d = Dual1::<2>::variable(lam, 0);
613        let phi_d = Dual1::<2>::variable(phi, 1);
614        let (x, y) = ad.project_fwd_dual2(lam_d, phi_d)?;
615
616        // The `ProjectGeneric` forward runs in the engine's normalised (a = 1)
617        // space — the semi-major-axis scaling is applied later by
618        // `fwd_finalize`, not by `project_fwd_generic`. So the Jacobian is the
619        // normalised-space Jacobian and MUST be reduced with `a = 1.0` (passing
620        // `self.ellipsoid.a` would wrongly shrink every magnitude by the radius
621        // whenever a ≠ 1). The squared eccentricity threads the ellipsoid
622        // radius-of-curvature correction (PROJ `factors.cpp`); `es == 0`
623        // recovers the spherical factors exactly.
624        Ok(oxiproj_core::FactorsExact::from_jacobian_es(
625            x.d[0], x.d[1], y.d[0], y.d[1], phi, 1.0, es,
626        ))
627    }
628}
629
630impl Operation for Pj {
631    fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
632        self.forward(c)
633    }
634    fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
635        self.inverse(c)
636    }
637    fn forward_2d(&self, lp: Lp) -> ProjResult<Xy> {
638        let c = self.forward(Coord::new(lp.lam, lp.phi, 0.0, 0.0))?;
639        Ok(c.xy())
640    }
641    fn inverse_2d(&self, xy: Xy) -> ProjResult<Lp> {
642        let c = self.inverse(Coord::new(xy.x, xy.y, 0.0, 0.0))?;
643        Ok(c.lp())
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    #[derive(Debug)]
652    struct Id;
653    impl Operation for Id {
654        fn forward_2d(&self, lp: Lp) -> ProjResult<Xy> {
655            Ok(Xy::new(lp.lam, lp.phi))
656        }
657        fn inverse_2d(&self, xy: Xy) -> ProjResult<Lp> {
658            Ok(Lp::new(xy.x, xy.y))
659        }
660    }
661
662    fn unit_latlong() -> Pj {
663        let ellipsoid = Ellipsoid::named("WGS84").unwrap();
664        Pj {
665            operation: Box::new(Id),
666            ellipsoid,
667            factors_es: ellipsoid.es,
668            lam0: 0.0,
669            phi0: 0.0,
670            x0: 0.0,
671            y0: 0.0,
672            z0: 0.0,
673            k0: 1.0,
674            to_meter: 1.0,
675            fr_meter: 1.0,
676            vto_meter: 1.0,
677            vfr_meter: 1.0,
678            from_greenwich: 0.0,
679            over: false,
680            geoc: false,
681            lon_wrap_center: None,
682            is_latlong: true,
683            left: IoUnits::Radians,
684            right: IoUnits::Radians,
685            inverted: false,
686            bypass_prepare_finalize: false,
687            omit_fwd: false,
688            omit_inv: false,
689            ad_proj: None,
690            op_name: String::new(),
691            axisswap_order: None,
692        }
693    }
694
695    #[test]
696    fn boundary_accessors_expose_fields() {
697        // Regression for the Pj field-hygiene change: `ellipsoid`, `left` and
698        // `right` are now `pub(crate)` and reachable from outside the crate
699        // only through these read accessors, which must return the field values.
700        let pj = unit_latlong();
701        assert_eq!(pj.left(), IoUnits::Radians);
702        assert_eq!(pj.right(), IoUnits::Radians);
703        let ell = pj.ellipsoid();
704        let named = Ellipsoid::named("WGS84").unwrap();
705        assert_eq!(ell.a, named.a);
706        assert_eq!(ell.es, named.es);
707    }
708
709    #[test]
710    fn latlong_round_trip() {
711        let pj = unit_latlong();
712        let lam = 0.2;
713        let phi = 0.5;
714        let fwd = pj.forward(Coord::new(lam, phi, 0.0, 0.0)).unwrap();
715        let inv = pj.inverse(fwd).unwrap();
716        let r = inv.v();
717        assert!((r[0] - lam).abs() < 1e-12);
718        assert!((r[1] - phi).abs() < 1e-12);
719    }
720
721    #[test]
722    fn non_finite_forward_yields_error_coord() {
723        let pj = unit_latlong();
724        let out = pj.forward(Coord::new(f64::NAN, 0.0, 0.0, 0.0)).unwrap();
725        // `Coord::is_error()` flags any non-finite component, so it stays
726        // `true` here regardless of *why* `v[0]` is non-finite — but that
727        // alone doesn't distinguish "the HUGE_VAL error sentinel" from "a
728        // genuine NaN that flowed through the transform". Pin the latter
729        // explicitly: see `nan_forward_propagates_through_not_folded_to_huge_val`.
730        assert!(out.is_error());
731    }
732
733    /// Regression test: `forward_impl`'s non-finite guard is ported from
734    /// PROJ's `fwd_prepare` `if (HUGE_VAL == coo.v[0] || ...)`, an *exact*
735    /// equality check against the `+infinity` error sentinel — not a general
736    /// `!is_finite()` test. A NaN `lam` must flow through the transform
737    /// ("NaN in -> NaN out") rather than being folded into `Coord::error()`
738    /// (all four slots `+infinity`); only a genuine `+infinity` input should
739    /// short-circuit to the error coordinate. Distinguishing the two matters
740    /// because `is_error()` alone can't tell them apart (both have a
741    /// non-finite component), and PROJ's own gie corpus has cases (e.g.
742    /// `+proj=laea +lat_0=90 ... accept NaN NaN NaN NaN expect NaN NaN NaN
743    /// NaN`) where a NaN input must produce a NaN *result*, not an error.
744    #[test]
745    fn nan_forward_propagates_through_not_folded_to_huge_val() {
746        let pj = unit_latlong();
747        let out = pj.forward(Coord::new(f64::NAN, 0.0, 0.0, 0.0)).unwrap();
748        let v = out.v();
749        assert!(v[0].is_nan(), "lam should be NaN, got {}", v[0]);
750        assert_eq!(v[1], 0.0, "finite phi should pass through unchanged");
751        assert_ne!(
752            v[1],
753            f64::INFINITY,
754            "must not have collapsed to the Coord::error() sentinel"
755        );
756
757        // A genuine +infinity input, by contrast, must still hit the HUGE_VAL
758        // guard and degrade to the error coordinate.
759        let inf_out = pj
760            .forward(Coord::new(f64::INFINITY, 0.0, 0.0, 0.0))
761            .unwrap();
762        assert!(inf_out.is_error());
763        assert_eq!(inf_out.v(), [f64::INFINITY; 4]);
764    }
765
766    #[test]
767    fn out_of_domain_latitude_rejected() {
768        let pj = unit_latlong();
769        let res = pj.forward(Coord::new(0.0, 2.0, 0.0, 0.0));
770        assert!(res.is_err());
771    }
772
773    #[test]
774    fn factors_exact_spherical_mercator_analytic() {
775        use oxiproj_core::autodiff::Dual1;
776
777        // Spherical Mercator Jacobian at lam=0.3, phi=0.5:
778        //   x = k0*lam  => dx/dlam = k0, dx/dphi = 0
779        //   y = k0*ln(tan(pi/4 + phi/2)) = k0*asinh(tan(phi))
780        //      => dy/dlam = 0, dy/dphi = k0/cos(phi) = k0*sec(phi)
781        let phi0 = 0.5_f64;
782        let lam_d = Dual1::<2>::variable(0.3, 0);
783        let phi_d = Dual1::<2>::variable(phi0, 1);
784
785        let k0 = 1.0_f64;
786        // x = k0 * lam
787        let x = lam_d * Dual1::constant(k0);
788        // y = k0 * asinh(tan(phi)) = k0 * ln(tan(phi) + sqrt(tan^2(phi) + 1))
789        let t = phi_d.tan();
790        let y = (t + (t * t + Dual1::constant(1.0)).sqrt()).ln() * Dual1::constant(k0);
791
792        // Verify Jacobian entries
793        assert!((x.d[0] - k0).abs() < 1e-12, "dx/dlam = k0");
794        assert!(x.d[1].abs() < 1e-12, "dx/dphi = 0");
795        assert!(y.d[0].abs() < 1e-12, "dy/dlam = 0");
796        let sec_phi = 1.0 / phi0.cos();
797        assert!(
798            (y.d[1] - sec_phi * k0).abs() < 1e-9,
799            "dy/dphi = sec(phi)*k0"
800        );
801
802        // FactorsExact: for spherical Mercator, h = k = sec(phi), omega = 0.
803        // Mercator is conformal (h=k, omega=0) but NOT equal-area.
804        // s = |det J| / (a^2 * cos phi) = sec(phi) / cos(phi) = sec^2(phi).
805        let fe =
806            oxiproj_core::FactorsExact::from_jacobian(x.d[0], x.d[1], y.d[0], y.d[1], phi0, 1.0);
807        assert!(
808            (fe.meridian_scale - sec_phi).abs() < 1e-9,
809            "h={}",
810            fe.meridian_scale
811        );
812        assert!(
813            (fe.parallel_scale - sec_phi).abs() < 1e-9,
814            "k={}",
815            fe.parallel_scale
816        );
817        let expected_s = sec_phi * sec_phi;
818        assert!(
819            (fe.areal_scale - expected_s).abs() < 1e-9,
820            "s={}",
821            fe.areal_scale
822        );
823        assert!(
824            fe.angular_distortion.abs() < 1e-9,
825            "omega={}",
826            fe.angular_distortion
827        );
828        assert!(fe.is_exact);
829    }
830
831    #[test]
832    fn factors_exact_no_ad_proj_returns_error() {
833        let pj = unit_latlong();
834        let res = pj.factors_exact(Coord::new(0.1, 0.3, 0.0, 0.0));
835        assert!(
836            matches!(res, Err(oxiproj_core::ProjError::UnsupportedOperation)),
837            "expected UnsupportedOperation, got {res:?}"
838        );
839    }
840
841    #[test]
842    fn non_finite_inverse_yields_error_coord() {
843        // E2 finding 3: a lone non-finite inverse input must not bubble up as
844        // an `Err` and fail a whole batch (matching PROJ's per-point graceful
845        // degradation). It resolves as `Ok`, with the *coordinate* content
846        // depending on why it's non-finite: a genuine `+infinity` (PROJ's
847        // `HUGE_VAL` sentinel) degrades to `Coord::error()`, while NaN flows
848        // through the transform instead — see
849        // `nan_inverse_propagates_through_not_folded_to_huge_val` below for
850        // that distinction pinned explicitly.
851        let pj = unit_latlong();
852        let out = pj.inverse(Coord::new(f64::NAN, 0.0, 0.0, 0.0)).unwrap();
853        assert!(out.is_error());
854        let out2 = pj
855            .inverse(Coord::new(0.0, f64::INFINITY, 0.0, 0.0))
856            .unwrap();
857        assert!(out2.is_error());
858    }
859
860    /// Regression test: `inverse_impl`'s non-finite guard, like
861    /// `forward_impl`'s, is an *exact* equality check against the `HUGE_VAL`
862    /// (+infinity) error sentinel (ported from PROJ's `inv_prepare`), not a
863    /// general `!is_finite()` test. See
864    /// `nan_forward_propagates_through_not_folded_to_huge_val` for the
865    /// rationale.
866    #[test]
867    fn nan_inverse_propagates_through_not_folded_to_huge_val() {
868        let pj = unit_latlong();
869        let out = pj.inverse(Coord::new(f64::NAN, 0.0, 0.0, 0.0)).unwrap();
870        let v = out.v();
871        assert!(v[0].is_nan(), "lam should be NaN, got {}", v[0]);
872        assert_eq!(v[1], 0.0, "finite phi should pass through unchanged");
873        assert_ne!(
874            v[1],
875            f64::INFINITY,
876            "must not have collapsed to the Coord::error() sentinel"
877        );
878    }
879
880    #[test]
881    fn factors_rejects_non_angular_left() {
882        // E2 finding 4: factors on a non-angular boundary op (Cartesian /
883        // Whatever) must report UnsupportedOperation, not silently reinterpret
884        // the coordinate as degrees.
885        let mut pj = unit_latlong();
886        pj.left = IoUnits::Cartesian;
887        assert!(matches!(
888            pj.factors(Coord::new(0.1, 0.3, 0.0, 0.0)),
889            Err(ProjError::UnsupportedOperation)
890        ));
891        assert!(matches!(
892            pj.factors_exact(Coord::new(0.1, 0.3, 0.0, 0.0)),
893            Err(ProjError::UnsupportedOperation)
894        ));
895        pj.left = IoUnits::Whatever;
896        assert!(matches!(
897            pj.factors(Coord::new(0.1, 0.3, 0.0, 0.0)),
898            Err(ProjError::UnsupportedOperation)
899        ));
900    }
901
902    #[test]
903    fn factors_applies_geoc_latitude_adjustment() {
904        // E2 finding 4: with `+geoc` the latitude is converted geocentric ->
905        // geographic before the derivative (mirroring fwd_prepare / PROJ
906        // pj_factors), so the parallel scale k of the identity op is evaluated
907        // at the adjusted latitude.
908        let phi_in = 0.5_f64;
909        let lam_in = 0.2_f64;
910
911        let mut geo = unit_latlong();
912        geo.geoc = true;
913        let f_geoc = geo.factors(Coord::new(lam_in, phi_in, 0.0, 0.0)).unwrap();
914
915        let plain = unit_latlong(); // geoc = false
916        let f_plain = plain.factors(Coord::new(lam_in, phi_in, 0.0, 0.0)).unwrap();
917
918        // For the identity op, k = sqrt(1 - es*sin^2 phi)/cos phi at the
919        // (possibly adjusted) latitude. Compute the expected geoc value directly.
920        // `+geoc` converts geocentric -> geographic (PJ_INV, `rone_es`), matching
921        // PROJ `pj_factors`/`fwd_prepare`.
922        let ell = &geo.ellipsoid;
923        let phi_adj = (ell.rone_es * phi_in.tan()).atan();
924        let expected_k = (1.0 - ell.es * phi_adj.sin().powi(2)).sqrt() / phi_adj.cos();
925        assert!(
926            (f_geoc.k - expected_k).abs() < 1e-6,
927            "geoc k: got {}, want {}",
928            f_geoc.k,
929            expected_k
930        );
931        // The adjustment moves the latitude, so geoc and non-geoc k differ.
932        assert!(
933            (f_geoc.k - f_plain.k).abs() > 1e-4,
934            "geoc adjustment must change k: geoc={}, plain={}",
935            f_geoc.k,
936            f_plain.k
937        );
938    }
939}