Skip to main content

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(&params, &ctx)
126    } else {
127        create_single(&params, &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(&params, ctx)
146    } else {
147        create_single(&params, 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(&params, &ctx)
176    } else {
177        create_single_projection_only(&params, &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 &params.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}