diff --git a/apps/CMakeLists.txt b/apps/CMakeLists.txt index 987ad3f41909..98ce585aa1de 100644 --- a/apps/CMakeLists.txt +++ b/apps/CMakeLists.txt @@ -100,8 +100,10 @@ target_sources(appslib PRIVATE gdalalg_vector_check_geometry.cpp gdalalg_vector_clip.cpp gdalalg_vector_clean_coverage.cpp + gdalalg_vector_collect.cpp gdalalg_vector_concat.cpp gdalalg_vector_convert.cpp + gdalalg_vector_dissolve.cpp gdalalg_vector_edit.cpp gdalalg_vector_pipeline.cpp gdalalg_vector_rasterize.cpp diff --git a/apps/gdalalg_vector.cpp b/apps/gdalalg_vector.cpp index 8cf7e1eac51b..4c92588799fd 100644 --- a/apps/gdalalg_vector.cpp +++ b/apps/gdalalg_vector.cpp @@ -18,8 +18,10 @@ #include "gdalalg_vector_check_coverage.h" #include "gdalalg_vector_clean_coverage.h" #include "gdalalg_vector_clip.h" +#include "gdalalg_vector_collect.h" #include "gdalalg_vector_concat.h" #include "gdalalg_vector_convert.h" +#include "gdalalg_vector_dissolve.h" #include "gdalalg_vector_edit.h" #include "gdalalg_vector_explode_collections.h" #include "gdalalg_vector_geom.h" @@ -73,8 +75,10 @@ class GDALVectorAlgorithm final : public GDALAlgorithm RegisterSubAlgorithm(); RegisterSubAlgorithm(); RegisterSubAlgorithm(); + RegisterSubAlgorithm(); RegisterSubAlgorithm(); RegisterSubAlgorithm(); + RegisterSubAlgorithm(); RegisterSubAlgorithm(); RegisterSubAlgorithm(); RegisterSubAlgorithm(); diff --git a/apps/gdalalg_vector_collect.cpp b/apps/gdalalg_vector_collect.cpp new file mode 100644 index 000000000000..bab2bb5966ce --- /dev/null +++ b/apps/gdalalg_vector_collect.cpp @@ -0,0 +1,230 @@ +/****************************************************************************** +* + * Project: GDAL + * Purpose: "gdal vector collect" subcommand + * Author: Daniel Baston + * + ****************************************************************************** + * Copyright (c) 2025, ISciences LLC + * + * SPDX-License-Identifier: MIT + ****************************************************************************/ + +#include "gdalalg_vector_collect.h" + +#include "cpl_error.h" +#include "gdal_priv.h" +#include "gdalalg_vector_geom.h" +#include "ogr_geometry.h" + +#include + +#ifndef _ +#define _(x) (x) +#endif + +//! @cond Doxygen_Suppress + +GDALVectorCollectAlgorithm::GDALVectorCollectAlgorithm(bool standaloneStep) + : GDALVectorPipelineStepAlgorithm(NAME, DESCRIPTION, HELP_URL, + standaloneStep) +{ + AddArg("group-by", 0, + _("Names of field(s) by which inputs should be grouped"), &m_groupBy) + .AddValidationAction( + [this]() + { + auto fields = m_groupBy; + + std::sort(fields.begin(), fields.end()); + if (std::adjacent_find(fields.begin(), fields.end()) != + fields.end()) + { + CPLError( + CE_Failure, CPLE_AppDefined, + "--group-by must be a list of unique field names."); + return false; + } + return true; + }); +} + +namespace +{ +class GDALVectorCollectDataset : public GDALVectorNonStreamingAlgorithmDataset +{ + public: + explicit GDALVectorCollectDataset(const std::vector &groupBy) + : m_groupBy(groupBy) + { + } + + bool Process(OGRLayer &srcLayer, OGRLayer &dstLayer) override + { + std::map, std::unique_ptr> + oDstFeatures{}; + const int nGeomFields = srcLayer.GetLayerDefn()->GetGeomFieldCount(); + + std::vector srcFieldIndices; + for (const auto &fieldName : m_groupBy) + { + // RunStep already checked that the field exists + srcFieldIndices.push_back( + srcLayer.GetLayerDefn()->GetFieldIndex(fieldName.c_str())); + } + + for (const auto &srcFeature : srcLayer) + { + std::vector fieldValues(srcFieldIndices.size()); + for (size_t iDstField = 0; iDstField < srcFieldIndices.size(); + iDstField++) + { + const int iSrcField = srcFieldIndices[iDstField]; + fieldValues[iDstField] = + srcFeature->GetFieldAsString(iSrcField); + } + + OGRFeature *dstFeature; + + if (auto it = oDstFeatures.find(fieldValues); + it == oDstFeatures.end()) + { + oDstFeatures[fieldValues] = + std::make_unique(dstLayer.GetLayerDefn()); + dstFeature = oDstFeatures[fieldValues].get(); + + // TODO compute field index from:to map and reuse + dstFeature->SetFrom(srcFeature.get()); + + for (int iGeomField = 0; iGeomField < nGeomFields; iGeomField++) + { + const OGRGeomFieldDefn *poGeomDefn = + dstLayer.GetLayerDefn()->GetGeomFieldDefn(iGeomField); + const auto eGeomType = poGeomDefn->GetType(); + + dstFeature->SetGeomFieldDirectly( + iGeomField, + OGRGeometryFactory::createGeometry(eGeomType)); + } + } + else + { + dstFeature = it->second.get(); + } + + for (int iGeomField = 0; iGeomField < nGeomFields; iGeomField++) + { + std::unique_ptr poSrcGeom( + srcFeature->StealGeometry(iGeomField)); + if (poSrcGeom != nullptr) + { + OGRGeometryCollection *poDstGeom = + cpl::down_cast( + dstFeature->GetGeomFieldRef(iGeomField)); + poDstGeom->addGeometry(std::move(poSrcGeom)); + } + } + } + + for (const auto &[_, poDstFeature] : oDstFeatures) + { + if (dstLayer.CreateFeature(poDstFeature.get()) != OGRERR_NONE) + { + return false; + } + } + + return true; + } + + private: + std::vector m_groupBy{}; +}; +} // namespace + +bool GDALVectorCollectAlgorithm::RunStep(GDALPipelineStepRunContext &) +{ + auto poSrcDS = m_inputDataset[0].GetDatasetRef(); + auto poDstDS = std::make_unique(m_groupBy); + + for (auto &&poSrcLayer : poSrcDS->GetLayers()) + { + if (m_inputLayerNames.empty() || + std::find(m_inputLayerNames.begin(), m_inputLayerNames.end(), + poSrcLayer->GetDescription()) != m_inputLayerNames.end()) + { + const auto poSrcLayerDefn = poSrcLayer->GetLayerDefn(); + if (poSrcLayerDefn->GetGeomFieldCount() == 0) + { + if (m_inputLayerNames.empty()) + continue; + ReportError(CE_Failure, CPLE_AppDefined, + "Specified layer '%s' has no geometry field", + poSrcLayer->GetDescription()); + return false; + } + + OGRFeatureDefn dstDefn(poSrcLayerDefn->GetName()); + + // Copy attribute fields specified with --group-by, discard others + for (const auto &fieldName : m_groupBy) + { + const int iSrcFieldIndex = + poSrcLayerDefn->GetFieldIndex(fieldName.c_str()); + if (iSrcFieldIndex == -1) + { + ReportError(CE_Failure, CPLE_AppDefined, + "Specified attribute field '%s' does not exist " + "in layer '%s'", + fieldName.c_str(), + poSrcLayer->GetDescription()); + return false; + } + + const OGRFieldDefn *poFieldDefn = + poSrcLayerDefn->GetFieldDefn(iSrcFieldIndex); + dstDefn.AddFieldDefn(poFieldDefn); + } + + // Copy all geometry fields, upgrading the type to the corresponding + // collection type. + for (int iGeomField = 0; + iGeomField < poSrcLayerDefn->GetGeomFieldCount(); iGeomField++) + { + const OGRGeomFieldDefn *srcGeomDefn = + poSrcLayerDefn->GetGeomFieldDefn(iGeomField); + const auto eSrcGeomType = srcGeomDefn->GetType(); + auto eDstGeomType = OGR_GT_GetCollection(eSrcGeomType); + if (eDstGeomType == wkbUnknown) + { + eDstGeomType = wkbGeometryCollection; + } + + if (iGeomField == 0) + { + dstDefn.DeleteGeomFieldDefn(0); + } + + auto dstGeomDefn = std::make_unique( + srcGeomDefn->GetNameRef(), eDstGeomType); + dstGeomDefn->SetSpatialRef(srcGeomDefn->GetSpatialRef()); + dstDefn.AddGeomFieldDefn(std::move(dstGeomDefn)); + } + //poDstDS->SetSourceGeometryField(geomFieldIndex); + + if (!poDstDS->AddProcessedLayer(*poSrcLayer, dstDefn)) + { + return false; + } + } + } + + m_outputDataset.Set(std::move(poDstDS)); + + return true; +} + +GDALVectorCollectAlgorithmStandalone::~GDALVectorCollectAlgorithmStandalone() = + default; + +//! @endcond diff --git a/apps/gdalalg_vector_collect.h b/apps/gdalalg_vector_collect.h new file mode 100644 index 000000000000..3d305a53f42b --- /dev/null +++ b/apps/gdalalg_vector_collect.h @@ -0,0 +1,62 @@ +/****************************************************************************** +* + * Project: GDAL + * Purpose: "gdal vector collect" + * Author: Daniel Baston + * + ****************************************************************************** + * Copyright (c) 2025, ISciences LLC + * + * SPDX-License-Identifier: MIT + ****************************************************************************/ + +#ifndef GDALALG_VECTOR_COLLECT_INCLUDED +#define GDALALG_VECTOR_COLLECT_INCLUDED + +#include "gdalalg_vector_pipeline.h" +#include "cpl_progress.h" + +#include + +//! @cond Doxygen_Suppress + +/************************************************************************/ +/* GDALVectorCollectAlgorithm */ +/************************************************************************/ + +class GDALVectorCollectAlgorithm : public GDALVectorPipelineStepAlgorithm +{ + public: + static constexpr const char *NAME = "collect"; + static constexpr const char *DESCRIPTION = + "Combine features into collections"; + static constexpr const char *HELP_URL = + "/programs/gdal_vector_collect.html"; + + explicit GDALVectorCollectAlgorithm(bool standaloneStep = false); + + private: + bool RunStep(GDALPipelineStepRunContext &ctxt) override; + + std::vector m_groupBy{}; +}; + +/************************************************************************/ +/* GDALVectorCollectAlgorithmStandalone */ +/************************************************************************/ + +class GDALVectorCollectAlgorithmStandalone final + : public GDALVectorCollectAlgorithm +{ + public: + GDALVectorCollectAlgorithmStandalone() + : GDALVectorCollectAlgorithm(/* standaloneStep = */ true) + { + } + + ~GDALVectorCollectAlgorithmStandalone() override; +}; + +//! @endcond + +#endif /* GDALALG_VECTOR_COLLECT_INCLUDED */ diff --git a/apps/gdalalg_vector_dissolve.cpp b/apps/gdalalg_vector_dissolve.cpp new file mode 100644 index 000000000000..f342805cb54c --- /dev/null +++ b/apps/gdalalg_vector_dissolve.cpp @@ -0,0 +1,132 @@ +/****************************************************************************** + * + * Project: GDAL + * Purpose: "gdal vector dissolve" + * Author: Dan Baston + * + ****************************************************************************** + * Copyright (c) 2025, ISciences LLC + * + * SPDX-License-Identifier: MIT + ****************************************************************************/ + +#include "gdalalg_vector_dissolve.h" + +#include "gdal_priv.h" +#include "ogrsf_frmts.h" + +//! @cond Doxygen_Suppress + +#ifndef _ +#define _(x) (x) +#endif + +/************************************************************************/ +/* GDALVectorDissolveAlgorithm() */ +/************************************************************************/ + +GDALVectorDissolveAlgorithm::GDALVectorDissolveAlgorithm(bool standaloneStep) + : GDALVectorGeomAbstractAlgorithm(NAME, DESCRIPTION, HELP_URL, + standaloneStep, m_opts) +{ +} + +#ifdef HAVE_GEOS + +namespace +{ + +/************************************************************************/ +/* GDALVectorDissolveAlgorithmLayer */ +/************************************************************************/ + +class GDALVectorDissolveAlgorithmLayer final + : public GDALVectorGeomOneToOneAlgorithmLayer +{ + public: + GDALVectorDissolveAlgorithmLayer( + OGRLayer &oSrcLayer, const GDALVectorDissolveAlgorithm::Options &opts) + : GDALVectorGeomOneToOneAlgorithmLayer( + oSrcLayer, opts) + { + } + + protected: + using GDALVectorGeomOneToOneAlgorithmLayer::TranslateFeature; + + std::unique_ptr + TranslateFeature(std::unique_ptr poSrcFeature) const override; + + private: +}; + +/************************************************************************/ +/* TranslateFeature() */ +/************************************************************************/ + +std::unique_ptr GDALVectorDissolveAlgorithmLayer::TranslateFeature( + std::unique_ptr poSrcFeature) const +{ + const int nGeomFieldCount = poSrcFeature->GetGeomFieldCount(); + for (int i = 0; i < nGeomFieldCount; ++i) + { + if (IsSelectedGeomField(i)) + { + if (auto poGeom = std::unique_ptr( + poSrcFeature->StealGeometry(i))) + { + poGeom.reset(poGeom->UnaryUnion()); + + if (poGeom) + { + poGeom->assignSpatialReference(m_srcLayer.GetLayerDefn() + ->GetGeomFieldDefn(i) + ->GetSpatialRef()); + poSrcFeature->SetGeomField(i, std::move(poGeom)); + } + } + } + } + + return poSrcFeature; +} + +} // namespace + +#endif // HAVE_GEOS + +/************************************************************************/ +/* GDALVectorDissolveAlgorithm::CreateAlgLayer() */ +/************************************************************************/ + +std::unique_ptr +GDALVectorDissolveAlgorithm::CreateAlgLayer([[maybe_unused]] OGRLayer &srcLayer) +{ +#ifdef HAVE_GEOS + return std::make_unique(srcLayer, m_opts); +#else + CPLAssert(false); + return nullptr; +#endif +} + +/************************************************************************/ +/* GDALVectorDissolveAlgorithm::RunStep() */ +/************************************************************************/ + +bool GDALVectorDissolveAlgorithm::RunStep(GDALPipelineStepRunContext &ctxt) +{ +#ifdef HAVE_GEOS + return GDALVectorGeomAbstractAlgorithm::RunStep(ctxt); +#else + (void)ctxt; + ReportError(CE_Failure, CPLE_NotSupported, + "This algorithm is only supported for builds against GEOS"); + return false; +#endif +} + +GDALVectorDissolveAlgorithmStandalone:: + ~GDALVectorDissolveAlgorithmStandalone() = default; + +//! @endcond diff --git a/apps/gdalalg_vector_dissolve.h b/apps/gdalalg_vector_dissolve.h new file mode 100644 index 000000000000..83e9a74d80f7 --- /dev/null +++ b/apps/gdalalg_vector_dissolve.h @@ -0,0 +1,68 @@ +/****************************************************************************** +* + * Project: GDAL + * Purpose: "gdal vector dissolve" + * Author: Daniel Baston + * + ****************************************************************************** + * Copyright (c) 2025, ISciences LLC + * + * SPDX-License-Identifier: MIT + ****************************************************************************/ + +#ifndef GDALALG_VECTOR_DISSOLVE_INCLUDED +#define GDALALG_VECTOR_DISSOLVE_INCLUDED + +#include "gdalalg_vector_geom.h" +#include "cpl_progress.h" + +#include + +//! @cond Doxygen_Suppress + +/************************************************************************/ +/* GDALVectorDissolveAlgorithm */ +/************************************************************************/ + +class GDALVectorDissolveAlgorithm : public GDALVectorGeomAbstractAlgorithm +{ + public: + static constexpr const char *NAME = "dissolve"; + static constexpr const char *DESCRIPTION = "Dissolves multipart features"; + static constexpr const char *HELP_URL = + "/programs/gdal_vector_dissolve.html"; + + explicit GDALVectorDissolveAlgorithm(bool standaloneStep = false); + + std::unique_ptr + CreateAlgLayer(OGRLayer &srcLayer) override; + + struct Options : OptionsBase + { + }; + + private: + bool RunStep(GDALPipelineStepRunContext &ctxt) override; + + Options m_opts{}; +}; + +/************************************************************************/ +/* GDALVectorDissolveAlgorithmStandalone */ +/************************************************************************/ + +class GDALVectorDissolveAlgorithmStandalone final + : public GDALVectorDissolveAlgorithm +{ + public: + GDALVectorDissolveAlgorithmStandalone() + : GDALVectorDissolveAlgorithm(/* standaloneStep = */ true) + { + } + + ~GDALVectorDissolveAlgorithmStandalone() override; +}; + +//! @endcond + +#endif /* GDALALG_VECTOR_DISSOLVE_INCLUDED */ diff --git a/apps/gdalalg_vector_pipeline.cpp b/apps/gdalalg_vector_pipeline.cpp index bee0b4f66f2b..bce057c537dd 100644 --- a/apps/gdalalg_vector_pipeline.cpp +++ b/apps/gdalalg_vector_pipeline.cpp @@ -18,7 +18,9 @@ #include "gdalalg_vector_check_geometry.h" #include "gdalalg_vector_clean_coverage.h" #include "gdalalg_vector_clip.h" +#include "gdalalg_vector_collect.h" #include "gdalalg_vector_concat.h" +#include "gdalalg_vector_dissolve.h" #include "gdalalg_vector_edit.h" #include "gdalalg_vector_explode_collections.h" #include "gdalalg_vector_filter.h" @@ -147,11 +149,13 @@ void GDALVectorPipelineAlgorithm::RegisterAlgorithms( registry.Register(); registry.Register(); registry.Register(); + registry.Register(); registry.Register(); registry.Register(); registry.Register( addSuffixIfNeeded(GDALVectorClipAlgorithm::NAME)); + registry.Register(); registry.Register( addSuffixIfNeeded(GDALVectorEditAlgorithm::NAME)); diff --git a/autotest/utilities/test_gdalalg_vector_collect.py b/autotest/utilities/test_gdalalg_vector_collect.py new file mode 100644 index 000000000000..593ccbc56ca7 --- /dev/null +++ b/autotest/utilities/test_gdalalg_vector_collect.py @@ -0,0 +1,290 @@ +#!/usr/bin/env pytest +# -*- coding: utf-8 -*- +############################################################################### +# Project: GDAL/OGR Test Suite +# Purpose: 'gdal vector collect' testing +# Author: Daniel Baston +# +############################################################################### +# Copyright (c) 2025, ISciences LLC +# +# SPDX-License-Identifier: MIT +############################################################################### + +import string + +import pytest + +from osgeo import gdal, ogr, osr + + +@pytest.fixture() +def alg(): + return gdal.GetGlobalAlgorithmRegistry()["vector"]["collect"] + + +def test_gdalalg_vector_collect(alg): + + src_ds = gdal.OpenEx("../ogr/data/poly.shp") + + alg["input"] = src_ds + alg["output"] = "" + alg["output-format"] = "stream" + + assert alg.Run() + + dst_ds = alg["output"].GetDataset() + + assert dst_ds.GetLayerCount() == 1 + assert dst_ds.GetSpatialRef().IsSame(src_ds.GetSpatialRef()) + + src_lyr = src_ds.GetLayer(0) + dst_lyr = dst_ds.GetLayer(0) + + assert dst_lyr.GetName() == src_lyr.GetName() + assert dst_lyr.GetSpatialRef().IsSame(src_lyr.GetSpatialRef()) + assert dst_lyr.GetFeatureCount() == 1 + assert dst_lyr.GetLayerDefn().GetGeomFieldCount() == 1 + assert dst_lyr.GetLayerDefn().GetGeomType() == ogr.wkbMultiPolygon + + src_area = sum(f.GetGeometryRef().GetArea() for f in src_lyr) + src_parts = sum(f.GetGeometryRef().GetGeometryCount() for f in src_lyr) + + f = dst_lyr.GetNextFeature() + + assert f.GetGeometryRef().GetGeometryCount() == src_parts + assert f.GetGeometryRef().GetArea() == src_area + + +@pytest.mark.parametrize( + "group_by", ["int_field", "str_field", ["str_field", "int_field"]] +) +def test_gdalalg_vector_collect_group_by(alg, group_by): + + src_ds = gdal.GetDriverByName("MEM").CreateVector("") + src_lyr = src_ds.CreateLayer("layer", geom_type=ogr.wkbPoint) + + src_lyr.CreateField(ogr.FieldDefn("int_field", ogr.OFTInteger)) + src_lyr.CreateField(ogr.FieldDefn("str_field", ogr.OFTString)) + + f = ogr.Feature(src_lyr.GetLayerDefn()) + + for i in range(7): + f["int_field"] = i % 2 + f["str_field"] = string.ascii_letters[i % 3] + f.SetGeometry(ogr.CreateGeometryFromWkt(f"POINT ({i} {2 * i})")) + src_lyr.CreateFeature(f) + + alg["input"] = src_ds + alg["output"] = "" + alg["output-format"] = "stream" + alg["group-by"] = group_by + + assert alg.Run() + + dst_ds = alg["output"].GetDataset() + dst_lyr = dst_ds.GetLayer() + + features = [f for f in dst_lyr] + + if group_by == "int_field": + assert len(features) == 2 + assert dst_lyr.GetLayerDefn().GetFieldCount() == 1 + assert dst_lyr.GetLayerDefn().GetFieldDefn(0).GetName() == "int_field" + assert dst_lyr.GetLayerDefn().GetFieldDefn(0).GetType() == ogr.OFTInteger + + assert features[0]["int_field"] == 0 + assert ( + features[0] + .GetGeometryRef() + .Equals( + ogr.CreateGeometryFromWkt("MULTIPOINT ((0 0), (2 4), (4 8), (6 12))") + ) + ) + + assert features[1]["int_field"] == 1 + assert ( + features[1] + .GetGeometryRef() + .Equals(ogr.CreateGeometryFromWkt("MULTIPOINT ((1 2), (3 6), (5 10))")) + ) + elif group_by == "str_field": + assert len(features) == 3 + assert dst_lyr.GetLayerDefn().GetFieldCount() == 1 + assert dst_lyr.GetLayerDefn().GetFieldDefn(0).GetName() == "str_field" + assert dst_lyr.GetLayerDefn().GetFieldDefn(0).GetType() == ogr.OFTString + + assert features[0]["str_field"] == "a" + assert ( + features[0] + .GetGeometryRef() + .Equals(ogr.CreateGeometryFromWkt("MULTIPOINT ((0 0), (3 6), (6 12)))")) + ) + + assert features[1]["str_field"] == "b" + assert ( + features[1] + .GetGeometryRef() + .Equals(ogr.CreateGeometryFromWkt("MULTIPOINT ((1 2), (4 8))")) + ) + + assert features[2]["str_field"] == "c" + assert ( + features[2] + .GetGeometryRef() + .Equals(ogr.CreateGeometryFromWkt("MULTIPOINT ((2 4), (5 10))")) + ) + elif group_by == ["str_field", "int_field"]: + assert len(features) == 6 + assert dst_lyr.GetLayerDefn().GetFieldCount() == 2 + assert dst_lyr.GetLayerDefn().GetFieldDefn(0).GetName() == "str_field" + assert dst_lyr.GetLayerDefn().GetFieldDefn(0).GetType() == ogr.OFTString + assert dst_lyr.GetLayerDefn().GetFieldDefn(1).GetName() == "int_field" + assert dst_lyr.GetLayerDefn().GetFieldDefn(1).GetType() == ogr.OFTInteger + + assert features[0]["int_field"] == 0 + assert features[0]["str_field"] == "a" + assert ( + features[0] + .GetGeometryRef() + .Equals(ogr.CreateGeometryFromWkt("MULTIPOINT ((0 0), (6 12))")) + ) + + assert features[1]["int_field"] == 1 + assert features[1]["str_field"] == "a" + assert ( + features[1] + .GetGeometryRef() + .Equals(ogr.CreateGeometryFromWkt("MULTIPOINT ((3 6))")) + ) + + assert features[2]["int_field"] == 0 + assert features[2]["str_field"] == "b" + assert ( + features[2] + .GetGeometryRef() + .Equals(ogr.CreateGeometryFromWkt("MULTIPOINT ((4 8))")) + ) + + assert features[3]["int_field"] == 1 + assert features[3]["str_field"] == "b" + assert ( + features[3] + .GetGeometryRef() + .Equals(ogr.CreateGeometryFromWkt("MULTIPOINT ((1 2))")) + ) + + assert features[4]["int_field"] == 0 + assert features[4]["str_field"] == "c" + assert ( + features[4] + .GetGeometryRef() + .Equals(ogr.CreateGeometryFromWkt("MULTIPOINT ((2 4))")) + ) + + assert features[5]["int_field"] == 1 + assert features[5]["str_field"] == "c" + assert ( + features[5] + .GetGeometryRef() + .Equals(ogr.CreateGeometryFromWkt("MULTIPOINT ((5 10))")) + ) + else: + pytest.fail(f"Unhandled value of group-by: : {group_by}") + + +@pytest.mark.parametrize("geoms", [["POINT (3 7)", None], [None, "POINT (3 7)"]]) +def test_gdalalg_vector_collect_null_geometry(alg, geoms): + + src_ds = gdal.GetDriverByName("MEM").CreateVector("") + src_lyr = src_ds.CreateLayer("layer", geom_type=ogr.wkbPoint) + + f = ogr.Feature(src_lyr.GetLayerDefn()) + + expect_empty = True + for wkt in geoms: + if wkt is None: + f.SetGeometry(None) + else: + f.SetGeometry(ogr.CreateGeometryFromWkt(wkt)) + expect_empty = False + src_lyr.CreateFeature(f) + + alg["input"] = src_ds + alg["output"] = "" + alg["output-format"] = "stream" + + assert alg.Run() + + dst_ds = alg["output"].GetDataset() + dst_lyr = dst_ds.GetLayer(0) + + assert dst_lyr.GetFeatureCount() == 1 + f = dst_lyr.GetNextFeature() + geom = f.GetGeometryRef() + if expect_empty: + assert geom.ExportToWkt() == "MULTIPOINT EMPTY" + else: + assert geom.Equals(ogr.CreateGeometryFromWkt("MULTIPOINT ((3 7))")) + + +def test_gdalalg_vector_collect_multiple_geom_fields(alg): + + src_ds = gdal.GetDriverByName("MEM").CreateVector("") + src_lyr = src_ds.CreateLayer( + "layer", srs=osr.SpatialReference(epsg=4326), geom_type=ogr.wkbPoint + ) + + line_geom_defn = ogr.GeomFieldDefn("line_geom", ogr.wkbLineStringM) + line_geom_defn.SetSpatialRef(osr.SpatialReference(epsg=32145)) + + src_lyr.CreateGeomField(line_geom_defn) + + f = ogr.Feature(src_lyr.GetLayerDefn()) + f.SetGeomField(0, ogr.CreateGeometryFromWkt("POINT (3 7)")) + f.SetGeomField(1, ogr.CreateGeometryFromWkt("LINESTRING M (1 2 3)")) + + src_lyr.CreateFeature(f) + + f.SetGeomField(0, ogr.CreateGeometryFromWkt("POINT (2 8)")) + f.SetGeomField(1, ogr.CreateGeometryFromWkt("LINESTRING M (4 5 6)")) + + src_lyr.CreateFeature(f) + + alg["input"] = src_ds + alg["output"] = "" + alg["output-format"] = "stream" + + assert alg.Run() + + dst_ds = alg["output"].GetDataset() + dst_lyr = dst_ds.GetLayer(0) + + assert dst_lyr.GetFeatureCount() == 1 + + f = dst_lyr.GetNextFeature() + assert f.GetGeomFieldRef(0).ExportToWkt() == "MULTIPOINT (3 7,2 8)" + assert f.GetGeomFieldRef(0).GetSpatialReference().IsSame(src_lyr.GetSpatialRef()) + + assert ( + f.GetGeomFieldRef(1).ExportToIsoWkt() == "MULTILINESTRING M ((1 2 3),(4 5 6))" + ) + assert ( + f.GetGeomFieldRef(1) + .GetSpatialReference() + .IsSame(line_geom_defn.GetSpatialRef()) + ) + + +def test_gdalalg_vector_collect_group_by_invalid(alg): + + alg["input"] = "../ogr/data/poly.shp" + alg["group-by"] = "does_not_exist" + alg["output"] = "" + alg["output-format"] = "stream" + + with pytest.raises(Exception, match="attribute field .* does not exist"): + alg.Run() + + with pytest.raises(Exception, match="must be a list of unique field names"): + alg["group-by"] = ["EAS_ID", "AREA", "EAS_ID"] diff --git a/autotest/utilities/test_gdalalg_vector_dissolve.py b/autotest/utilities/test_gdalalg_vector_dissolve.py new file mode 100644 index 000000000000..f5b272635e2b --- /dev/null +++ b/autotest/utilities/test_gdalalg_vector_dissolve.py @@ -0,0 +1,63 @@ +#!/usr/bin/env pytest +# -*- coding: utf-8 -*- +############################################################################### +# Project: GDAL/OGR Test Suite +# Purpose: 'gdal vector dissolve' testing +# Author: Daniel Baston +# +############################################################################### +# Copyright (c) 2025, ISciences LLC +# +# SPDX-License-Identifier: MIT +############################################################################### + +import gdaltest +import pytest + +from osgeo import gdal, ogr + + +@pytest.fixture() +def alg(): + return gdal.GetGlobalAlgorithmRegistry()["vector"]["dissolve"] + + +pytestmark = pytest.mark.require_geos + + +@pytest.mark.parametrize( + "wkt_in,wkt_out", + [ + ["MULTIPOINT (3 3, 4 4, 3 3)", "MULTIPOINT (3 3, 4 4)"], + [ + "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)), ((0 0, 1 1, 0 1, 0 0)))", + "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + ], + [ + "MULTILINESTRING ((0 0, 1 1), (1 1, 2 2), (1 0, 0 1))", + "MULTILINESTRING ((0 0, 0.5 0.5), (0.5 0.5, 1 1), (1 1, 2 2), (1 0, 0.5 0.5), (0.5 0.5, 0 1))", + ], + ], +) +def test_gdalalg_vector_dissolve(alg, wkt_in, wkt_out): + + if type(wkt_in) is str: + wkt_in = [wkt_in] + if type(wkt_out) is str: + wkt_out = [wkt_out] + + alg["input"] = gdaltest.wkt_ds(wkt_in) + alg["output"] = "" + alg["output-format"] = "stream" + + assert alg.Run() + + dst_ds = alg["output"].GetDataset() + dst_lyr = dst_ds.GetLayer(0) + + assert dst_lyr.GetFeatureCount() == len(wkt_out) + + for f, expected_wkt in zip(dst_lyr, wkt_out): + g1 = f.GetGeometryRef().Normalize() + g2 = ogr.CreateGeometryFromWkt(expected_wkt).Normalize() + assert g1.Equals(g2) diff --git a/doc/source/conf.py b/doc/source/conf.py index 8de4e9ca6de1..af93c70e250e 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -869,9 +869,16 @@ def check_python_bindings(): [author_evenr], 1, ), + ( + "programs/gdal_vector_collect", + "gdal-vector-collect", + "Combine geometries into geometry collections", + [author_dbaston], + 1, + ), ( "programs/gdal_vector_concat", - "gdal-vector_concat", + "gdal-vector-concat", "Concatenate vector datasets", [author_evenr], 1, @@ -883,6 +890,13 @@ def check_python_bindings(): [author_evenr], 1, ), + ( + "programs/gdal_vector_dissolve", + "gdal-vector-dissolve", + "Unions the elmeents of each feature's geometry.", + [author_dbaston], + 1, + ), ( "programs/gdal_vector_edit", "gdal-vector-edit", diff --git a/doc/source/programs/gdal_vector_collect.rst b/doc/source/programs/gdal_vector_collect.rst new file mode 100644 index 000000000000..a417a42732df --- /dev/null +++ b/doc/source/programs/gdal_vector_collect.rst @@ -0,0 +1,38 @@ +.. _gdal_vector_collect: + +================================================================================ +``gdal vector collect`` +================================================================================ + +.. versionadded:: 3.13 + +.. only:: html + + Combine geometries into collections + +.. Index:: gdal vector collect + +Synopsis +-------- + +.. program-output:: gdal vector collect --help-doc + +Description +----------- + +:program:`gdal vector collect` combines geometries into geometry collections. + +The :option:`--group-by` argument can be used to determine which features are combined. + +``collect`` can be used as a step of :ref:`gdal_vector_pipeline`. + +Options ++++++++ + +.. option:: --group-by + + The names of fields whose unique values will be used to collect + input geometries. Any fields not listed in :option:`--group-by` will be + removed from the source layer. If :option:`--group-by` is omitted, the + entire layer will be combined into a single feature. + diff --git a/doc/source/programs/gdal_vector_dissolve.rst b/doc/source/programs/gdal_vector_dissolve.rst new file mode 100644 index 000000000000..c3b8f39f2332 --- /dev/null +++ b/doc/source/programs/gdal_vector_dissolve.rst @@ -0,0 +1,29 @@ +.. _gdal_vector_dissolve: + +================================================================================ +``gdal vector dissolve`` +================================================================================ + +.. versionadded:: 3.13 + +.. only:: html + + Unions the elements of each feature's geometry. + +.. Index:: gdal vector dissolve + +Synopsis +-------- + +.. program-output:: gdal vector dissolve --help-doc + +Description +----------- + +:program:`gdal vector dissolve` performs a union operation on the elements of each feature's geometry. This has the following effects: + +- Duplicate vertices are eliminated. +- Nodes are added where input linework intersects. +- Polygons that overlap are "dissolved" into a single feature. + +``dissolve`` can be used as a step of :ref:`gdal_vector_pipeline`. diff --git a/doc/source/programs/index.rst b/doc/source/programs/index.rst index c92048800e11..56fbcae1456a 100644 --- a/doc/source/programs/index.rst +++ b/doc/source/programs/index.rst @@ -198,8 +198,10 @@ Vector commands gdal_vector_check_geometry gdal_vector_clean_coverage gdal_vector_clip + gdal_vector_collect gdal_vector_concat gdal_vector_convert + gdal_vector_dissolve gdal_vector_edit gdal_vector_filter gdal_vector_info