@@ -560,6 +560,7 @@ struct CoordinateOperationFactory::Private {
560560 bool inCreateOperationsWithDatumPivotAntiRecursion = false ;
561561 bool inCreateOperationsGeogToVertWithAlternativeGeog = false ;
562562 bool inCreateOperationsGeogToVertWithIntermediateVert = false ;
563+ bool inCreateOperationsVertToVertWithIntermediateVert = false ;
563564 bool skipHorizontalTransformation = false ;
564565 int nRecLevelCreateOperations = 0 ;
565566 std::map<std::pair<io::AuthorityFactory::ObjectType, std::string>,
@@ -649,6 +650,15 @@ struct CoordinateOperationFactory::Private {
649650 const crs::CRSNNPtr &sourceCRS, const crs::CRSNNPtr &targetCRS,
650651 Context &context);
651652
653+ static std::vector<CoordinateOperationNNPtr>
654+ createOperationsVertToVertWithIntermediateVert (
655+ const crs::CRSNNPtr &sourceCRS,
656+ const util::optional<common::DataEpoch> &sourceEpoch,
657+ const crs::CRSNNPtr &targetCRS,
658+ const util::optional<common::DataEpoch> &targetEpoch,
659+ const crs::VerticalCRS *vertSrc, const crs::VerticalCRS *vertDst,
660+ Context &context);
661+
652662 static void createOperationsFromDatabaseWithVertCRS (
653663 const crs::CRSNNPtr &sourceCRS,
654664 const util::optional<common::DataEpoch> &sourceEpoch,
@@ -4530,6 +4540,143 @@ std::vector<CoordinateOperationNNPtr> CoordinateOperationFactory::Private::
45304540
45314541// ---------------------------------------------------------------------------
45324542
4543+ // When transforming between two vertical CRS with different datums, check
4544+ // if there are registered operations whose target (or source) is a different
4545+ // vertical CRS sharing the same datum as our target (or source). If so,
4546+ // chain: source → intermediate + intermediate → target (the latter being a
4547+ // simple unit/axis conversion handled by createOperationsVertToVert).
4548+ //
4549+ // Typical example: Baltic 1977 height (EPSG:5705) → Caspian depth (EPSG:5706).
4550+ // EPSG has an operation 5705 → 5611 (Caspian height). 5611 and 5706 share the
4551+ // same datum but differ only in axis direction. We compose:
4552+ // 5705 →(EPSG:5438)→ 5611 →(height-to-depth)→ 5706
4553+ std::vector<CoordinateOperationNNPtr> CoordinateOperationFactory::Private::
4554+ createOperationsVertToVertWithIntermediateVert (
4555+ const crs::CRSNNPtr &sourceCRS,
4556+ const util::optional<common::DataEpoch> &sourceEpoch,
4557+ const crs::CRSNNPtr &targetCRS,
4558+ const util::optional<common::DataEpoch> &targetEpoch,
4559+ const crs::VerticalCRS *vertSrc, const crs::VerticalCRS *vertDst,
4560+ Private::Context &context) {
4561+
4562+ ENTER_FUNCTION ();
4563+
4564+ std::vector<CoordinateOperationNNPtr> res;
4565+
4566+ struct AntiRecursionGuard {
4567+ Context &context;
4568+
4569+ explicit AntiRecursionGuard (Context &contextIn) : context(contextIn) {
4570+ assert (!context.inCreateOperationsVertToVertWithIntermediateVert );
4571+ context.inCreateOperationsVertToVertWithIntermediateVert = true ;
4572+ }
4573+
4574+ ~AntiRecursionGuard () {
4575+ context.inCreateOperationsVertToVertWithIntermediateVert = false ;
4576+ }
4577+ };
4578+ AntiRecursionGuard guard (context);
4579+
4580+ const auto &authFactory = context.context ->getAuthorityFactory ();
4581+ if (!authFactory)
4582+ return res;
4583+ const auto dbContext = authFactory->databaseContext ().as_nullable ();
4584+
4585+ // Strategy 1: pivot through candidates sharing the TARGET's datum.
4586+ // Find vertical CRSs on the same datum as the target, then look for
4587+ // operations from the source to each candidate (using the full
4588+ // createOperations machinery so multi-step chains are also found).
4589+ auto candidatesDst = findCandidateVertCRSForDatum (
4590+ authFactory, vertDst->datumNonNull (dbContext).get ());
4591+ for (const auto &candidateVert : candidatesDst) {
4592+ if (candidateVert->_isEquivalentTo (
4593+ targetCRS.get (),
4594+ util::IComparable::Criterion::EQUIVALENT)) {
4595+ continue ; // Skip the target itself — already tried by the caller.
4596+ }
4597+ auto opsFirst = createOperations (sourceCRS, sourceEpoch, candidateVert,
4598+ sourceEpoch, context);
4599+ if (!opsFirst.empty ()) {
4600+ const auto opsSecond = createOperations (
4601+ candidateVert, sourceEpoch, targetCRS, targetEpoch, context);
4602+ if (!opsSecond.empty ()) {
4603+ // The transformation from candidateVert to targetCRS should
4604+ // be just a unit/axis change typically, so take only the
4605+ // first one, which is likely/hopefully the only one.
4606+ for (const auto &opFirst : opsFirst) {
4607+ if (hasIdentifiers (opFirst)) {
4608+ if (candidateVert->_isEquivalentTo (
4609+ targetCRS.get (),
4610+ util::IComparable::Criterion::EQUIVALENT)) {
4611+ res.emplace_back (opFirst);
4612+ } else {
4613+ try {
4614+ res.emplace_back (
4615+ ConcatenatedOperation::
4616+ createComputeMetadata (
4617+ {opFirst, opsSecond.front ()},
4618+ context
4619+ .disallowEmptyIntersection ()));
4620+ } catch (
4621+ const InvalidOperationEmptyIntersection &) {
4622+ }
4623+ }
4624+ }
4625+ }
4626+ if (!res.empty ())
4627+ return res;
4628+ }
4629+ }
4630+ }
4631+
4632+ // Strategy 2: pivot through candidates sharing the SOURCE's datum.
4633+ // Find vertical CRSs on the same datum as the source, then look for
4634+ // operations from each candidate to the target.
4635+ auto candidatesSrc = findCandidateVertCRSForDatum (
4636+ authFactory, vertSrc->datumNonNull (dbContext).get ());
4637+ for (const auto &candidateVert : candidatesSrc) {
4638+ if (candidateVert->_isEquivalentTo (
4639+ sourceCRS.get (),
4640+ util::IComparable::Criterion::EQUIVALENT)) {
4641+ continue ;
4642+ }
4643+ auto opsSecond = createOperations (candidateVert, sourceEpoch, targetCRS,
4644+ targetEpoch, context);
4645+ if (!opsSecond.empty ()) {
4646+ const auto opsFirst = createOperations (
4647+ sourceCRS, sourceEpoch, candidateVert, sourceEpoch, context);
4648+ if (!opsFirst.empty ()) {
4649+ for (const auto &opSecond : opsSecond) {
4650+ if (hasIdentifiers (opSecond)) {
4651+ if (candidateVert->_isEquivalentTo (
4652+ sourceCRS.get (),
4653+ util::IComparable::Criterion::EQUIVALENT)) {
4654+ res.emplace_back (opSecond);
4655+ } else {
4656+ try {
4657+ res.emplace_back (
4658+ ConcatenatedOperation::
4659+ createComputeMetadata (
4660+ {opsFirst.front (), opSecond},
4661+ context
4662+ .disallowEmptyIntersection ()));
4663+ } catch (
4664+ const InvalidOperationEmptyIntersection &) {
4665+ }
4666+ }
4667+ }
4668+ }
4669+ if (!res.empty ())
4670+ return res;
4671+ }
4672+ }
4673+ }
4674+
4675+ return res;
4676+ }
4677+
4678+ // ---------------------------------------------------------------------------
4679+
45334680std::vector<CoordinateOperationNNPtr> CoordinateOperationFactory::Private::
45344681 createOperationsGeogToVertWithAlternativeGeog (
45354682 const crs::CRSNNPtr &sourceCRS, // geographic CRS
@@ -4663,6 +4810,19 @@ void CoordinateOperationFactory::Private::
46634810 res = applyInverse (res);
46644811 }
46654812
4813+ // Typically to transform between two vertical CRS with different datums
4814+ // (e.g. "Baltic 1977 height" to "Caspian depth") by pivoting through
4815+ // an intermediate vertical CRS sharing the target's (or source's) datum.
4816+ // This handles the case where a registered CT targets a height CRS but
4817+ // the user requests the corresponding depth CRS (or vice versa).
4818+ if (res.empty () &&
4819+ !context.inCreateOperationsVertToVertWithIntermediateVert && vertSrc &&
4820+ vertDst) {
4821+ res = createOperationsVertToVertWithIntermediateVert (
4822+ sourceCRS, sourceEpoch, targetCRS, targetEpoch, vertSrc, vertDst,
4823+ context);
4824+ }
4825+
46664826 // There's no direct transformation from NAVD88 height to WGS84,
46674827 // so try to research all transformations from NAVD88 to another
46684828 // intermediate GeographicCRS.
0 commit comments