oxiproj_engine/create.rs
1//! Top-level construction and transformation entry points.
2//!
3//! Ported from PROJ 9.8.0 `src/4D_api.cpp` (`proj_create`, `proj_trans`,
4//! `proj_trans_array`) and `src/pipeline.cpp` (pipeline assembly).
5
6use crate::context::Context;
7use crate::params::{parse, ParamList};
8use crate::pipeline::Pipeline;
9use crate::pj::Pj;
10use oxiproj_core::{Coord, Direction, Ellipsoid, IoUnits, ProjError, ProjResult};
11
12/// The keys that, if present, mean a parameter set carries its own ellipsoid.
13const ELLIPSOID_KEYS: [&str; 9] = ["R", "a", "ellps", "datum", "rf", "f", "es", "e", "b"];
14
15fn has_ellipsoid_def(pl: &ParamList) -> bool {
16 ELLIPSOID_KEYS.iter().any(|k| pl.exists(k))
17}
18
19// Pipeline boundary i/o units mirror PROJ 9.8.0 src/internal.cpp
20// `pj_left`/`pj_right`, consumed by src/pipeline.cpp
21// (`P->left = pj_left(front); P->right = pj_right(back)`). A step that runs
22// inverted swaps its raw left/right when reported in forward sense, and the
23// `Classic` sentinel is normalized to `Projected`.
24
25/// Effective forward-input units of a single step, mirroring PROJ
26/// `pj_left` (src/internal.cpp): an inverted step swaps its raw left/right,
27/// and `Classic` is normalized to `Projected`.
28fn effective_left(s: &Pj) -> IoUnits {
29 let u = if s.inverted { s.right } else { s.left };
30 match u {
31 IoUnits::Classic => IoUnits::Projected,
32 other => other,
33 }
34}
35
36/// Effective forward-output units of a single step, mirroring PROJ
37/// `pj_right` (src/internal.cpp): an inverted step swaps its raw left/right,
38/// and `Classic` is normalized to `Projected`.
39fn effective_right(s: &Pj) -> IoUnits {
40 let u = if s.inverted { s.left } else { s.right };
41 match u {
42 IoUnits::Classic => IoUnits::Projected,
43 other => other,
44 }
45}
46
47/// Propagate `IoUnits::Whatever` boundary units of unit-agnostic steps
48/// (e.g. `axisswap`, `push`, `pop`, `affine`) from their neighbours, mirroring
49/// PROJ 9.8.0 `src/pipeline.cpp` (the two passes right after the steps are
50/// built, before `P->left = pj_left(front); P->right = pj_right(back)`).
51///
52/// A step whose *effective* left and right are BOTH `Whatever` adopts the units
53/// of an adjacent step so that angular pipelines whose last (or first) step is
54/// unit-agnostic still report `Radians` at the pipeline boundary — which is what
55/// drives the degree<->radian conversion in the CLI / gie harness
56/// (`proj_angular_input`/`proj_angular_output`, i.e. `pj_left == RADIANS` /
57/// `pj_right == RADIANS`). Without this, a `+proj=pipeline +step +proj=latlong
58/// +step +proj=axisswap` pipeline reports `Whatever` output and its degrees are
59/// never emitted (radians leak out); likewise a leading `push`/`pop` step would
60/// leave the pipeline input `Whatever`, so degrees are fed to a following
61/// `utm`/`merc` step verbatim as radians and blow up (out-of-domain / NaN).
62///
63/// PROJ reads neighbour units through `pj_left`/`pj_right` (the effective,
64/// inversion-aware, `Classic`->`Projected`-normalized units — our
65/// [`effective_left`]/[`effective_right`]) and writes the adopted value into the
66/// step's *raw* `left` and `right` fields, setting BOTH to the same value so the
67/// step is thereafter unit-consistent regardless of its `inverted` flag.
68///
69/// Pass 1 walks right-to-left: a `Whatever/Whatever` step takes its right
70/// neighbour's `pj_left`, provided that neighbour is not itself fully
71/// `Whatever`. Pass 2 walks left-to-right using the left neighbour's
72/// `pj_right`. Two passes let a run of unit-agnostic steps fill in from whichever
73/// side carries real units.
74fn propagate_whatever_units(steps: &mut [Pj]) {
75 let nsteps = steps.len();
76 if nsteps < 2 {
77 return;
78 }
79
80 // Pass 1: right-to-left (PROJ `for (i = nsteps - 2; i >= 0; --i)`).
81 for i in (0..nsteps - 1).rev() {
82 if effective_left(&steps[i]) == IoUnits::Whatever
83 && effective_right(&steps[i]) == IoUnits::Whatever
84 {
85 let right_left = effective_left(&steps[i + 1]);
86 let right_right = effective_right(&steps[i + 1]);
87 if right_left != right_right || right_left != IoUnits::Whatever {
88 steps[i].left = right_left;
89 steps[i].right = right_left;
90 }
91 }
92 }
93
94 // Pass 2: left-to-right (PROJ `for (i = 1; i < nsteps; i++)`).
95 for i in 1..nsteps {
96 if effective_left(&steps[i]) == IoUnits::Whatever
97 && effective_right(&steps[i]) == IoUnits::Whatever
98 {
99 let left_left = effective_left(&steps[i - 1]);
100 let left_right = effective_right(&steps[i - 1]);
101 if left_left != left_right || left_right != IoUnits::Whatever {
102 steps[i].left = left_right;
103 steps[i].right = left_right;
104 }
105 }
106 }
107}
108
109/// Construct a [`Pj`] from a proj-string.
110///
111/// Handles both single operations and `+proj=pipeline` definitions. Pipelines
112/// resolve a global ellipsoid (GRS80 by default) inherited by steps lacking an
113/// explicit ellipsoid, and are driven with prepare/finalize bypassed so each
114/// step does its own unit handling.
115pub fn create(proj_string: &str) -> ProjResult<Pj> {
116 let ctx = Context::new();
117 let params = parse(proj_string);
118
119 let is_pipeline = params
120 .entries
121 .iter()
122 .any(|(k, v)| k == "proj" && v.as_deref() == Some("pipeline"));
123
124 if is_pipeline {
125 create_pipeline(¶ms, &ctx)
126 } else {
127 create_single(¶ms, &ctx)
128 }
129}
130
131/// Construct a [`Pj`] from a proj-string using a supplied [`Context`].
132///
133/// Unlike [`create`], this allows the caller to register grid data in the
134/// context before construction so that `+nadgrids=` and `+hgridshift` steps
135/// can resolve their grids.
136pub fn create_with_ctx(proj_string: &str, ctx: &Context) -> ProjResult<Pj> {
137 let params = parse(proj_string);
138
139 let is_pipeline = params
140 .entries
141 .iter()
142 .any(|(k, v)| k == "proj" && v.as_deref() == Some("pipeline"));
143
144 if is_pipeline {
145 create_pipeline(¶ms, ctx)
146 } else {
147 create_single(¶ms, ctx)
148 }
149}
150
151/// Construct a [`Pj`] that performs the **map projection only**, ignoring any
152/// `+towgs84`/`+nadgrids`/`+geoidgrids` datum-shift directives.
153///
154/// This mirrors PROJ's legacy `proj` program (`src/apps/proj.cpp`), which drives
155/// the coordinate through `pj_fwd`/`pj_inv` and runs the map projection with its
156/// own unit/axis handling but **never** applies the datum shift — in contrast to
157/// the modern `proj_create` API (exposed by [`create`]), which synthesizes a
158/// cs2cs-style `cart`/`helmert`/`cart` pipeline whenever `+towgs84` (or a
159/// `+datum` carrying one) is present.
160///
161/// For a `+proj=pipeline` spec, or any spec without datum-shift parameters, this
162/// is identical to [`create`]; only the towgs84/nadgrids/geoidgrids injection is
163/// dropped. Prime-meridian, false easting/northing, unit and `+axis` handling
164/// are all preserved.
165pub fn create_projection_only(proj_string: &str) -> ProjResult<Pj> {
166 let ctx = Context::new();
167 let params = parse(proj_string);
168
169 let is_pipeline = params
170 .entries
171 .iter()
172 .any(|(k, v)| k == "proj" && v.as_deref() == Some("pipeline"));
173
174 if is_pipeline {
175 create_pipeline(¶ms, &ctx)
176 } else {
177 create_single_projection_only(¶ms, &ctx)
178 }
179}
180
181/// Projection-only single-operation build: like [`create_single`] but never
182/// synthesizes the `+towgs84`/`+nadgrids`/`+geoidgrids` datum-shift steps.
183/// The `+axis` re-ordering (a unit/axis concern, applied by PROJ's
184/// `fwd_finalize` regardless of datum shift) is still honored.
185fn create_single_projection_only(params: &ParamList, ctx: &Context) -> ProjResult<Pj> {
186 // `+lon_wrap` is parsed and validated by `registry::build_single_op`
187 // (which every path below eventually reaches), matching PROJ's
188 // `is_long_wrap_set`/`long_wrap_center` (`src/init.cpp`); no rejection
189 // needed here.
190
191 // Classic `+axis=` re-orders / sign-flips output axes, mirroring PROJ's
192 // fwd_finalize (`enu` is the identity and needs no axisswap step). The
193 // `axisswap` conversion consumes its own `+axis=` (named-axis form), so
194 // `+axis=` is never the classic directive for it.
195 let name_is_axisswap = params
196 .entries
197 .iter()
198 .any(|(k, v)| k == "proj" && v.as_deref() == Some("axisswap"));
199 let axis = if name_is_axisswap {
200 None
201 } else {
202 params
203 .get_str("axis")
204 .map(|s| s.trim().to_string())
205 .filter(|s| !s.is_empty() && s != "enu")
206 };
207
208 // No axis synthesis: the projection alone, with the towgs84/nadgrids/
209 // geoidgrids directives simply ignored (they never reach `build_single_op`).
210 if axis.is_none() {
211 return create_single_core(params, ctx);
212 }
213
214 let src_ell = crate::setup::setup_ellipsoid(params)?;
215 let mut pipeline_str = String::from("+proj=pipeline");
216 if params
217 .entries
218 .iter()
219 .any(|(k, v)| (k == "inv" || k == "inverted") && v.is_none())
220 {
221 pipeline_str.push_str(" +inv");
222 }
223 pipeline_str.push_str(" +step ");
224 pipeline_str.push_str(&build_projection_step(params, &src_ell));
225 if let Some(ax) = &axis {
226 pipeline_str.push_str(" +step +proj=axisswap +axis=");
227 pipeline_str.push_str(ax);
228 }
229
230 let pipeline_params = parse(&pipeline_str);
231 create_pipeline(&pipeline_params, ctx)
232}
233
234fn extract_towgs84_from_params(params: &ParamList) -> Option<String> {
235 // 1. Check for explicit +towgs84=... param
236 if let Some(s) = params.get_str("towgs84") {
237 if !s.is_empty() {
238 return Some(s.to_string());
239 }
240 }
241 // 2. Check +datum=<name> and look up in datum table
242 if let Some(datum_name) = params.get_str("datum") {
243 if !datum_name.is_empty() {
244 if let Some(d) = oxiproj_core::find_datum(datum_name) {
245 if let Some(rest) = d.defn.strip_prefix("towgs84=") {
246 return Some(rest.to_string());
247 }
248 }
249 }
250 }
251 None
252}
253
254/// Pad a `+towgs84` value list to PROJ's canonical arity.
255///
256/// PROJ (`src/datum_set.cpp`) zero-fills the seven Helmert parameters: a list
257/// with 4-6 values keeps its rotation/scale terms (zero-padded) rather than
258/// being silently truncated to a 3-parameter shift, a list with more than 7
259/// values is truncated to 7, and short lists (<=3) stay a 3-parameter shift
260/// (padded to 3). The result is a comma-joined string that `+proj=helmert
261/// +towgs84=` accepts directly.
262fn normalize_towgs84(s: &str) -> String {
263 let mut vals: Vec<String> = s.split(',').map(|v| v.trim().to_string()).collect();
264 if vals.len() > 7 {
265 vals.truncate(7);
266 } else if vals.len() > 3 {
267 while vals.len() < 7 {
268 vals.push("0".to_string());
269 }
270 } else {
271 while vals.len() < 3 {
272 vals.push("0".to_string());
273 }
274 }
275 vals.join(",")
276}
277
278/// Whether a comma-joined `+towgs84` value list is entirely zero (identity).
279fn towgs84_is_identity(vals: &str) -> bool {
280 vals.split(',')
281 .all(|v| matches!(v.trim(), "0" | "0.0" | "-0" | "-0.0" | ""))
282}
283
284/// Whether an ellipsoid is (numerically) WGS84, matching the tolerance PROJ
285/// uses in `cs2cs_emulation_setup` (`src/create.cpp`) to decide whether an
286/// all-zero shift still needs a cartesian ellipsoid round-trip.
287fn is_wgs84_ellipsoid(ell: &Ellipsoid) -> bool {
288 (ell.a - 6378137.0).abs() < 1e-8 && (ell.es - 0.0066943799901413).abs() < 1e-15
289}
290
291/// Keys stripped from the projection step of an injected pipeline: the
292/// datum-shift/axis/geoid directives (applied as their own steps), inversion
293/// flags (applied at the pipeline level), and every ellipsoid-defining key
294/// (re-emitted as explicit `+a`/`+es`).
295///
296/// `lon_wrap` is deliberately NOT stripped: PROJ applies the `+lon_wrap`
297/// output wrap in `fwd_finalize`, before the `+axis` axisswap step
298/// (`src/fwd.cpp`: `is_long_wrap_set` handling precedes `if (P->axisswap)`),
299/// so it must stay on the projection step itself rather than being dropped
300/// or hoisted onto the pipeline as a whole.
301fn strip_from_projection_step(key: &str) -> bool {
302 matches!(
303 key,
304 "towgs84" | "nadgrids" | "geoidgrids" | "axis" | "inv" | "inverted"
305 ) || ELLIPSOID_KEYS.contains(&key)
306}
307
308/// Build the projection step string for an injected pipeline: the original
309/// parameters minus the stripped keys, with the resolved source ellipsoid
310/// re-emitted as explicit `+a`/`+es` so the projection runs on exactly the
311/// ellipsoid PROJ uses for `P->fwd` (`P->a`/`P->es`), including datum-derived
312/// ones.
313fn build_projection_step(params: &ParamList, src_ell: &Ellipsoid) -> String {
314 let mut out: String = params
315 .entries
316 .iter()
317 .filter(|(k, _)| !strip_from_projection_step(k))
318 .map(|(k, v)| match v {
319 Some(val) => format!("+{}={}", k, val),
320 None => format!("+{}", k),
321 })
322 .collect::<Vec<_>>()
323 .join(" ");
324 out.push_str(&format!(" +a={} +es={}", src_ell.a, src_ell.es));
325 out
326}
327
328fn extract_nadgrids_from_params(params: &ParamList) -> Option<String> {
329 if let Some(s) = params.get_str("nadgrids") {
330 if !s.is_empty() {
331 return Some(s.to_string());
332 }
333 }
334 None
335}
336
337/// Extract a non-empty classic `+geoidgrids=` value.
338///
339/// PROJ (`cs2cs_emulation_setup`, `src/create.cpp`) turns `+geoidgrids=<grids>`
340/// into a `proj=vgridshift grids=<grids>` helper stored in `P->vgridshift` and
341/// applied by `fwd_prepare`/`inv_finalize` to move between geometric
342/// (ellipsoidal) and orthometric heights.
343fn extract_geoidgrids_from_params(params: &ParamList) -> Option<String> {
344 if let Some(s) = params.get_str("geoidgrids") {
345 if !s.is_empty() {
346 return Some(s.to_string());
347 }
348 }
349 None
350}
351
352fn create_single(params: &ParamList, ctx: &Context) -> ProjResult<Pj> {
353 // `+lon_wrap` is parsed and validated by `registry::build_single_op` on
354 // whichever step's `+proj=` operation it lands on (either the plain
355 // single operation below, or the re-emitted projection step of a
356 // synthesized datum-shift/axis pipeline — see
357 // `strip_from_projection_step`, which deliberately keeps `lon_wrap` in
358 // that step's parameters so its own `fwd_finalize` wraps the projection
359 // output before any injected `+step +proj=axisswap` runs, mirroring
360 // PROJ's `fwd_finalize` order (`is_long_wrap_set` wrap, then
361 // `P->axisswap`; `src/fwd.cpp`).
362 let name = params
363 .entries
364 .iter()
365 .find(|(k, _)| k == "proj")
366 .and_then(|(_, v)| v.as_deref())
367 .ok_or(ProjError::MissingArg)?;
368
369 // Resolve the source ellipsoid once (handles +ellps, +datum, +a/+rf, ...).
370 let src_ell = crate::setup::setup_ellipsoid(params)?;
371
372 // Build the operation (with cs2cs datum-shift / geoidgrids / axis emulation)
373 // WITHOUT consuming a top-level `+inv`, then apply the inversion here.
374 let mut pj = build_single_emulated(name, params, src_ell, ctx)?;
375 if params
376 .entries
377 .iter()
378 .any(|(k, v)| (k == "inv" || k == "inverted") && v.is_none())
379 {
380 pj.inverted = true;
381 }
382 Ok(pj)
383}
384
385/// Whether `name` refers to a map projection (or the longlat/latlong
386/// geographic pass-through), as opposed to a transformation/conversion.
387///
388/// The `oxiproj-projections` crate builds the former and returns
389/// [`ProjError::InvalidOp`] for the latter (see `registry::build_single_op`,
390/// which routes on exactly this distinction). A projection with otherwise-bad
391/// parameters still fails with a *non*-`InvalidOp` error, so it is correctly
392/// classified as a projection here. Used to gate the cs2cs datum-shift
393/// emulation, which PROJ applies only to geodetic-CRS (projection/longlat)
394/// operations.
395fn op_is_projection(name: &str, params: &ParamList, ellipsoid: &Ellipsoid) -> bool {
396 let phi0 = params.get_dms("lat_0").unwrap_or(0.0);
397 let k0 = params
398 .get_f64("k_0")
399 .or_else(|| params.get_f64("k"))
400 .unwrap_or(1.0);
401 let view = crate::params::ParamView(params);
402 let pp = oxiproj_projections::ProjParams {
403 ellipsoid,
404 phi0,
405 k0,
406 params: &view,
407 };
408 !matches!(
409 oxiproj_projections::build(name, &pp),
410 Err(ProjError::InvalidOp)
411 )
412}
413
414/// Build a single operation applying PROJ's cs2cs datum-shift / geoidgrids /
415/// axis emulation, WITHOUT consuming a top-level `+inv` (the caller applies
416/// inversion by setting `.inverted`).
417///
418/// Used both for a standalone single op ([`create_single`]) and for each
419/// pipeline step ([`create_pipeline`]), so that a step carrying
420/// `+towgs84` / `+datum` / `+nadgrids` / `+geoidgrids` / `+axis` gets the same
421/// synthesized `cart`/`helmert`/`vgridshift`/`axisswap` sub-pipeline PROJ builds
422/// per-PJ in `pj_init` (`P->helmert`, `P->cart`, `P->vgridshift`, `P->axisswap`
423/// applied by `fwd_prepare`/`fwd_finalize`). Without this, a pipeline step's
424/// `+towgs84` was silently ignored (the shift only ran for standalone ops).
425fn build_single_emulated(
426 name: &str,
427 params: &ParamList,
428 src_ell: Ellipsoid,
429 ctx: &Context,
430) -> ProjResult<Pj> {
431 let towgs84 = extract_towgs84_from_params(params);
432 let nadgrids = extract_nadgrids_from_params(params);
433 // Classic `+geoidgrids=` attaches a vertical grid shift (geometric <->
434 // orthometric height) directly to a projection, e.g.
435 // `+proj=merc +geoidgrids=egm96_15.gtx`.
436 let geoidgrids = extract_geoidgrids_from_params(params);
437 // Classic `+axis=` re-orders / sign-flips output axes. PROJ's
438 // cs2cs_emulation_setup (`src/create.cpp`) skips the axisswap synthesis
439 // when the order is already "enu"; so do we.
440 //
441 // The `axisswap` conversion consumes its OWN `+axis=` parameter (the
442 // named-axis form `+axis=neu|nue|swd`); for it, `+axis=` is NOT the classic
443 // cs2cs output-reordering directive and must be left on the operation
444 // rather than synthesized into an extra (and invalid, order-less) axisswap
445 // step. So never treat `+axis=` as the classic directive for `axisswap`.
446 let axis = if name == "axisswap" {
447 None
448 } else {
449 params
450 .get_str("axis")
451 .map(|s| s.trim().to_string())
452 .filter(|s| !s.is_empty() && s != "enu")
453 };
454
455 // The cs2cs datum-shift / geoidgrids / classic-axis emulation only applies
456 // to map projections and the longlat/latlong geographic pass-through — the
457 // geodetic-CRS operations PROJ runs it for. A transformation/conversion
458 // (helmert, cart, geocent, unitconvert, push/pop, ...) consumes these
459 // parameters itself (helmert's own `+towgs84`) or ignores them, and must
460 // NOT be wrapped: in particular `+proj=helmert +towgs84=...` would otherwise
461 // synthesize its own inner `+proj=helmert +towgs84=...` step and recurse
462 // without bound. So build the plain operation for anything without a
463 // datum-shift directive, or that is not a projection.
464 let needs_emulation =
465 towgs84.is_some() || nadgrids.is_some() || geoidgrids.is_some() || axis.is_some();
466 if !needs_emulation || !op_is_projection(name, params, &src_ell) {
467 return crate::registry::build_single_op(name, params, src_ell, ctx);
468 }
469
470 // Prepare-side (pre-projection) steps, in PROJ fwd_prepare order.
471 //
472 // Precedence: when BOTH `+nadgrids` and `+towgs84` are present, PROJ uses
473 // the grid shift and ignores the Helmert. `cs2cs_emulation_setup`
474 // (`src/create.cpp`) builds `P->hgridshift` first, then gates the Helmert
475 // on it: `p = P->hgridshift ? nullptr : pj_param_exists(params, "towgs84")`
476 // — so nadgrids wins. Check nadgrids first to match.
477 let mut prepare_steps: Vec<String> = Vec::new();
478 if let Some(grid) = &nadgrids {
479 // fwd_prepare applies hgridshift INVERSE on the forward (source ->
480 // WGS84) path; PROJ: `proj_trans(P->hgridshift, PJ_INV, coo)`.
481 prepare_steps.push(format!("+proj=hgridshift +grids={grid} +inv"));
482 } else if let Some(tw) = &towgs84 {
483 let vals = normalize_towgs84(tw);
484 if towgs84_is_identity(&vals) {
485 // Identity shift: PROJ (do_cart) still performs the cartesian
486 // round-trip when the ellipsoid differs from WGS84, else nothing.
487 if !is_wgs84_ellipsoid(&src_ell) {
488 prepare_steps.push("+proj=cart +ellps=WGS84".to_string());
489 prepare_steps.push(format!(
490 "+proj=cart +inv +a={} +es={}",
491 src_ell.a, src_ell.es
492 ));
493 }
494 } else {
495 // fwd_prepare: cart(WGS84) FWD -> helmert INV -> cart(local) INV.
496 prepare_steps.push("+proj=cart +ellps=WGS84".to_string());
497 prepare_steps.push(format!(
498 "+proj=helmert +towgs84={vals} +convention=position_vector +exact +inv"
499 ));
500 prepare_steps.push(format!(
501 "+proj=cart +inv +a={} +es={}",
502 src_ell.a, src_ell.es
503 ));
504 }
505 }
506
507 // `+geoidgrids=` -> vgridshift, applied FORWARD on the forward path *after*
508 // the horizontal datum shift, matching PROJ fwd_prepare order
509 // (`if (P->vgridshift) coo = proj_trans(P->vgridshift, PJ_FWD, coo)` runs
510 // after hgridshift/helmert; `src/fwd.cpp`). Running the pipeline in reverse
511 // then applies it INVERSE before the horizontal shift, matching
512 // inv_finalize (`proj_trans(P->vgridshift, PJ_INV, coo)` before
513 // hgridshift/helmert; `src/inv.cpp`). No `+inv` flag: forward is FWD.
514 if let Some(grids) = &geoidgrids {
515 prepare_steps.push(format!("+proj=vgridshift +grids={grids}"));
516 }
517
518 // If the shift collapsed to nothing and there is no axis synthesis, build
519 // the plain single operation (avoids a needless one-step pipeline). No
520 // inversion is applied here — the caller does that.
521 if prepare_steps.is_empty() && axis.is_none() {
522 return crate::registry::build_single_op(name, params, src_ell, ctx);
523 }
524
525 // Assemble: [prepare...] projection [axisswap]. `+axis` is applied last,
526 // mirroring PROJ's fwd_finalize (axisswap after the projection output).
527 // The synthesized pipeline is NOT inverted here; the caller applies any
528 // top-level `+inv` by flipping the returned wrapper's `.inverted`.
529 let mut pipeline_str = String::from("+proj=pipeline");
530 for step in &prepare_steps {
531 pipeline_str.push_str(" +step ");
532 pipeline_str.push_str(step);
533 }
534 pipeline_str.push_str(" +step ");
535 pipeline_str.push_str(&build_projection_step(params, &src_ell));
536 if let Some(ax) = &axis {
537 pipeline_str.push_str(" +step +proj=axisswap +axis=");
538 pipeline_str.push_str(ax);
539 }
540
541 let pipeline_params = parse(&pipeline_str);
542 create_pipeline(&pipeline_params, ctx)
543}
544
545fn create_single_core(params: &ParamList, ctx: &Context) -> ProjResult<Pj> {
546 let name = params
547 .entries
548 .iter()
549 .find(|(k, _)| k == "proj")
550 .and_then(|(_, v)| v.as_deref())
551 .ok_or(ProjError::MissingArg)?;
552
553 let ellipsoid = crate::setup::setup_ellipsoid(params)?;
554 let mut pj = crate::registry::build_single_op(name, params, ellipsoid, ctx)?;
555
556 if params
557 .entries
558 .iter()
559 .any(|(k, v)| (k == "inv" || k == "inverted") && v.is_none())
560 {
561 pj.inverted = true;
562 }
563 Ok(pj)
564}
565
566fn create_pipeline(params: &ParamList, ctx: &Context) -> ProjResult<Pj> {
567 // Split entries into a global section (before the first `step`) and one
568 // vec per subsequent step. The `step` markers are not retained.
569 //
570 // Structural validation mirrors PROJ 9.8.0 `pj_create_pipeline`
571 // (`src/pipeline.cpp`, the first `for` loop): a malformed pipeline shape is
572 // rejected at creation time with the malformed-pipeline error class.
573 let mut global: Vec<(String, Option<String>)> = Vec::new();
574 let mut steps_entries: Vec<Vec<(String, Option<String>)>> = Vec::new();
575 let mut seen_pipeline = false;
576 let mut seen_step = false;
577 for (k, v) in ¶ms.entries {
578 if k == "step" && v.is_none() {
579 if !seen_pipeline {
580 // `+step` before `+proj=pipeline` (PROJ: "+step before
581 // +proj=pipeline").
582 return Err(ProjError::InvalidOp);
583 }
584 seen_step = true;
585 steps_entries.push(Vec::new());
586 continue;
587 }
588 if k == "proj" && v.as_deref() == Some("pipeline") {
589 if seen_pipeline {
590 // A second `+proj=pipeline` — either a duplicate in the globals
591 // or a nested pipeline as a step (PROJ: "Nesting only allowed
592 // when child pipelines are wrapped in '+init's").
593 return Err(ProjError::InvalidOp);
594 }
595 seen_pipeline = true;
596 // Drop the pipeline marker from globals; it is never looked up.
597 continue;
598 }
599 if !seen_step {
600 // Global (pre-first-step) section. PROJ forbids `proj=`/`o_proj=`
601 // operators here ("proj= operator before first step not allowed").
602 if k == "proj" || k == "o_proj" {
603 return Err(ProjError::InvalidOp);
604 }
605 global.push((k.clone(), v.clone()));
606 } else if let Some(last) = steps_entries.last_mut() {
607 last.push((k.clone(), v.clone()));
608 }
609 }
610
611 if !seen_pipeline {
612 // No `+proj=pipeline` marker at all (PROJ: "no pipeline def").
613 return Err(ProjError::InvalidOp);
614 }
615
616 let global_pl = ParamList { entries: global };
617
618 // Global ellipsoid: explicit definition, else GRS80.
619 //
620 // GRS80 default per src/pipeline.cpp set_ellipsoid (a = 6378137,
621 // f = 1/298.257222101).
622 let global_ellipsoid = if has_ellipsoid_def(&global_pl) {
623 crate::setup::setup_ellipsoid(&global_pl)?
624 } else {
625 Ellipsoid::from_a_rf(6378137.0, 298.257222101)?
626 };
627
628 // Global parameters inherited by every step. PROJ's pipeline constructor
629 // (`src/pipeline.cpp`) appends all global args to each step's argument list
630 // *after* the step-specific args, so step-local keys win first-match
631 // lookups. We exclude the ellipsoid keys (already propagated via
632 // `global_ellipsoid`, which also carries the GRS80 pipeline default),
633 // inversion flags (handled by `top_inverted` / per-step `inv`), the
634 // per-step `omit_*` flags, and the pipeline `proj` marker.
635 let appended_globals: Vec<(String, Option<String>)> = global_pl
636 .entries
637 .iter()
638 .filter(|(k, _)| {
639 k != "inv"
640 && k != "inverted"
641 && k != "step"
642 && k != "proj"
643 && k != "omit_fwd"
644 && k != "omit_inv"
645 && !ELLIPSOID_KEYS.contains(&k.as_str())
646 })
647 .cloned()
648 .collect();
649
650 let mut steps: Vec<Pj> = Vec::new();
651 for step_vec in steps_entries {
652 let step_pl = ParamList { entries: step_vec };
653 let name = step_pl.get_str("proj").ok_or(ProjError::MissingArg)?;
654 // get_str returns "" for a bare `proj`; that is still missing.
655 if name.is_empty() {
656 return Err(ProjError::MissingArg);
657 }
658 let inverted = step_pl
659 .entries
660 .iter()
661 .any(|(k, v)| k == "inv" && v.is_none());
662 let omit_fwd = step_pl
663 .entries
664 .iter()
665 .any(|(k, v)| k == "omit_fwd" && v.is_none());
666 let omit_inv = step_pl
667 .entries
668 .iter()
669 .any(|(k, v)| k == "omit_inv" && v.is_none());
670 let step_ellipsoid = if has_ellipsoid_def(&step_pl) {
671 crate::setup::setup_ellipsoid(&step_pl)?
672 } else {
673 global_ellipsoid
674 };
675 // Append the inherited globals after the step-local entries so
676 // step-local keys take precedence on first-match lookups.
677 let mut augmented = step_pl.entries.clone();
678 augmented.extend(appended_globals.iter().cloned());
679 let augmented_pl = ParamList { entries: augmented };
680 // Build the step WITH cs2cs datum-shift / geoidgrids / axis emulation
681 // (via `build_single_emulated`), so a step carrying `+towgs84` /
682 // `+datum` / `+nadgrids` / `+geoidgrids` / `+axis` gets the same
683 // synthesized sub-pipeline PROJ applies per-PJ — matching PROJ's
684 // per-step `pj_init` (`P->helmert`/`P->cart`/`P->vgridshift`). The step
685 // inversion / omit flags are applied to the (possibly wrapper) result.
686 let mut step_pj = build_single_emulated(name, &augmented_pl, step_ellipsoid, ctx)?;
687 step_pj.inverted = inverted;
688 step_pj.omit_fwd = omit_fwd;
689 step_pj.omit_inv = omit_inv;
690 steps.push(step_pj);
691 }
692
693 if steps.is_empty() {
694 return Err(ProjError::MissingArg);
695 }
696
697 // Apply pipeline optimization: eliminate redundant/cancelling steps.
698 // Run after the empty-pipeline guard (a truly empty pipeline string is an
699 // error regardless) but before computing boundary i/o units (those must
700 // reflect the post-optimization boundary steps).
701 let mut steps = crate::pipeline_opt::optimize_pipeline(steps);
702
703 if steps.is_empty() {
704 // All steps were optimized away (e.g. every step was a complete no-op).
705 return Err(ProjError::MissingArg);
706 }
707
708 // Fill in `Whatever` boundary units of unit-agnostic steps from their
709 // neighbours (PROJ `src/pipeline.cpp`), so a pipeline whose boundary step is
710 // unit-agnostic (axisswap / push / pop / affine) still reports the correct
711 // angular boundary units. Must run before the `left`/`right` computation
712 // below so front/back units reflect the propagated values.
713 propagate_whatever_units(&mut steps);
714
715 let top_inverted = global_pl
716 .entries
717 .iter()
718 .any(|(k, v)| k == "inv" && v.is_none());
719
720 // --- Malformed-pipeline unit-consistency validation ---
721 //
722 // PROJ `pj_create_pipeline` (`src/pipeline.cpp`) checks, AFTER the
723 // Whatever-propagation, that each step's forward output units match the next
724 // step's forward input units, failing with the malformed-pipeline error
725 // class if they don't. A `Whatever` boundary on either side is compatible
726 // (skipped). Using the EFFECTIVE (inversion-aware, Classic->Projected
727 // normalized) units matches PROJ's `pj_right(step[i])` / `pj_left(step[i+1])`.
728 // This rejects e.g. `merc` straight into `merc` (projected metres into
729 // angular radians) or `merc` into `helmert` (projected metres into
730 // cartesian).
731 for pair in steps.windows(2) {
732 let curr_right = effective_right(&pair[0]);
733 let next_left = effective_left(&pair[1]);
734 if curr_right == IoUnits::Whatever || next_left == IoUnits::Whatever {
735 continue;
736 }
737 if curr_right != next_left {
738 return Err(ProjError::InvalidOp);
739 }
740 }
741
742 // --- Forward-path availability validation (reject un-runnable steps) ---
743 //
744 // PROJ `pj_create_pipeline` requires a valid FORWARD path: every step that
745 // is not omitted in the forward direction must provide the operation for
746 // the direction it will actually run. A step whose net direction is inverse
747 // (its own `+inv`, XOR the pipeline's global `+inv`) but whose operation has
748 // no inverse (e.g. forward-only `urm5`, or a singular `affine`) is rejected
749 // at creation with the no-inverse error — matching PROJ erroring rather than
750 // producing NaN at transform time. The `omit` role swaps under a global
751 // `+inv` (the forward pass then runs the inverse traversal), so a step
752 // omitted in the forward direction is the `omit_inv` one when `top_inverted`.
753 for step in &steps {
754 let omitted_in_fwd = if top_inverted {
755 step.omit_inv
756 } else {
757 step.omit_fwd
758 };
759 if omitted_in_fwd {
760 continue;
761 }
762 let net_inverted = top_inverted ^ step.inverted;
763 if net_inverted && !step.inner_has_inverse() {
764 return Err(ProjError::NoInverseOp);
765 }
766 }
767
768 // Pipeline i/o units mirror PROJ src/pipeline.cpp
769 // (`P->left = pj_left(front); P->right = pj_right(back)`): take the
770 // EFFECTIVE units of the boundary steps so an inverted boundary step
771 // (e.g. `+proj=cart +inv`, whose effective output is geographic radians)
772 // propagates correctly to the pipeline's forward-sense units.
773 //
774 // When the whole pipeline carries a global `+inv` (`top_inverted`), its
775 // forward pass runs the steps' inverse traversal (last step first, each
776 // inverted). The forward-sense boundary units are therefore SWAPPED
777 // relative to the non-inverted pipeline: the forward input is what the last
778 // step's forward would output (`pj_right(back)`), and the forward output is
779 // what the first step's forward would input (`pj_left(front)`). Without this
780 // swap a single-step `+proj=pipeline +inv +step +proj=urm5 +inv` reports its
781 // boundary units backwards, so the degree<->radian conversion at the
782 // boundary is skipped and the (radian-domain) step is fed raw degrees.
783 let (left, right) = if top_inverted {
784 (
785 steps
786 .last()
787 .map(effective_right)
788 .unwrap_or(IoUnits::Whatever),
789 steps
790 .first()
791 .map(effective_left)
792 .unwrap_or(IoUnits::Whatever),
793 )
794 } else {
795 (
796 steps
797 .first()
798 .map(effective_left)
799 .unwrap_or(IoUnits::Whatever),
800 steps
801 .last()
802 .map(effective_right)
803 .unwrap_or(IoUnits::Whatever),
804 )
805 };
806
807 Ok(Pj {
808 operation: Box::new(Pipeline { steps }),
809 ellipsoid: global_ellipsoid,
810 // A pipeline wrapper does not compute map-scale factors on its own
811 // (its boundary units are not a single projection's lon/lat), so the
812 // factors metric simply carries the global ellipsoid's `es`.
813 factors_es: global_ellipsoid.es,
814 lam0: 0.0,
815 phi0: 0.0,
816 x0: 0.0,
817 y0: 0.0,
818 z0: 0.0,
819 k0: 1.0,
820 to_meter: 1.0,
821 fr_meter: 1.0,
822 vto_meter: 1.0,
823 vfr_meter: 1.0,
824 from_greenwich: 0.0,
825 over: false,
826 geoc: false,
827 // The pipeline wrapper itself never carries `+lon_wrap`: a global
828 // `+lon_wrap` is inherited by every step through `appended_globals`
829 // (like `+lon_0`) and applied by the step whose own forward pass
830 // produces the pipeline's angular output, mirroring PROJ's per-step
831 // `pj_init_ctx` inheritance (`src/pipeline.cpp`). Setting it here too
832 // would double-wrap (a no-op since `adjlon` is idempotent, but not
833 // the single source of truth the field's contract implies).
834 lon_wrap_center: None,
835 is_latlong: false,
836 left,
837 right,
838 inverted: top_inverted,
839 bypass_prepare_finalize: true,
840 omit_fwd: false,
841 omit_inv: false,
842 ad_proj: None,
843 op_name: String::new(),
844 // The pipeline wrapper itself is not an `axisswap` step.
845 axisswap_order: None,
846 })
847}
848
849/// Transform a single coordinate in the given direction.
850///
851/// The `inverted` flag is honored inside [`Pj::forward`]/[`Pj::inverse`], so the
852/// direction is not swapped here.
853pub fn trans(pj: &Pj, dir: Direction, c: Coord) -> ProjResult<Coord> {
854 match dir {
855 Direction::Fwd => pj.forward(c),
856 Direction::Inv => pj.inverse(c),
857 Direction::Ident => Ok(c),
858 }
859}
860
861/// Transform a slice of coordinates in place.
862///
863/// A failed coordinate becomes [`Coord::error`]; the loop does not abort early.
864pub fn trans_array(pj: &Pj, dir: Direction, coords: &mut [Coord]) -> ProjResult<()> {
865 for c in coords.iter_mut() {
866 match trans(pj, dir, *c) {
867 Ok(r) => *c = r,
868 Err(_) => *c = Coord::error(),
869 }
870 }
871 Ok(())
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877 use oxiproj_core::DEG_TO_RAD;
878
879 // audit confirmed[11]: end-to-end coverage (through the public `create`
880 // entry point, not just `setup_ellipsoid` in isolation) that a degenerate
881 // ellipsoid is rejected for both a plain single operation and a pipeline
882 // step. Cross-checked live against Homebrew PROJ 9.7.0: `projinfo
883 // "+proj=longlat +a=0 +b=0 +no_defs +type=crs"` fails with error 1027
884 // (`pj_init_ctx: Must specify ellipsoid or sphere`).
885 #[test]
886 fn create_rejects_zero_semi_major_axis() {
887 assert!(create("+proj=longlat +a=0 +b=0 +ellps=WGS84").is_err());
888 // `+a=0` must win over an explicit `+ellps` baseline (setup.rs step
889 // 3a: "+a overrides the size"), so the trailing `+ellps=WGS84` above
890 // must not mask the rejection; assert the same without it too.
891 assert!(create("+proj=longlat +a=0 +b=0").is_err());
892 }
893
894 #[test]
895 fn create_rejects_negative_semi_major_axis() {
896 assert!(create("+proj=longlat +a=-6378137 +b=-6356752").is_err());
897 }
898
899 #[test]
900 fn create_rejects_negative_flattening() {
901 assert!(create("+proj=merc +a=6378137 +rf=-298.257223563").is_err());
902 }
903
904 #[test]
905 fn create_pipeline_rejects_degenerate_step_ellipsoid() {
906 // A single bad step must fail the whole pipeline build, matching
907 // PROJ's `pj_create_pipeline` (a step's `pj_create_argv` failure
908 // aborts the whole pipeline construction).
909 assert!(create(
910 "+proj=pipeline +step +proj=merc +ellps=WGS84 +step +proj=longlat +a=0 +b=0"
911 )
912 .is_err());
913 }
914
915 #[test]
916 fn merc_forward_and_round_trip() {
917 let pj = create("+proj=merc +ellps=WGS84").unwrap();
918 let input = Coord::new(12.0 * DEG_TO_RAD, 55.0 * DEG_TO_RAD, 0.0, 0.0);
919 let fwd = trans(&pj, Direction::Fwd, input).unwrap();
920 let f = fwd.v();
921 assert!((f[0] - 1335833.8895192828).abs() < 1e-6, "x got {}", f[0]);
922 assert!(
923 (f[1] - 7_326_837.715_045_549).abs() < 1e-6,
924 "y got {}",
925 f[1]
926 );
927 let inv = trans(&pj, Direction::Inv, fwd).unwrap();
928 let i = inv.v();
929 assert!((i[0] - 12.0 * DEG_TO_RAD).abs() < 1e-9, "lam got {}", i[0]);
930 assert!((i[1] - 55.0 * DEG_TO_RAD).abs() < 1e-9, "phi got {}", i[1]);
931 }
932
933 #[test]
934 fn utm_known_values() {
935 let pj = create("+proj=utm +zone=32 +ellps=WGS84").unwrap();
936 let at_origin = trans(
937 &pj,
938 Direction::Fwd,
939 Coord::new(9.0 * DEG_TO_RAD, 0.0, 0.0, 0.0),
940 )
941 .unwrap();
942 let o = at_origin.v();
943 assert!((o[0] - 500000.0).abs() < 1e-6, "x got {}", o[0]);
944 assert!((o[1] - 0.0).abs() < 1e-6, "y got {}", o[1]);
945
946 let p = trans(
947 &pj,
948 Direction::Fwd,
949 Coord::new(12.0 * DEG_TO_RAD, 55.0 * DEG_TO_RAD, 0.0, 0.0),
950 )
951 .unwrap();
952 let pv = p.v();
953 assert!(
954 (pv[0] - 691_875.632_137_542).abs() < 1e-6,
955 "x got {}",
956 pv[0]
957 );
958 assert!(
959 (pv[1] - 6_098_907.825_129_169).abs() < 1e-6,
960 "y got {}",
961 pv[1]
962 );
963 }
964
965 #[test]
966 fn etmerc_central_meridian() {
967 let pj = create("+proj=etmerc +lon_0=9 +ellps=WGS84").unwrap();
968 let out = trans(
969 &pj,
970 Direction::Fwd,
971 Coord::new(9.0 * DEG_TO_RAD, 50.0 * DEG_TO_RAD, 0.0, 0.0),
972 )
973 .unwrap();
974 let o = out.v();
975 assert!(o[0].abs() < 1e-6, "x got {}", o[0]);
976 assert!(
977 (o[1] - 5_540_847.041_684_148).abs() < 1e-6,
978 "y got {}",
979 o[1]
980 );
981 }
982
983 #[test]
984 fn pipeline_utm_round_trip() {
985 let pj = create(
986 "+proj=pipeline +step +proj=utm +zone=32 +ellps=WGS84 +step +proj=utm +zone=32 +ellps=WGS84 +inv",
987 )
988 .unwrap();
989 let out = trans(
990 &pj,
991 Direction::Fwd,
992 Coord::new(12.0 * DEG_TO_RAD, 55.0 * DEG_TO_RAD, 0.0, 0.0),
993 )
994 .unwrap();
995 let o = out.v();
996 assert!((o[0] - 12.0 * DEG_TO_RAD).abs() < 1e-9, "lam got {}", o[0]);
997 assert!((o[1] - 55.0 * DEG_TO_RAD).abs() < 1e-9, "phi got {}", o[1]);
998 }
999
1000 #[test]
1001 fn trans_array_maps_in_place() {
1002 let pj = create("+proj=merc +ellps=WGS84").unwrap();
1003 let mut coords = [
1004 Coord::new(12.0 * DEG_TO_RAD, 55.0 * DEG_TO_RAD, 0.0, 0.0),
1005 Coord::new(0.0, 0.0, 0.0, 0.0),
1006 ];
1007 trans_array(&pj, Direction::Fwd, &mut coords).unwrap();
1008 assert!((coords[0].v()[0] - 1335833.8895192828).abs() < 1e-6);
1009 assert!(coords[1].v()[0].abs() < 1e-6);
1010 }
1011
1012 /// Build a minimal NTv2 file in memory with a uniform shift for test use.
1013 ///
1014 /// Covers lon [-1°, 0°] and lat [0°, 1°] — a 2×2 grid whose every node
1015 /// carries the same `lat_sec`/`lon_sec` shift (arcseconds).
1016 fn make_uniform_shift_ntv2(lat_sec: f32, lon_sec: f32) -> Vec<u8> {
1017 let mut buf = Vec::new();
1018
1019 // File overview header (11 records × 16 bytes = 176 bytes)
1020 // Record 0: NUM_OREC
1021 buf.extend_from_slice(b"NUM_OREC");
1022 buf.extend_from_slice(&11i32.to_le_bytes());
1023 buf.extend_from_slice(&[0u8; 4]);
1024 // Record 1: NUM_SREC
1025 buf.extend_from_slice(b"NUM_SREC");
1026 buf.extend_from_slice(&11i32.to_le_bytes());
1027 buf.extend_from_slice(&[0u8; 4]);
1028 // Record 2: NUM_FILE (1 subfile)
1029 buf.extend_from_slice(b"NUM_FILE");
1030 buf.extend_from_slice(&1u32.to_le_bytes());
1031 buf.extend_from_slice(&[0u8; 4]);
1032 // Record 3: GS_TYPE = "SECONDS "
1033 buf.extend_from_slice(b"GS_TYPE ");
1034 buf.extend_from_slice(b"SECONDS ");
1035 // Records 4-10: unused filler (7 × 16 = 112 bytes)
1036 buf.extend_from_slice(&[0u8; 112]);
1037
1038 // Sub-grid header (11 records × 16 bytes = 176 bytes)
1039 // Record 0: SUB_NAME
1040 buf.extend_from_slice(b"SUB_NAME");
1041 buf.extend_from_slice(b"TESTGRID");
1042 // Record 1: PARENT
1043 buf.extend_from_slice(b"PARENT ");
1044 buf.extend_from_slice(b"NONE ");
1045 // Record 2: CREATED
1046 buf.extend_from_slice(b"CREATED ");
1047 buf.extend_from_slice(b"20240101");
1048 // Record 3: UPDATED
1049 buf.extend_from_slice(b"UPDATED ");
1050 buf.extend_from_slice(b"20240101");
1051 // Record 4: S_LAT = 0 arcsec (equator)
1052 buf.extend_from_slice(b"S_LAT ");
1053 buf.extend_from_slice(&0.0f64.to_le_bytes());
1054 // Record 5: N_LAT = 3600 arcsec = 1 degree
1055 buf.extend_from_slice(b"N_LAT ");
1056 buf.extend_from_slice(&3600.0f64.to_le_bytes());
1057 // Record 6: E_LONG = 0 arcsec (east boundary, positive-westward = 0°W = 0°E)
1058 buf.extend_from_slice(b"E_LONG ");
1059 buf.extend_from_slice(&0.0f64.to_le_bytes());
1060 // Record 7: W_LONG = 3600 arcsec (west boundary, positive-westward = 1°W = -1°E)
1061 buf.extend_from_slice(b"W_LONG ");
1062 buf.extend_from_slice(&3600.0f64.to_le_bytes());
1063 // Record 8: LAT_INC = 3600 arcsec (1 degree → 2 rows)
1064 buf.extend_from_slice(b"LAT_INC ");
1065 buf.extend_from_slice(&3600.0f64.to_le_bytes());
1066 // Record 9: LONG_INC = 3600 arcsec (1 degree → 2 cols)
1067 buf.extend_from_slice(b"LONG_INC");
1068 buf.extend_from_slice(&3600.0f64.to_le_bytes());
1069 // Record 10: GS_COUNT = 4 (2×2 grid)
1070 buf.extend_from_slice(b"GS_COUNT");
1071 buf.extend_from_slice(&4i32.to_le_bytes());
1072 buf.extend_from_slice(&[0u8; 4]);
1073
1074 // Data: 4 cells × 4 f32 = 64 bytes (uniform shift everywhere)
1075 for _ in 0..4 {
1076 buf.extend_from_slice(&lat_sec.to_le_bytes()); // lat shift
1077 buf.extend_from_slice(&lon_sec.to_le_bytes()); // lon shift
1078 buf.extend_from_slice(&0.0f32.to_le_bytes()); // lat accuracy
1079 buf.extend_from_slice(&0.0f32.to_le_bytes()); // lon accuracy
1080 }
1081
1082 buf
1083 }
1084
1085 /// Zero-shift NTv2 grid: `hgridshift` is an identity over the covered region.
1086 fn make_zero_shift_ntv2() -> Vec<u8> {
1087 make_uniform_shift_ntv2(0.0, 0.0)
1088 }
1089
1090 #[test]
1091 fn nadgrids_pipeline_constructed() {
1092 // Verify that +nadgrids= on a single proj-string builds a pipeline
1093 // containing an hgridshift step, and that the grid is used for the shift.
1094 // We use a zero-shift grid so the round-trip is identity.
1095 let mut ctx = crate::context::Context::new();
1096 ctx.register_grid("test.gsb", make_zero_shift_ntv2());
1097
1098 // merc with nadgrids — the point (lon=-0.5°, lat=0.5°) is inside the grid
1099 let pj = create_with_ctx("+proj=merc +ellps=WGS84 +nadgrids=test.gsb", &ctx).unwrap();
1100
1101 // With zero shift, result should match plain merc
1102 let plain = create("+proj=merc +ellps=WGS84").unwrap();
1103 let coord = Coord::new(-0.5 * DEG_TO_RAD, 0.5 * DEG_TO_RAD, 0.0, 0.0);
1104 let shifted = trans(&pj, Direction::Fwd, coord).unwrap();
1105 let expected = trans(&plain, Direction::Fwd, coord).unwrap();
1106 let sv = shifted.v();
1107 let ev = expected.v();
1108 assert!(
1109 (sv[0] - ev[0]).abs() < 1e-3,
1110 "x: got {}, expected {}",
1111 sv[0],
1112 ev[0]
1113 );
1114 assert!(
1115 (sv[1] - ev[1]).abs() < 1e-3,
1116 "y: got {}, expected {}",
1117 sv[1],
1118 ev[1]
1119 );
1120 }
1121
1122 #[test]
1123 fn pipeline_omit_fwd_skips_middle_step() {
1124 // Pipeline: noop -> axisswap(+omit_fwd) -> noop
1125 // Forward: middle step omitted => x,y unchanged
1126 // Inverse: middle step applied => x,y swapped
1127 let pj = create(
1128 "+proj=pipeline \
1129 +step +proj=noop \
1130 +step +proj=axisswap +order=2,1 +omit_fwd \
1131 +step +proj=noop",
1132 )
1133 .unwrap();
1134
1135 let input = Coord::new(1.0, 2.0, 3.0, 0.0);
1136
1137 // Forward: middle step omitted, so output equals input (noop+noop)
1138 let fwd = trans(&pj, Direction::Fwd, input).unwrap();
1139 let fv = fwd.v();
1140 assert!(
1141 (fv[0] - 1.0).abs() < 1e-12,
1142 "forward x should be 1.0, got {}",
1143 fv[0]
1144 );
1145 assert!(
1146 (fv[1] - 2.0).abs() < 1e-12,
1147 "forward y should be 2.0, got {}",
1148 fv[1]
1149 );
1150
1151 // Inverse: middle step included (axisswap), so x and y are swapped
1152 let inv = trans(&pj, Direction::Inv, input).unwrap();
1153 let iv = inv.v();
1154 assert!(
1155 (iv[0] - 2.0).abs() < 1e-12,
1156 "inverse x should be 2.0 (swapped), got {}",
1157 iv[0]
1158 );
1159 assert!(
1160 (iv[1] - 1.0).abs() < 1e-12,
1161 "inverse y should be 1.0 (swapped), got {}",
1162 iv[1]
1163 );
1164 }
1165
1166 #[test]
1167 fn optimizer_removes_double_omit_step() {
1168 // A step with both +omit_fwd and +omit_inv is never executed — the
1169 // pipeline optimizer removes it. The remaining noop steps let coordinates
1170 // pass through unchanged.
1171 let pj = create(
1172 "+proj=pipeline \
1173 +step +proj=noop \
1174 +step +proj=noop +omit_fwd +omit_inv \
1175 +step +proj=noop",
1176 )
1177 .expect("pipeline with double-omit middle step");
1178 let input = Coord::new(1.0, 2.0, 3.0, 0.0);
1179 let fwd = trans(&pj, Direction::Fwd, input).expect("forward through optimized pipeline");
1180 let fv = fwd.v();
1181 assert!(
1182 (fv[0] - 1.0).abs() < 1e-12,
1183 "x should pass through unchanged, got {}",
1184 fv[0]
1185 );
1186 assert!(
1187 (fv[1] - 2.0).abs() < 1e-12,
1188 "y should pass through unchanged, got {}",
1189 fv[1]
1190 );
1191 }
1192
1193 #[test]
1194 fn optimizer_cancels_axisswap_pair() {
1195 // Two consecutive axisswap(2,1) steps cancel each other (axisswap is
1196 // self-inverse). After optimization the pipeline reduces to noop only,
1197 // and the input coordinate passes through unchanged.
1198 let pj = create(
1199 "+proj=pipeline \
1200 +step +proj=noop \
1201 +step +proj=axisswap +order=2,1 \
1202 +step +proj=axisswap +order=2,1",
1203 )
1204 .expect("pipeline with noop + cancelling axisswap pair");
1205 let input = Coord::new(3.0, 7.0, 0.0, 0.0);
1206 let fwd = trans(&pj, Direction::Fwd, input).expect("forward through optimized pipeline");
1207 let fv = fwd.v();
1208 assert!(
1209 (fv[0] - 3.0).abs() < 1e-12,
1210 "x should be unchanged after axisswap cancellation, got {}",
1211 fv[0]
1212 );
1213 assert!(
1214 (fv[1] - 7.0).abs() < 1e-12,
1215 "y should be unchanged after axisswap cancellation, got {}",
1216 fv[1]
1217 );
1218 }
1219
1220 #[test]
1221 fn optimizer_keeps_non_cancelling_three_cycle_pair() {
1222 // Regression for the axisswap-cancellation bug: `+order=2,3,1` twice is
1223 // a 3-cycle squared (net permutation 3,1,2), NOT the identity. The
1224 // optimizer must NOT remove the pair. Verified against PROJ `cct`:
1225 // echo "1 2 3 0" | cct +proj=pipeline \
1226 // +step +proj=axisswap +order=2,3,1 \
1227 // +step +proj=axisswap +order=2,3,1 -> 3 1 2 0
1228 let pj = create(
1229 "+proj=pipeline \
1230 +step +proj=axisswap +order=2,3,1 \
1231 +step +proj=axisswap +order=2,3,1",
1232 )
1233 .expect("two 3-cycle axisswap steps");
1234 let out = trans(&pj, Direction::Fwd, Coord::new(1.0, 2.0, 3.0, 4.0))
1235 .unwrap()
1236 .v();
1237 assert_eq!(
1238 out,
1239 [3.0, 1.0, 2.0, 4.0],
1240 "3-cycle applied twice must be the net permutation 3,1,2, not identity"
1241 );
1242 }
1243
1244 #[test]
1245 fn optimizer_keeps_swap_then_negate_pair() {
1246 // `+order=2,1` then `+order=1,-2` is not the identity — keep both.
1247 // Verified against PROJ `cct`:
1248 // echo "1 2 3 0" | cct +proj=pipeline \
1249 // +step +proj=axisswap +order=2,1 \
1250 // +step +proj=axisswap +order=1,-2 -> 2 -1 3 0
1251 let pj = create(
1252 "+proj=pipeline \
1253 +step +proj=axisswap +order=2,1 \
1254 +step +proj=axisswap +order=1,-2",
1255 )
1256 .expect("swap then negate axisswap steps");
1257 let out = trans(&pj, Direction::Fwd, Coord::new(1.0, 2.0, 3.0, 4.0))
1258 .unwrap()
1259 .v();
1260 assert_eq!(
1261 out,
1262 [2.0, -1.0, 3.0, 4.0],
1263 "swap-then-negate must not be optimized to identity"
1264 );
1265 }
1266
1267 #[test]
1268 fn optimizer_keeps_rotation_twice_pair() {
1269 // `+order=-2,1` twice is a 90° rotation squared (= 180° rotation),
1270 // not the identity. Verified against PROJ `cct`:
1271 // echo "1 2 3 0" | cct +proj=pipeline \
1272 // +step +proj=axisswap +order=-2,1 \
1273 // +step +proj=axisswap +order=-2,1 -> -1 -2 3 0
1274 let pj = create(
1275 "+proj=pipeline \
1276 +step +proj=axisswap +order=-2,1 \
1277 +step +proj=axisswap +order=-2,1",
1278 )
1279 .expect("two rotation axisswap steps");
1280 let out = trans(&pj, Direction::Fwd, Coord::new(1.0, 2.0, 3.0, 4.0))
1281 .unwrap()
1282 .v();
1283 assert_eq!(
1284 out,
1285 [-1.0, -2.0, 3.0, 4.0],
1286 "90° rotation applied twice must be a 180° rotation, not identity"
1287 );
1288 }
1289
1290 #[test]
1291 fn optimizer_cancels_true_inverse_axisswap_pair() {
1292 // A permutation followed by its own inverse (`+order=2,3,1` then the
1293 // same `+inv`) is the identity and IS safely cancelled. A leading `noop`
1294 // keeps the pipeline non-empty after the pair is removed; the output
1295 // must equal the input.
1296 let pj = create(
1297 "+proj=pipeline \
1298 +step +proj=noop \
1299 +step +proj=axisswap +order=2,3,1 \
1300 +step +proj=axisswap +order=2,3,1 +inv",
1301 )
1302 .expect("noop + axisswap and its inverse");
1303 let out = trans(&pj, Direction::Fwd, Coord::new(1.0, 2.0, 3.0, 4.0))
1304 .unwrap()
1305 .v();
1306 assert_eq!(
1307 out,
1308 [1.0, 2.0, 3.0, 4.0],
1309 "a permutation and its inverse compose to the identity"
1310 );
1311 }
1312
1313 #[test]
1314 fn optimizer_all_cancelled_returns_error() {
1315 // Every step has both +omit_fwd and +omit_inv — all steps are removed by
1316 // the optimizer, leaving zero steps. This must return an error.
1317 let result = create(
1318 "+proj=pipeline \
1319 +step +proj=noop +omit_fwd +omit_inv \
1320 +step +proj=noop +omit_fwd +omit_inv",
1321 );
1322 assert!(
1323 result.is_err(),
1324 "all-cancelled pipeline must return an error"
1325 );
1326 }
1327
1328 // ---------------------------------------------------------------------
1329 // E1 findings: single-string datum shift / nadgrids / axis direction,
1330 // pipeline global inheritance, towgs84 padding. Expected values are
1331 // derived from Homebrew PROJ 9.x `cs2cs`/`proj` (see test comments).
1332 // ---------------------------------------------------------------------
1333
1334 fn assert_xy(got: Coord, ex: [f64; 2], tol: f64, label: &str) {
1335 let g = got.v();
1336 assert!(
1337 (g[0] - ex[0]).abs() < tol,
1338 "{label} x: got {}, want {}",
1339 g[0],
1340 ex[0]
1341 );
1342 assert!(
1343 (g[1] - ex[1]).abs() < tol,
1344 "{label} y: got {}, want {}",
1345 g[1],
1346 ex[1]
1347 );
1348 }
1349
1350 #[test]
1351 fn towgs84_3param_matches_cs2cs() {
1352 // cs2cs +proj=longlat +datum=WGS84 +to +proj=utm +zone=32 +ellps=intl
1353 // +towgs84=-87,-98,-121 at 9 0 -> 500083.152337543 120.954458759
1354 let pj = create("+proj=utm +zone=32 +ellps=intl +towgs84=-87,-98,-121").unwrap();
1355 let out = trans(
1356 &pj,
1357 Direction::Fwd,
1358 Coord::new(9.0 * DEG_TO_RAD, 0.0, 0.0, 0.0),
1359 )
1360 .unwrap();
1361 assert_xy(
1362 out,
1363 [500083.152337543, 120.954458759],
1364 1e-4,
1365 "utm intl towgs84",
1366 );
1367 }
1368
1369 #[test]
1370 fn towgs84_7param_matches_cs2cs() {
1371 // merc intl +towgs84=59.47,-5.04,187.44,0.47,-0.1,1.024,-4.5993 at 174 -41
1372 // -> 19370336.340988029 -4984632.904443800
1373 let pj =
1374 create("+proj=merc +ellps=intl +towgs84=59.47,-5.04,187.44,0.47,-0.1,1.024,-4.5993")
1375 .unwrap();
1376 let out = trans(
1377 &pj,
1378 Direction::Fwd,
1379 Coord::new(174.0 * DEG_TO_RAD, -41.0 * DEG_TO_RAD, 0.0, 0.0),
1380 )
1381 .unwrap();
1382 assert_xy(
1383 out,
1384 [19370336.34098803, -4984632.9044438],
1385 1e-2,
1386 "merc intl 7-param towgs84",
1387 );
1388 }
1389
1390 #[test]
1391 fn towgs84_datum_resolves_ellipsoid() {
1392 // +datum=carthage must resolve to ellps=clrk80ign for BOTH the cart
1393 // round-trip and the projection step, matching the explicit form.
1394 let via_datum = create("+proj=merc +datum=carthage").unwrap();
1395 let via_explicit = create("+proj=merc +ellps=clrk80ign +towgs84=-263.0,6.0,431.0").unwrap();
1396 let p = Coord::new(10.0 * DEG_TO_RAD, 34.0 * DEG_TO_RAD, 0.0, 0.0);
1397 let a = trans(&via_datum, Direction::Fwd, p).unwrap();
1398 let b = trans(&via_explicit, Direction::Fwd, p).unwrap();
1399 // cs2cs ground truth for the explicit form.
1400 assert_xy(
1401 a,
1402 [1113152.342921798, 4004375.48761513],
1403 1e-2,
1404 "merc datum=carthage",
1405 );
1406 let (av, bv) = (a.v(), b.v());
1407 assert!(
1408 (av[0] - bv[0]).abs() < 1e-6 && (av[1] - bv[1]).abs() < 1e-6,
1409 "datum vs explicit ellps mismatch: {av:?} vs {bv:?}"
1410 );
1411 }
1412
1413 #[test]
1414 fn towgs84_4param_zero_padded_not_dropped() {
1415 // finding 4: a 4-6 element list keeps its rotation term (padded to 7),
1416 // never silently truncated to a 3-parameter shift.
1417 assert_eq!(normalize_towgs84("1,2,3,4"), "1,2,3,4,0,0,0");
1418 assert_eq!(normalize_towgs84("1,2"), "1,2,0");
1419 assert_eq!(normalize_towgs84("1,2,3,4,5,6,7,8"), "1,2,3,4,5,6,7");
1420 // End-to-end: +towgs84=1,2,3,4 equals the explicit +towgs84=1,2,3,4,0,0,0.
1421 let four = create("+proj=merc +ellps=intl +towgs84=1,2,3,4").unwrap();
1422 let out = trans(
1423 &four,
1424 Direction::Fwd,
1425 Coord::new(10.0 * DEG_TO_RAD, 40.0 * DEG_TO_RAD, 0.0, 0.0),
1426 )
1427 .unwrap();
1428 // cs2cs ground truth for +towgs84=1,2,3,4,0,0,0.
1429 assert_xy(
1430 out,
1431 [1113337.903887998, 4838632.996508759],
1432 1e-2,
1433 "merc intl 4-param towgs84",
1434 );
1435 }
1436
1437 #[test]
1438 fn nadgrids_applies_hgridshift_inverse() {
1439 // finding 2: on the forward path PROJ applies hgridshift INVERSE.
1440 let mut ctx = crate::context::Context::new();
1441 ctx.register_grid("shift.gsb", make_uniform_shift_ntv2(5.0, -8.0));
1442 let p = Coord::new(-0.5 * DEG_TO_RAD, 0.5 * DEG_TO_RAD, 0.0, 0.0);
1443
1444 let engine = create_with_ctx("+proj=merc +ellps=WGS84 +nadgrids=shift.gsb", &ctx).unwrap();
1445 let inv_ref = create_with_ctx(
1446 "+proj=pipeline +step +proj=hgridshift +grids=shift.gsb +inv \
1447 +step +proj=merc +ellps=WGS84",
1448 &ctx,
1449 )
1450 .unwrap();
1451 let fwd_ref = create_with_ctx(
1452 "+proj=pipeline +step +proj=hgridshift +grids=shift.gsb \
1453 +step +proj=merc +ellps=WGS84",
1454 &ctx,
1455 )
1456 .unwrap();
1457
1458 let e = trans(&engine, Direction::Fwd, p).unwrap().v();
1459 let i = trans(&inv_ref, Direction::Fwd, p).unwrap().v();
1460 let f = trans(&fwd_ref, Direction::Fwd, p).unwrap().v();
1461
1462 assert!(
1463 (e[0] - i[0]).abs() < 1e-9 && (e[1] - i[1]).abs() < 1e-9,
1464 "engine must apply hgridshift +inv: engine={e:?} inv_ref={i:?}"
1465 );
1466 // With a nonzero shift the forward direction is measurably different,
1467 // proving the injected step really is inverse.
1468 assert!(
1469 (e[0] - f[0]).abs() > 1e-3 || (e[1] - f[1]).abs() > 1e-3,
1470 "engine must NOT match forward hgridshift: engine={e:?} fwd_ref={f:?}"
1471 );
1472 }
1473
1474 #[test]
1475 fn nadgrids_projection_uses_source_ellipsoid() {
1476 // The injected projection step keeps the source ellipsoid (intl),
1477 // not the pipeline GRS80/WGS84 default.
1478 let mut ctx = crate::context::Context::new();
1479 ctx.register_grid("shift.gsb", make_uniform_shift_ntv2(5.0, -8.0));
1480 let p = Coord::new(-0.5 * DEG_TO_RAD, 0.5 * DEG_TO_RAD, 0.0, 0.0);
1481 let engine = create_with_ctx("+proj=merc +ellps=intl +nadgrids=shift.gsb", &ctx).unwrap();
1482 let reference = create_with_ctx(
1483 "+proj=pipeline +step +proj=hgridshift +grids=shift.gsb +inv \
1484 +step +proj=merc +ellps=intl",
1485 &ctx,
1486 )
1487 .unwrap();
1488 let e = trans(&engine, Direction::Fwd, p).unwrap().v();
1489 let r = trans(&reference, Direction::Fwd, p).unwrap().v();
1490 assert!(
1491 (e[0] - r[0]).abs() < 1e-9 && (e[1] - r[1]).abs() < 1e-9,
1492 "nadgrids projection must use intl: engine={e:?} reference={r:?}"
1493 );
1494 }
1495
1496 #[test]
1497 fn nadgrids_takes_precedence_over_towgs84() {
1498 // When BOTH +nadgrids and +towgs84 are present, PROJ uses the grid
1499 // shift and ignores the Helmert: cs2cs_emulation_setup builds
1500 // P->hgridshift first, then `p = P->hgridshift ? nullptr : towgs84`
1501 // (src/create.cpp). The engine must match the nadgrids-only result and
1502 // NOT the towgs84-only result.
1503 let mut ctx = crate::context::Context::new();
1504 ctx.register_grid("shift.gsb", make_uniform_shift_ntv2(5.0, -8.0));
1505 let p = Coord::new(-0.5 * DEG_TO_RAD, 0.5 * DEG_TO_RAD, 0.0, 0.0);
1506
1507 let both = create_with_ctx(
1508 "+proj=merc +ellps=WGS84 +towgs84=100,200,300 +nadgrids=shift.gsb",
1509 &ctx,
1510 )
1511 .unwrap();
1512 let grid_only =
1513 create_with_ctx("+proj=merc +ellps=WGS84 +nadgrids=shift.gsb", &ctx).unwrap();
1514 let helmert_only =
1515 create_with_ctx("+proj=merc +ellps=WGS84 +towgs84=100,200,300", &ctx).unwrap();
1516
1517 let b = trans(&both, Direction::Fwd, p).unwrap().v();
1518 let g = trans(&grid_only, Direction::Fwd, p).unwrap().v();
1519 let h = trans(&helmert_only, Direction::Fwd, p).unwrap().v();
1520
1521 assert!(
1522 (b[0] - g[0]).abs() < 1e-9 && (b[1] - g[1]).abs() < 1e-9,
1523 "nadgrids must win over towgs84: both={b:?} grid_only={g:?}"
1524 );
1525 assert!(
1526 (b[0] - h[0]).abs() > 1e-3 || (b[1] - h[1]).abs() > 1e-3,
1527 "with both present the towgs84 path must NOT be used: both={b:?} helmert_only={h:?}"
1528 );
1529 }
1530
1531 #[test]
1532 fn pipeline_global_param_inherited_by_steps() {
1533 // finding 3: a global +lon_0 is inherited by each step (PROJ pipeline.cpp).
1534 // Verified against PROJ cct: GLOBAL == STEP-LOCAL, both != NONE.
1535 let p = Coord::new(12.0 * DEG_TO_RAD, 55.0 * DEG_TO_RAD, 0.0, 0.0);
1536 let global = create("+proj=pipeline +lon_0=10 +step +proj=merc +ellps=WGS84").unwrap();
1537 let local = create("+proj=pipeline +step +proj=merc +lon_0=10 +ellps=WGS84").unwrap();
1538 let none = create("+proj=pipeline +step +proj=merc +ellps=WGS84").unwrap();
1539 let g = trans(&global, Direction::Fwd, p).unwrap().v();
1540 let l = trans(&local, Direction::Fwd, p).unwrap().v();
1541 let n = trans(&none, Direction::Fwd, p).unwrap().v();
1542 assert!(
1543 (g[0] - l[0]).abs() < 1e-9 && (g[1] - l[1]).abs() < 1e-9,
1544 "global lon_0 must equal step-local lon_0: {g:?} vs {l:?}"
1545 );
1546 assert!(
1547 (g[0] - n[0]).abs() > 1.0,
1548 "global lon_0 must change the result vs none: {g:?} vs {n:?}"
1549 );
1550 }
1551
1552 #[test]
1553 fn axis_wsu_negates_output() {
1554 // finding 6: +axis=wsu negates x and y (verified against cs2cs).
1555 let pj = create("+proj=merc +axis=wsu +ellps=WGS84").unwrap();
1556 let out = trans(
1557 &pj,
1558 Direction::Fwd,
1559 Coord::new(12.0 * DEG_TO_RAD, 55.0 * DEG_TO_RAD, 0.0, 0.0),
1560 )
1561 .unwrap();
1562 assert_xy(
1563 out,
1564 [-1335833.8895192828, -7_326_837.715_045_549],
1565 1e-6,
1566 "merc axis=wsu",
1567 );
1568 }
1569
1570 #[test]
1571 fn axis_neu_swaps_output() {
1572 // finding 6: +axis=neu swaps x and y.
1573 let pj = create("+proj=merc +axis=neu +ellps=WGS84").unwrap();
1574 let out = trans(
1575 &pj,
1576 Direction::Fwd,
1577 Coord::new(12.0 * DEG_TO_RAD, 55.0 * DEG_TO_RAD, 0.0, 0.0),
1578 )
1579 .unwrap();
1580 assert_xy(
1581 out,
1582 [7_326_837.715_045_549, 1335833.8895192828],
1583 1e-6,
1584 "merc axis=neu",
1585 );
1586 }
1587
1588 #[test]
1589 fn axis_round_trips() {
1590 let pj = create("+proj=merc +axis=wsu +ellps=WGS84").unwrap();
1591 let input = Coord::new(12.0 * DEG_TO_RAD, 55.0 * DEG_TO_RAD, 0.0, 0.0);
1592 let fwd = trans(&pj, Direction::Fwd, input).unwrap();
1593 let inv = trans(&pj, Direction::Inv, fwd).unwrap().v();
1594 assert!(
1595 (inv[0] - 12.0 * DEG_TO_RAD).abs() < 1e-9 && (inv[1] - 55.0 * DEG_TO_RAD).abs() < 1e-9,
1596 "axis round trip: {inv:?}"
1597 );
1598 }
1599
1600 #[test]
1601 fn lon_wrap_wraps_output_into_center_band() {
1602 // audit gap 3: `+lon_wrap=<center>` must wrap angular OUTPUT into
1603 // [center-180, center+180), matching PROJ `fwd_finalize`'s
1604 // `is_long_wrap_set` branch. Reference values cross-checked live
1605 // against Homebrew PROJ 9.7.0 `cct` on an explicit
1606 // deg->rad->longlat(+lon_wrap=180)->rad->deg pipeline:
1607 // -170 10 -> 190 10 (wrapped: -170 + 360 = 190)
1608 // 10 10 -> 10 10 (already inside [0, 360))
1609 // 190 10 -> 190 10 (already inside [0, 360))
1610 let pj = create("+proj=longlat +lon_wrap=180 +ellps=WGS84").unwrap();
1611
1612 let cases = [
1613 (-170.0, 10.0, 190.0, 10.0),
1614 (10.0, 10.0, 10.0, 10.0),
1615 (190.0, 10.0, 190.0, 10.0),
1616 ];
1617 for (lam_in, phi_in, lam_want, phi_want) in cases {
1618 let out = trans(
1619 &pj,
1620 Direction::Fwd,
1621 Coord::new(lam_in * DEG_TO_RAD, phi_in * DEG_TO_RAD, 0.0, 0.0),
1622 )
1623 .unwrap();
1624 let v = out.v();
1625 assert!(
1626 (v[0] - lam_want * DEG_TO_RAD).abs() < 1e-9,
1627 "lam_in={lam_in}: got {} deg, want {lam_want} deg",
1628 v[0] * oxiproj_core::RAD_TO_DEG
1629 );
1630 assert!(
1631 (v[1] - phi_want * DEG_TO_RAD).abs() < 1e-9,
1632 "lam_in={lam_in}: phi got {} deg, want {phi_want} deg",
1633 v[1] * oxiproj_core::RAD_TO_DEG
1634 );
1635 }
1636 }
1637
1638 #[test]
1639 fn lon_wrap_oracle_sweep() {
1640 // audit gap 3: broader sweep, including boundary/wraparound inputs,
1641 // cross-checked live against Homebrew PROJ 9.7.0 `cct` on
1642 // `+proj=pipeline +lon_wrap=180 +step +proj=longlat +ellps=WGS84`
1643 // (`-z 0 -t 0` pinned; `+proj=longlat` rejects HUGE_VAL z/t inside a
1644 // pipeline step). -180 -> 180 and 360 -> 0 exercise the raw-input
1645 // `adjlon` normalization in `fwd_prepare` running BEFORE
1646 // `fwd_finalize`'s `+lon_wrap` wrap ever sees the value (both are
1647 // pre-existing `forward_impl` prepare-stage behavior, untouched by
1648 // this fix) composing with the new finalize-stage wrap.
1649 let pj = create("+proj=pipeline +lon_wrap=180 +step +proj=longlat +ellps=WGS84").unwrap();
1650 let cases: [(f64, f64); 11] = [
1651 (-170.0, 190.0),
1652 (-180.0, 180.0),
1653 (0.0, 0.0),
1654 (10.0, 10.0),
1655 (170.0, 170.0),
1656 (179.9999, 179.9999),
1657 (180.0, 180.0),
1658 (180.0001, 180.0001),
1659 (190.0, 190.0),
1660 (359.9999, 359.9999),
1661 (360.0, 0.0),
1662 ];
1663 for (lam_in, lam_want) in cases {
1664 let out = trans(
1665 &pj,
1666 Direction::Fwd,
1667 Coord::new(lam_in * DEG_TO_RAD, 5.0 * DEG_TO_RAD, 0.0, 0.0),
1668 )
1669 .unwrap();
1670 let got_deg = out.v()[0] * oxiproj_core::RAD_TO_DEG;
1671 assert!(
1672 (got_deg - lam_want).abs() < 1e-6,
1673 "lam_in={lam_in}: got {got_deg} deg, want {lam_want} deg"
1674 );
1675 }
1676 }
1677
1678 #[test]
1679 fn lon_wrap_absent_leaves_longitude_unwrapped() {
1680 // Baseline: without `+lon_wrap`, -170 stays -170 (already inside the
1681 // default (-180, 180] band adjlon normalizes to), unlike the
1682 // +lon_wrap=180 case above which relocates it to 190.
1683 let pj = create("+proj=longlat +ellps=WGS84").unwrap();
1684 let out = trans(
1685 &pj,
1686 Direction::Fwd,
1687 Coord::new(-170.0 * DEG_TO_RAD, 10.0 * DEG_TO_RAD, 0.0, 0.0),
1688 )
1689 .unwrap();
1690 let v = out.v();
1691 assert!(
1692 (v[0] - (-170.0 * DEG_TO_RAD)).abs() < 1e-9,
1693 "lam got {}",
1694 v[0]
1695 );
1696 }
1697
1698 #[test]
1699 fn lon_wrap_precedes_axisswap_in_synthesized_pipeline() {
1700 // audit gap 3: when `+axis` forces the `+lon_wrap`/axisswap
1701 // synthesized-pipeline path (`create_single` / `strip_from_projection_step`),
1702 // the wrap must run BEFORE the axisswap step, matching PROJ
1703 // `fwd_finalize`'s `is_long_wrap_set` wrap preceding `P->axisswap`
1704 // (`src/fwd.cpp`). Reference cross-checked live against Homebrew PROJ
1705 // 9.7.0 `cct` on the equivalent deg->rad->longlat(+lon_wrap=180
1706 // +axis=wsu)->rad->deg pipeline: -170 10 -> -190 -10 (wrap to 190,
1707 // then axisswap wsu negates both components).
1708 let pj = create("+proj=longlat +lon_wrap=180 +axis=wsu +ellps=WGS84").unwrap();
1709 let out = trans(
1710 &pj,
1711 Direction::Fwd,
1712 Coord::new(-170.0 * DEG_TO_RAD, 10.0 * DEG_TO_RAD, 0.0, 0.0),
1713 )
1714 .unwrap();
1715 let v = out.v();
1716 assert!(
1717 (v[0] - (-190.0 * DEG_TO_RAD)).abs() < 1e-9,
1718 "x got {} deg",
1719 v[0] * oxiproj_core::RAD_TO_DEG
1720 );
1721 assert!(
1722 (v[1] - (-10.0 * DEG_TO_RAD)).abs() < 1e-9,
1723 "y got {} deg",
1724 v[1] * oxiproj_core::RAD_TO_DEG
1725 );
1726 }
1727
1728 #[test]
1729 fn lon_wrap_pipeline_global_wraps_single_step_output() {
1730 // A global `+lon_wrap` on a `+proj=pipeline` is inherited by every
1731 // step (like `+lon_0`; see `create.rs`'s `appended_globals`), and
1732 // applied on each step's own true forward pass. Cross-checked live
1733 // against Homebrew PROJ 9.7.0 `cct` on the identical raw pipeline
1734 // string (-170 10 -> 190 10, z/t pinned since `+proj=longlat`
1735 // rejects HUGE_VAL z/t inside a pipeline step).
1736 let pj = create("+proj=pipeline +lon_wrap=180 +step +proj=longlat +ellps=WGS84").unwrap();
1737 let out = trans(
1738 &pj,
1739 Direction::Fwd,
1740 Coord::new(-170.0 * DEG_TO_RAD, 10.0 * DEG_TO_RAD, 0.0, 0.0),
1741 )
1742 .unwrap();
1743 let v = out.v();
1744 assert!(
1745 (v[0] - (190.0 * DEG_TO_RAD)).abs() < 1e-9,
1746 "x got {} deg",
1747 v[0] * oxiproj_core::RAD_TO_DEG
1748 );
1749 }
1750
1751 #[test]
1752 fn lon_wrap_pipeline_global_undone_by_inverted_round_trip_step() {
1753 // A two-step round-trip pipeline (plain forward, then the same
1754 // operation `+inv`) does NOT keep the first step's `+lon_wrap`
1755 // relocation: the second (inverted) step runs its own `inv_finalize`
1756 // equivalent, which in PROJ unconditionally re-applies the plain
1757 // `+over`-gated `adjlon` (`src/inv.cpp`) regardless of `+lon_wrap` —
1758 // PROJ's `inv_finalize` has no `is_long_wrap_set` branch at all, only
1759 // `fwd_finalize` does. So the relocation set up by step 0's forward
1760 // pass is normalized straight back to the standard band by step 1's
1761 // inverse pass. Cross-checked live against Homebrew PROJ 9.7.0 `cct`
1762 // on the identical raw pipeline string: -170 10 -> -170 10 (NOT 190).
1763 let pj = create(
1764 "+proj=pipeline +lon_wrap=180 +step +proj=longlat +ellps=WGS84 +step +proj=longlat +ellps=WGS84 +inv",
1765 )
1766 .unwrap();
1767 let out = trans(
1768 &pj,
1769 Direction::Fwd,
1770 Coord::new(-170.0 * DEG_TO_RAD, 10.0 * DEG_TO_RAD, 0.0, 0.0),
1771 )
1772 .unwrap();
1773 let v = out.v();
1774 assert!(
1775 (v[0] - (-170.0 * DEG_TO_RAD)).abs() < 1e-9,
1776 "x got {} deg",
1777 v[0] * oxiproj_core::RAD_TO_DEG
1778 );
1779 }
1780
1781 #[test]
1782 fn lon_wrap_rejects_excessive_center() {
1783 // PROJ `src/init.cpp`: `if (!(fabs(long_wrap_center) < 10 * M_TWOPI))`
1784 // rejects an excessive center with error 1027 ("Invalid value for
1785 // lon_wrap"). Cross-checked live: Homebrew PROJ 9.7.0 `projinfo
1786 // "+proj=longlat +lon_wrap=999999 +ellps=WGS84 +type=crs"` fails with
1787 // exactly this message.
1788 assert!(create("+proj=longlat +lon_wrap=999999 +ellps=WGS84").is_err());
1789 }
1790
1791 #[test]
1792 fn lon_wrap_accepts_bare_flag_as_zero_center() {
1793 // A bare `+lon_wrap` (no `=value`) must not error; PROJ's numeric
1794 // param parse falls back to 0.0 for a missing operand.
1795 assert!(create("+proj=longlat +lon_wrap +ellps=WGS84").is_ok());
1796 }
1797
1798 /// Build a 2×2 GTX grid over lat/lon [0°,1°] with a uniform vertical shift
1799 /// (metres). Big-endian, matching `oxiproj_grids::read_gtx`.
1800 fn make_uniform_gtx(shift_m: f32) -> Vec<u8> {
1801 let mut buf = Vec::new();
1802 buf.extend_from_slice(&0.0f64.to_be_bytes()); // south_lat (deg)
1803 buf.extend_from_slice(&0.0f64.to_be_bytes()); // west_lon (deg)
1804 buf.extend_from_slice(&1.0f64.to_be_bytes()); // lat_inc (deg)
1805 buf.extend_from_slice(&1.0f64.to_be_bytes()); // lon_inc (deg)
1806 buf.extend_from_slice(&2i32.to_be_bytes()); // rows
1807 buf.extend_from_slice(&2i32.to_be_bytes()); // cols
1808 for _ in 0..4 {
1809 buf.extend_from_slice(&shift_m.to_be_bytes());
1810 }
1811 buf
1812 }
1813
1814 #[test]
1815 fn geoidgrids_injects_vgridshift() {
1816 // E2 finding 1: classic `+geoidgrids=` must no longer be silently
1817 // ignored. PROJ (cs2cs_emulation_setup) turns it into a vgridshift
1818 // helper applied FORWARD on the forward path (fwd_prepare), moving
1819 // between geometric and orthometric height. PROJ's vgridshift forward
1820 // SUBTRACTS the grid value (default forward_multiplier = -1.0), so a
1821 // uniform +10 m geoid grid REMOVES 10 m from the height before the
1822 // projection runs (verified: `cct +proj=vgridshift` yields z-10).
1823 let mut ctx = crate::context::Context::new();
1824 ctx.register_grid("geoid.gtx", make_uniform_gtx(10.0));
1825
1826 // Point (lon=0.5°, lat=0.5°) is inside the grid; carry a nonzero height.
1827 let p = Coord::new(0.5 * DEG_TO_RAD, 0.5 * DEG_TO_RAD, 5.0, 0.0);
1828
1829 let engine =
1830 create_with_ctx("+proj=merc +ellps=WGS84 +geoidgrids=geoid.gtx", &ctx).unwrap();
1831 // Equivalent explicit pipeline: vgridshift (FWD) then the projection.
1832 let fwd_ref = create_with_ctx(
1833 "+proj=pipeline +step +proj=vgridshift +grids=geoid.gtx \
1834 +step +proj=merc +ellps=WGS84",
1835 &ctx,
1836 )
1837 .unwrap();
1838 let plain = create("+proj=merc +ellps=WGS84").unwrap();
1839
1840 let e = trans(&engine, Direction::Fwd, p).unwrap().v();
1841 let r = trans(&fwd_ref, Direction::Fwd, p).unwrap().v();
1842 let n = trans(&plain, Direction::Fwd, p).unwrap().v();
1843
1844 // Correct wiring & direction: engine equals the explicit vgridshift+merc.
1845 assert!(
1846 (e[0] - r[0]).abs() < 1e-9 && (e[1] - r[1]).abs() < 1e-9 && (e[2] - r[2]).abs() < 1e-6,
1847 "geoidgrids must equal explicit vgridshift pipeline: engine={e:?} ref={r:?}"
1848 );
1849 // Not silently ignored: height differs from plain merc by the -10 m
1850 // shift (PROJ vgridshift forward subtracts the grid value).
1851 assert!(
1852 (e[2] - (n[2] - 10.0)).abs() < 1e-6,
1853 "geoidgrids must subtract the +10 m geoid shift from height: engine z={}, plain z={}",
1854 e[2],
1855 n[2]
1856 );
1857 // Horizontal output is unaffected by the vertical shift.
1858 assert!(
1859 (e[0] - n[0]).abs() < 1e-6 && (e[1] - n[1]).abs() < 1e-6,
1860 "geoidgrids must not move x/y: engine={e:?} plain={n:?}"
1861 );
1862 }
1863
1864 #[test]
1865 fn geoidgrids_round_trips() {
1866 // Forward then inverse restores the original height: the injected
1867 // vgridshift runs INVERSE on the inverse path (inv_finalize).
1868 let mut ctx = crate::context::Context::new();
1869 ctx.register_grid("geoid.gtx", make_uniform_gtx(10.0));
1870 let engine =
1871 create_with_ctx("+proj=merc +ellps=WGS84 +geoidgrids=geoid.gtx", &ctx).unwrap();
1872 let p = Coord::new(0.5 * DEG_TO_RAD, 0.5 * DEG_TO_RAD, 5.0, 0.0);
1873 let fwd = trans(&engine, Direction::Fwd, p).unwrap();
1874 let inv = trans(&engine, Direction::Inv, fwd).unwrap().v();
1875 assert!(
1876 (inv[0] - 0.5 * DEG_TO_RAD).abs() < 1e-9
1877 && (inv[1] - 0.5 * DEG_TO_RAD).abs() < 1e-9
1878 && (inv[2] - 5.0).abs() < 1e-6,
1879 "geoidgrids round trip: {inv:?}"
1880 );
1881 }
1882
1883 #[test]
1884 fn hgridshift_resolves_grid_from_proj_data_via_public_api() {
1885 // E4 finding: grid loading must work end-to-end from a local PROJ_DATA
1886 // directory through the public engine API (`create`), not only via the
1887 // in-memory registry. Write a synthetic NTv2 grid to a unique temp dir,
1888 // point PROJ_DATA at it, and run `+proj=hgridshift`. `cargo nextest`
1889 // runs every test in its own process, so mutating PROJ_DATA here cannot
1890 // race other tests; the prior value is still saved and restored.
1891 use std::io::Write;
1892 let ns = std::time::SystemTime::now()
1893 .duration_since(std::time::UNIX_EPOCH)
1894 .map(|d| d.as_nanos())
1895 .unwrap_or(0);
1896 let dir = std::env::temp_dir().join(format!("oxiproj_e4_projdata_{ns}"));
1897 std::fs::create_dir_all(&dir).expect("create temp PROJ_DATA dir");
1898 // +1 deg latitude shift, 0 deg longitude shift over lon[-1,0], lat[0,1].
1899 let grid_bytes = make_uniform_shift_ntv2(3600.0, 0.0);
1900 {
1901 let mut f = std::fs::File::create(dir.join("e4_shift.gsb")).expect("create grid file");
1902 f.write_all(&grid_bytes).expect("write grid bytes");
1903 }
1904
1905 let saved = std::env::var_os("PROJ_DATA");
1906 std::env::set_var("PROJ_DATA", &dir);
1907
1908 // Point inside the grid (lon=-0.5 deg, lat=0.5 deg).
1909 let p = Coord::new(-0.5 * DEG_TO_RAD, 0.5 * DEG_TO_RAD, 0.0, 0.0);
1910 let disk_out = create("+proj=hgridshift +grids=e4_shift.gsb")
1911 .ok()
1912 .and_then(|pj| trans(&pj, Direction::Fwd, p).ok().map(|c| c.v()));
1913
1914 // Restore env and remove temp files before any assertion can unwind.
1915 match saved {
1916 Some(v) => std::env::set_var("PROJ_DATA", v),
1917 None => std::env::remove_var("PROJ_DATA"),
1918 }
1919 let _ = std::fs::remove_dir_all(&dir);
1920
1921 let out = disk_out
1922 .expect("hgridshift must build and transform using a grid resolved from PROJ_DATA");
1923 // The +1 deg latitude shift was applied; longitude is unchanged.
1924 assert!(
1925 (out[0] - (-0.5 * DEG_TO_RAD)).abs() < 1e-9,
1926 "lon should be unchanged, got {}",
1927 out[0]
1928 );
1929 assert!(
1930 (out[1] - 1.5 * DEG_TO_RAD).abs() < 1e-9,
1931 "lat should be shifted +1 deg, got {}",
1932 out[1]
1933 );
1934
1935 // Cross-check: the identical grid registered in memory yields the same
1936 // result, tying the disk-resolution path to the already-verified
1937 // in-memory registration path.
1938 let mut ctx = crate::context::Context::new();
1939 ctx.register_grid("e4_shift.gsb", grid_bytes);
1940 let mem = create_with_ctx("+proj=hgridshift +grids=e4_shift.gsb", &ctx)
1941 .expect("in-memory hgridshift");
1942 let mem_out = trans(&mem, Direction::Fwd, p)
1943 .expect("in-memory transform")
1944 .v();
1945 assert!(
1946 (out[0] - mem_out[0]).abs() < 1e-12 && (out[1] - mem_out[1]).abs() < 1e-12,
1947 "disk-resolved result must match in-memory-registered result: disk={out:?} mem={mem_out:?}"
1948 );
1949 }
1950
1951 #[test]
1952 fn moll_factors_use_sphere_metric() {
1953 // Defect B: Mollweide is sphere-only (PROJ `moll.cpp` sets `P->es = 0`),
1954 // so its map-scale factors must be computed on the sphere even when an
1955 // ellipsoid is supplied. Ground truth from PROJ `proj -S +proj=moll
1956 // +ellps=WGS84` at (0,0): <1.11072 0.900316 1 ...> — areal scale s = 1
1957 // (Mollweide is equal-area), meridional h = 1.11072.
1958 let pj = create("+proj=moll +ellps=WGS84").unwrap();
1959 let f = pj.factors(Coord::new(0.0, 0.0, 0.0, 0.0)).unwrap();
1960 assert!(
1961 (f.s - 1.0).abs() < 1e-6,
1962 "moll areal scale must be 1.0 (sphere metric), got {}",
1963 f.s
1964 );
1965 assert!(
1966 (f.h - 1.110_720_734_5).abs() < 1e-4,
1967 "moll meridional scale must match PROJ 1.11072, got {}",
1968 f.h
1969 );
1970 // Exact-AD factors path must agree with the numeric one (both es=0).
1971 let fe = pj.factors_exact(Coord::new(0.0, 0.0, 0.0, 0.0)).unwrap();
1972 assert!(
1973 (fe.areal_scale - 1.0).abs() < 1e-6,
1974 "moll exact areal scale must be 1.0, got {}",
1975 fe.areal_scale
1976 );
1977 }
1978
1979 #[test]
1980 fn sphere_only_projections_report_unit_areal_scale() {
1981 // sinu, hammer, eck4 are all sphere-only equal-area projections; given an
1982 // ellipsoid PROJ still reports areal scale 1.0 (verified vs `proj -S`).
1983 for name in ["sinu", "hammer", "eck4"] {
1984 let pj = create(&format!("+proj={name} +ellps=WGS84")).unwrap();
1985 let f = pj
1986 .factors(Coord::new(20.0 * DEG_TO_RAD, 40.0 * DEG_TO_RAD, 0.0, 0.0))
1987 .unwrap();
1988 assert!(
1989 (f.s - 1.0).abs() < 1e-6,
1990 "{name} areal scale must be 1.0 (sphere metric), got {}",
1991 f.s
1992 );
1993 }
1994 }
1995
1996 #[test]
1997 fn ellipsoidal_projection_factors_keep_eccentricity() {
1998 // Genuinely ellipsoidal projections must NOT be forced onto the sphere
1999 // metric. Ellipsoidal Mercator at (20,40): PROJ `proj -S` reports
2000 // h = k = 1.30360 (ellipsoidal), distinct from the spherical
2001 // sec(40°) = 1.305407. Confirms `factors_es` stays = ellipsoid.es here.
2002 let pj = create("+proj=merc +ellps=WGS84").unwrap();
2003 let f = pj
2004 .factors(Coord::new(20.0 * DEG_TO_RAD, 40.0 * DEG_TO_RAD, 0.0, 0.0))
2005 .unwrap();
2006 assert!(
2007 (f.h - 1.303_600_689_3).abs() < 1e-4,
2008 "merc meridional scale must match ellipsoidal PROJ 1.30360, got {}",
2009 f.h
2010 );
2011 let spherical = 1.0 / 40.0_f64.to_radians().cos();
2012 assert!(
2013 (f.h - spherical).abs() > 1e-3,
2014 "merc factors must be ellipsoidal, not spherical {spherical}: got {}",
2015 f.h
2016 );
2017 }
2018
2019 #[test]
2020 fn geocent_produces_ecef_via_engine() {
2021 // E2 finding 2: `+proj=geocent` must return real ECEF, not the input
2022 // radians. Ground truth from Homebrew PROJ 9.x:
2023 // echo "12 55 100" | cct +proj=geocent +ellps=WGS84
2024 // -> 3586525.7610 762339.5841 5201465.4384
2025 let pj = create("+proj=geocent +ellps=WGS84").unwrap();
2026 let out = trans(
2027 &pj,
2028 Direction::Fwd,
2029 Coord::new(12.0 * DEG_TO_RAD, 55.0 * DEG_TO_RAD, 100.0, 0.0),
2030 )
2031 .unwrap()
2032 .v();
2033 assert!((out[0] - 3586525.761017917).abs() < 1e-3, "X = {}", out[0]);
2034 assert!((out[1] - 762339.584102928).abs() < 1e-3, "Y = {}", out[1]);
2035 assert!((out[2] - 5201465.438406702).abs() < 1e-3, "Z = {}", out[2]);
2036 // Inverse restores the geodetic input.
2037 let back = trans(&pj, Direction::Inv, Coord::new(out[0], out[1], out[2], 0.0))
2038 .unwrap()
2039 .v();
2040 assert!(
2041 (back[0] - 12.0 * DEG_TO_RAD).abs() < 1e-9,
2042 "lam = {}",
2043 back[0]
2044 );
2045 assert!(
2046 (back[1] - 55.0 * DEG_TO_RAD).abs() < 1e-9,
2047 "phi = {}",
2048 back[1]
2049 );
2050 assert!((back[2] - 100.0).abs() < 1e-3, "h = {}", back[2]);
2051 }
2052}