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}