From 3378d3e010d92b420f045d19140a0adf88ec22f1 Mon Sep 17 00:00:00 2001 From: Paul Ramsey Date: Thu, 11 Jun 2026 21:07:33 +0000 Subject: [PATCH 1/3] LineCollectionNoder.h in support for ST_NodeWin line noding pattern --- capi/geos_c.cpp | 6 + capi/geos_c.h.in | 34 +++ capi/geos_ts_c.cpp | 28 +++ .../operation/linenode/LineCollectionNoder.h | 147 +++++++++++ .../linenode/LineCollectionNoder.cpp | 203 ++++++++++++++++ tests/unit/capi/GEOSNodeCollectionTest.cpp | 176 ++++++++++++++ .../linenode/LineCollectionNoderTest.cpp | 229 ++++++++++++++++++ 7 files changed, 823 insertions(+) create mode 100644 include/geos/operation/linenode/LineCollectionNoder.h create mode 100644 src/operation/linenode/LineCollectionNoder.cpp create mode 100644 tests/unit/capi/GEOSNodeCollectionTest.cpp create mode 100644 tests/unit/operation/linenode/LineCollectionNoderTest.cpp diff --git a/capi/geos_c.cpp b/capi/geos_c.cpp index a421eabc06..5cd498514c 100644 --- a/capi/geos_c.cpp +++ b/capi/geos_c.cpp @@ -789,6 +789,12 @@ extern "C" { return GEOSNode_r(handle, g); } + Geometry* + GEOSNodeCollection(const Geometry* input, double gridSize) + { + return GEOSNodeCollection_r(handle, input, gridSize); + } + Geometry* GEOSSplit(const Geometry* g, const Geometry* edge) { diff --git a/capi/geos_c.h.in b/capi/geos_c.h.in index 56b0abc6cf..ffb44bcf83 100644 --- a/capi/geos_c.h.in +++ b/capi/geos_c.h.in @@ -1214,6 +1214,12 @@ extern GEOSGeometry GEOS_DLL *GEOSNode_r( GEOSContextHandle_t handle, const GEOSGeometry* g); +/** \see GEOSNodeCollection */ +extern GEOSGeometry GEOS_DLL *GEOSNodeCollection_r( + GEOSContextHandle_t handle, + const GEOSGeometry* input, + double gridSize); + /** \see GEOSSplit */ extern GEOSGeometry GEOS_DLL *GEOSSplit_r( GEOSContextHandle_t handle, @@ -5562,6 +5568,34 @@ extern GEOSGeometry GEOS_DLL * GEOSVoronoiDiagram( */ extern GEOSGeometry GEOS_DLL *GEOSNode(const GEOSGeometry* g); +/** +* Nodes a collection of linear geometries against each other, +* returning a collection of the same size where each member has been +* split into a MultiLineString at all interior node points. +* +* Unlike GEOSNode(), which collects all edges into a single flattened +* MultiLineString, this function preserves the per-member structure of +* the input, returning one MultiLineString per input element. +* Linework shared between members is not dissolved. +* +* Input members must be linear (LineString, MultiLineString, or +* GeometryCollection of linear types). Non-linear components are +* ignored; their output slot will be an empty MultiLineString. +* +* \param input A GeometryCollection of linear geometries. +* \param gridSize Snap-rounding grid size for robust noding of inputs +* with near-coincident coordinates, or 0.0 for standard noding. +* \return A GeometryCollection of the same size as the input, with each +* member returned as a noded MultiLineString, or NULL on error. +* Caller is responsible for freeing with GEOSGeom_destroy(). +* \see geos::operation::linenode::LineCollectionNoder::node +* +* \since 3.14 +*/ +extern GEOSGeometry GEOS_DLL *GEOSNodeCollection( + const GEOSGeometry* input, + double gridSize); + /** Split a linear or polygonal input * diff --git a/capi/geos_ts_c.cpp b/capi/geos_ts_c.cpp index 7b06105cff..26f0569b53 100644 --- a/capi/geos_ts_c.cpp +++ b/capi/geos_ts_c.cpp @@ -87,6 +87,7 @@ #include #include #include +#include #include #include #include @@ -2042,6 +2043,33 @@ extern "C" { }); } + Geometry* + GEOSNodeCollection_r(GEOSContextHandle_t extHandle, + const Geometry* input, + double gridSize) + { + using geos::operation::linenode::LineCollectionNoder; + + return execute(extHandle, [&]() -> Geometry* { + const GeometryCollection* col = + dynamic_cast(input); + if (!col) return nullptr; + + std::vector geoms; + geoms.reserve(col->getNumGeometries()); + for (std::size_t i = 0; i < col->getNumGeometries(); i++) + geoms.push_back(col->getGeometryN(i)); + + LineCollectionNoder lcn(geoms); + auto result = lcn.node(gridSize); + + const GeometryFactory* gf = input->getFactory(); + auto r = gf->createGeometryCollection(std::move(result)); + r->setSRID(input->getSRID()); + return r.release(); + }); + } + Geometry* GEOSSplit_r(GEOSContextHandle_t extHandle, const Geometry* g, const Geometry* edge) { diff --git a/include/geos/operation/linenode/LineCollectionNoder.h b/include/geos/operation/linenode/LineCollectionNoder.h new file mode 100644 index 0000000000..0ccb789db4 --- /dev/null +++ b/include/geos/operation/linenode/LineCollectionNoder.h @@ -0,0 +1,147 @@ +/********************************************************************** + * + * GEOS - Geometry Engine Open Source + * http://geos.osgeo.org + * + * Copyright (C) 2025 Paul Ramsey + * + * This is free software; you can redistribute and/or modify it under + * the terms of the GNU Lesser General Public Licence as published + * by the Free Software Foundation. + * See the COPYING file for more information. + * + **********************************************************************/ + +#pragma once + +#include +#include +#include + +// Forward declarations +namespace geos { +namespace geom { +class Geometry; +class GeometryFactory; +} +namespace noding { +class NodedSegmentString; +class SegmentString; +} +} + +namespace geos { +namespace operation { +namespace linenode { + +/** + * Nodes a collection of linear geometries against each other, + * returning a collection of the same size where each member has + * been split into a MultiLineString at all interior node points. + * + * Unlike geos::noding::GeometryNoder::node(), which collects all + * input edges into a single flattened MultiLineString, this class + * preserves the identity of each input member. Linework that is + * shared (or nearly shared) between members is not dissolved. + * + * Input geometries must be linear (LineString, MultiLineString, or + * a GeometryCollection of linear types). Non-linear components (e.g. + * polygon rings, points) are silently ignored; their output slot + * will contain an empty MultiLineString. + * + * When gridSize == 0.0 (default), standard IteratedNoder is used. + * When gridSize > 0.0, SnapRoundingNoder is used instead, providing + * robust output for inputs with near-coincident coordinates + * (see https://github.com/libgeos/geos/issues/877). + * Values <= 0.0 all use IteratedNoder. + * + * Usage: + * @code + * std::vector lines = { ... }; + * auto noded = LineCollectionNoder::node(lines); // standard noding + * auto noded = LineCollectionNoder::node(lines, 1.0); // snap-rounding + * @endcode + * + * @author Paul Ramsey + */ +class GEOS_DLL LineCollectionNoder { + + using Geometry = geos::geom::Geometry; + using GeometryFactory = geos::geom::GeometryFactory; + using NodedSegmentString = geos::noding::NodedSegmentString; + using SegmentString = geos::noding::SegmentString; + +public: + + /** + * Creates a new LineCollectionNoder for the given collection of + * linear geometries. + * + * @param collection input geometries; caller retains ownership. + * The vector must outlive this object. + */ + LineCollectionNoder(const std::vector& collection); + + /** + * Nodes a collection of linear geometries (vector of raw const pointers). + * + * @param collection vector of linear geometries + * @param gridSize snap-rounding grid size, or 0.0 for standard noding + * @return one noded MultiLineString per input element, in the same order + */ + static std::vector> node( + std::vector& collection, + double gridSize = 0.0); + + /** + * Nodes a collection of linear geometries (vector of unique_ptr). + * + * @param collection vector of owning pointers to linear geometries + * @param gridSize snap-rounding grid size, or 0.0 for standard noding + * @return one noded MultiLineString per input element, in the same order + */ + static std::vector> node( + const std::vector>& collection, + double gridSize = 0.0); + + /** + * Computes the noded collection. + * + * @param gridSize snap-rounding grid size, or 0.0 for standard noding + * @return one noded MultiLineString per input element, in the same order + */ + std::vector> node(double gridSize = 0.0); + + // Noncopyable + LineCollectionNoder(const LineCollectionNoder&) = delete; + LineCollectionNoder& operator=(const LineCollectionNoder&) = delete; + +private: + + const std::vector& m_input; + const GeometryFactory* m_geomFactory; + + /** + * Extracts NodedSegmentStrings from the linear components of g + * (LineString only, not LinearRing/polygon rings), tagging each + * with reinterpret_cast(static_cast(index)). + */ + static void extractSegments( + const Geometry& g, + std::size_t index, + std::vector>& segments); + + /** + * Builds a MultiLineString from the noded sub-segments that carry + * the given source index tag. Deduplicates via OrientedCoordinateArray + * (same logic as GeometryNoder::toGeometry). + */ + std::unique_ptr buildResult( + const std::vector>& nodedSegs, + std::size_t index) const; + +}; + +} // geos::operation::linenode +} // geos::operation +} // geos diff --git a/src/operation/linenode/LineCollectionNoder.cpp b/src/operation/linenode/LineCollectionNoder.cpp new file mode 100644 index 0000000000..66dfafc591 --- /dev/null +++ b/src/operation/linenode/LineCollectionNoder.cpp @@ -0,0 +1,203 @@ +/********************************************************************** + * + * GEOS - Geometry Engine Open Source + * http://geos.osgeo.org + * + * Copyright (C) 2025 Paul Ramsey + * + * This is free software; you can redistribute and/or modify it under + * the terms of the GNU Lesser General Public Licence as published + * by the Free Software Foundation. + * See the COPYING file for more information. + * + **********************************************************************/ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using geos::geom::Geometry; +using geos::geom::GeometryComponentFilter; +using geos::geom::GeometryFactory; +using geos::geom::LinearRing; +using geos::geom::LineString; +using geos::geom::PrecisionModel; +using geos::noding::IteratedNoder; +using geos::noding::NodedSegmentString; +using geos::noding::Noder; +using geos::noding::OrientedCoordinateArray; +using geos::noding::SegmentString; +using geos::noding::snapround::SnapRoundingNoder; + +namespace geos { +namespace operation { +namespace linenode { + +namespace { + +class LineStringExtractor : public GeometryComponentFilter { +public: + LineStringExtractor(std::size_t p_index, + std::vector>& p_segs) + : m_index(p_index) + , m_segs(p_segs) + {} + + void filter_ro(const Geometry* g) override + { + // Skip polygon rings — only standalone LineStrings + if (dynamic_cast(g)) return; + const auto* ls = dynamic_cast(g); + if (!ls || ls->isEmpty()) return; + + auto coords = ls->getSharedCoordinates(); + auto nss = std::make_unique( + coords, + ls->hasZ(), + ls->hasM(), + reinterpret_cast(static_cast(m_index))); + m_segs.push_back(std::move(nss)); + } + +private: + std::size_t m_index; + std::vector>& m_segs; +}; + +} // anonymous namespace + + +LineCollectionNoder::LineCollectionNoder(const std::vector& collection) + : m_input(collection) + , m_geomFactory(collection.empty() ? nullptr : collection[0]->getFactory()) +{} + + +/* static */ +void +LineCollectionNoder::extractSegments( + const Geometry& g, + std::size_t index, + std::vector>& segments) +{ + LineStringExtractor ex(index, segments); + g.apply_ro(&ex); +} + + +/* private */ +std::unique_ptr +LineCollectionNoder::buildResult( + const std::vector>& nodedSegs, + std::size_t index) const +{ + const void* tag = reinterpret_cast(static_cast(index)); + + std::set seen; + std::vector> lines; + + for (const auto& ss : nodedSegs) { + if (ss->getData() != tag) continue; + const auto& coords = ss->getCoordinates(); + OrientedCoordinateArray oca(*coords); + if (seen.insert(oca).second) { + lines.push_back(m_geomFactory->createLineString(coords)); + } + } + + return m_geomFactory->createMultiLineString(std::move(lines)); +} + + +/* public */ +std::vector> +LineCollectionNoder::node(double gridSize) +{ + const std::size_t n = m_input.size(); + std::vector> result; + result.reserve(n); + + if (n == 0) return result; + + // Extract all LinearString segments from all inputs, tagged with source index + std::vector> segments; + for (std::size_t i = 0; i < n; ++i) { + extractSegments(*m_input[i], i, segments); + } + + if (segments.empty()) { + for (std::size_t i = 0; i < n; ++i) + result.push_back(m_geomFactory->createMultiLineString()); + return result; + } + + // Build raw pointer vector for noder API + std::vector rawSegs; + rawSegs.reserve(segments.size()); + for (auto& ss : segments) + rawSegs.push_back(ss.get()); + + // Select noder based on gridSize + std::unique_ptr pm; + std::unique_ptr noder; + if (gridSize > 0.0) { + pm = std::make_unique(1.0 / gridSize); + noder = std::make_unique(pm.get()); + } else { + noder = std::make_unique(m_geomFactory->getPrecisionModel()); + } + + noder->computeNodes(rawSegs); + auto nodedSegs = noder->getNodedSubstrings(); + + // Build one noded MultiLineString per input geometry + for (std::size_t i = 0; i < n; ++i) + result.push_back(buildResult(nodedSegs, i)); + + return result; +} + + +/* static */ +std::vector> +LineCollectionNoder::node( + std::vector& collection, + double gridSize) +{ + LineCollectionNoder lcn(collection); + return lcn.node(gridSize); +} + + +/* static */ +std::vector> +LineCollectionNoder::node( + const std::vector>& collection, + double gridSize) +{ + std::vector raw; + raw.reserve(collection.size()); + for (const auto& g : collection) + raw.push_back(g.get()); + LineCollectionNoder lcn(raw); + return lcn.node(gridSize); +} + +} // geos::operation::linenode +} // geos::operation +} // geos diff --git a/tests/unit/capi/GEOSNodeCollectionTest.cpp b/tests/unit/capi/GEOSNodeCollectionTest.cpp new file mode 100644 index 0000000000..43be9881e0 --- /dev/null +++ b/tests/unit/capi/GEOSNodeCollectionTest.cpp @@ -0,0 +1,176 @@ +// Test Suite for C-API GEOSNodeCollection + +#include +#include + +#include "capi_test_utils.h" + +namespace tut { + +struct test_capigeosnode_collection_data : public capitest::utility {}; + +typedef test_group group; +typedef group::object object; +group test_capigeosnode_collection_group("capi::GEOSNodeCollection"); + +// Basic: two crossing lines produce two noded MultiLineStrings +template<> template<> void object::test<1>() +{ + set_test_name("two crossing linestrings"); + input_ = fromWKT("GEOMETRYCOLLECTION (LINESTRING (0 0, 10 10), LINESTRING (0 10, 10 0))"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(result_ != nullptr); + ensure_equals(GEOSGeomTypeId(result_), GEOS_GEOMETRYCOLLECTION); + ensure_equals(GEOSGetNumGeometries(result_), 2); + ensure_equals(GEOSGetNumGeometries(GEOSGetGeometryN(result_, 0)), 2); + ensure_equals(GEOSGetNumGeometries(GEOSGetGeometryN(result_, 1)), 2); +} + +// Issue 877: near-duplicate rings fail with IteratedNoder; snap-rounding fixes it +template<> template<> void object::test<2>() +{ + set_test_name("issue 877 near-duplicate rings with snap-rounding"); + input_ = fromWKT( + "GEOMETRYCOLLECTION (" + "LINESTRING (125635 6696, 131951 6376, 132163 474.0000000000043, 128381 1569.9999999999986, 125635 6696)," + "LINESTRING (125635 6696, 131951 6376, 132163 474, 128381 1570, 125635 6696))"); + result_ = GEOSNodeCollection(input_, 1.0); + ensure("snap-rounding succeeds", result_ != nullptr); + ensure_equals("count preserved", GEOSGetNumGeometries(result_), 2); +} + +// Non-collection input returns NULL +template<> template<> void object::test<3>() +{ + set_test_name("non-collection input returns NULL"); + input_ = fromWKT("LINESTRING (0 0, 1 1)"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure("non-collection returns NULL", result_ == nullptr); +} + +// Non-intersecting: count preserved, each member stays as single-segment MultiLineString +template<> template<> void object::test<4>() +{ + set_test_name("non-intersecting: count preserved"); + input_ = fromWKT("GEOMETRYCOLLECTION (LINESTRING (0 0, 5 5), LINESTRING (10 0, 15 5))"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(result_ != nullptr); + ensure_equals(GEOSGetNumGeometries(result_), 2); + ensure_equals(GEOSGetNumGeometries(GEOSGetGeometryN(result_, 0)), 1); + ensure_equals(GEOSGetNumGeometries(GEOSGetGeometryN(result_, 1)), 1); +} + +// SRID is preserved on the output collection +template<> template<> void object::test<5>() +{ + set_test_name("SRID preservation"); + input_ = fromWKT("GEOMETRYCOLLECTION (LINESTRING (0 0, 10 10), LINESTRING (0 10, 10 0))"); + GEOSSetSRID(input_, 4326); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(result_ != nullptr); + ensure_equals("SRID preserved", GEOSGetSRID(result_), GEOSGetSRID(input_)); +} + +// Z interpolation: new node points get Z averaged from both intersecting lines +template<> template<> void object::test<6>() +{ + set_test_name("Z averaged at new node points from both intersecting lines"); + // Line 0: Z 0->1 at t=0.5 -> 0.5; Line 1: Z 5->10 at t=0.5 -> 7.5; avg=4.0 + input_ = fromWKT( + "GEOMETRYCOLLECTION (" + "LINESTRING Z (0 0 0, 1 1 1)," + "LINESTRING Z (0 1 5, 1 0 10))"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(result_ != nullptr); + expected_ = fromWKT( + "GEOMETRYCOLLECTION (" + "MULTILINESTRING Z ((0 0 0, 0.5 0.5 4), (0.5 0.5 4, 1 1 1))," + "MULTILINESTRING Z ((0 1 5, 0.5 0.5 4), (0.5 0.5 4, 1 0 10)))"); + GEOSNormalize(result_); + GEOSNormalize(expected_); + ensure_geometry_equals(result_, expected_); +} + +// M interpolation: new node points get M averaged from both intersecting lines +template<> template<> void object::test<7>() +{ + set_test_name("M averaged at new node points from both intersecting lines"); + // Line 0: M 0->1 at t=0.5 -> 0.5; Line 1: M 5->10 at t=0.5 -> 7.5; avg=4.0 + input_ = fromWKT( + "GEOMETRYCOLLECTION (" + "LINESTRING M (0 0 0, 1 1 1)," + "LINESTRING M (0 1 5, 1 0 10))"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(result_ != nullptr); + expected_ = fromWKT( + "GEOMETRYCOLLECTION (" + "MULTILINESTRING M ((0 0 0, 0.5 0.5 4), (0.5 0.5 4, 1 1 1))," + "MULTILINESTRING M ((0 1 5, 0.5 0.5 4), (0.5 0.5 4, 1 0 10)))"); + GEOSNormalize(result_); + GEOSNormalize(expected_); + ensure_geometry_equals(result_, expected_); +} + +// gridSize < 0: treated as 0, does not crash +template<> template<> void object::test<8>() +{ + set_test_name("gridSize < 0 treated as standard noding"); + input_ = fromWKT("GEOMETRYCOLLECTION (LINESTRING (0 0, 10 10), LINESTRING (0 10, 10 0))"); + result_ = GEOSNodeCollection(input_, -1.0); + ensure("negative gridSize does not crash", result_ != nullptr); + ensure_equals("count preserved", GEOSGetNumGeometries(result_), 2); +} + +// Empty GEOMETRYCOLLECTION: returns empty collection, not NULL +template<> template<> void object::test<9>() +{ + set_test_name("empty collection"); + input_ = fromWKT("GEOMETRYCOLLECTION EMPTY"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure("empty collection returns non-NULL", result_ != nullptr); + ensure_equals("result has 0 members", GEOSGetNumGeometries(result_), 0); +} + +// Identical members: both preserved, not dissolved +template<> template<> void object::test<10>() +{ + set_test_name("identical members each preserved separately"); + input_ = fromWKT("GEOMETRYCOLLECTION (LINESTRING (0 0, 10 0), LINESTRING (0 0, 10 0))"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(result_ != nullptr); + ensure_equals("count preserved", GEOSGetNumGeometries(result_), 2); + ensure("member 0 non-empty", !GEOSisEmpty(GEOSGetGeometryN(result_, 0))); + ensure("member 1 non-empty", !GEOSisEmpty(GEOSGetGeometryN(result_, 1))); +} + +// Non-linear (POLYGON) member produces empty MultiLineString in its output slot +template<> template<> void object::test<11>() +{ + set_test_name("polygon member yields empty output slot"); + input_ = fromWKT( + "GEOMETRYCOLLECTION (" + "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))," + "LINESTRING (5 -5, 5 15))"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(result_ != nullptr); + ensure_equals("count preserved", GEOSGetNumGeometries(result_), 2); + ensure("polygon slot is empty", GEOSisEmpty(GEOSGetGeometryN(result_, 0))); + ensure("line slot is non-empty", !GEOSisEmpty(GEOSGetGeometryN(result_, 1))); +} + +// Nested GEOMETRYCOLLECTION member: all linear components extracted +template<> template<> void object::test<12>() +{ + set_test_name("nested GEOMETRYCOLLECTION member"); + input_ = fromWKT( + "GEOMETRYCOLLECTION (" + "GEOMETRYCOLLECTION (LINESTRING (0 0, 10 0), LINESTRING (0 5, 10 5))," + "LINESTRING (5 -1, 5 10))"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(result_ != nullptr); + ensure_equals("count preserved", GEOSGetNumGeometries(result_), 2); + ensure_equals("nested member has 4 segments", + GEOSGetNumGeometries(GEOSGetGeometryN(result_, 0)), 4); +} + +} // namespace tut diff --git a/tests/unit/operation/linenode/LineCollectionNoderTest.cpp b/tests/unit/operation/linenode/LineCollectionNoderTest.cpp new file mode 100644 index 0000000000..d9e0af152b --- /dev/null +++ b/tests/unit/operation/linenode/LineCollectionNoderTest.cpp @@ -0,0 +1,229 @@ +// Test Suite for geos::operation::linenode::LineCollectionNoder + +#include +#include +#include +#include + +#include "utility.h" + +using geos::geom::Geometry; +using geos::io::WKTReader; +using geos::operation::linenode::LineCollectionNoder; + +namespace tut { + +struct test_linecollectionnoder_data { + WKTReader r; + + std::vector> readArray( + std::initializer_list wkts) + { + std::vector> v; + for (const char* wkt : wkts) + v.push_back(r.read(wkt)); + return v; + } + + void checkResult( + const std::vector>& input, + std::initializer_list expectedWkts, + double gridSize = 0.0) + { + auto actual = LineCollectionNoder::node(input, gridSize); + std::vector exp(expectedWkts); + ensure_equals("result count", actual.size(), exp.size()); + for (std::size_t i = 0; i < exp.size(); i++) { + auto e = r.read(exp[i]); + ensure_equals_exact_geometry_xyzm(actual[i].get(), e.get(), 1e-9); + } + } +}; + +typedef test_group group; +typedef group::object object; +group test_linecollectionnoder_group("operation::linenode::LineCollectionNoder"); + +// Two crossing linestrings: each split at the crossing point +template<> template<> void object::test<1>() +{ + set_test_name("two crossing linestrings"); + checkResult( + readArray({"LINESTRING (0 0, 10 10)", "LINESTRING (0 10, 10 0)"}), + {"MULTILINESTRING ((0 0, 5 5), (5 5, 10 10))", + "MULTILINESTRING ((0 10, 5 5), (5 5, 10 0))"}); +} + +// Non-intersecting: each becomes a single-segment MultiLineString +template<> template<> void object::test<2>() +{ + set_test_name("non-intersecting: each kept as single segment"); + checkResult( + readArray({"LINESTRING (0 0, 5 5)", "LINESTRING (10 0, 15 5)"}), + {"MULTILINESTRING ((0 0, 5 5))", "MULTILINESTRING ((10 0, 15 5))"}); +} + +// Three-way: three lines that all cross at (5,5) +template<> template<> void object::test<3>() +{ + set_test_name("three linestrings intersecting pairwise"); + checkResult( + readArray({ + "LINESTRING (0 5, 10 5)", + "LINESTRING (5 0, 5 10)", + "LINESTRING (0 0, 10 10)"}), + {"MULTILINESTRING ((0 5, 5 5), (5 5, 10 5))", + "MULTILINESTRING ((5 0, 5 5), (5 5, 5 10))", + "MULTILINESTRING ((0 0, 5 5), (5 5, 10 10))"}); +} + +// Issue 877: near-duplicate rings — snap-rounding converges where IteratedNoder would not +template<> template<> void object::test<4>() +{ + set_test_name("issue 877 near-duplicate rings with snap-rounding"); + auto input = readArray({ + "LINESTRING (125635 6696, 131951 6376, 132163 474.0000000000043, 128381 1569.9999999999986, 125635 6696)", + "LINESTRING (125635 6696, 131951 6376, 132163 474, 128381 1570, 125635 6696)" + }); + auto actual = LineCollectionNoder::node(input, 1.0); + ensure_equals("count preserved", actual.size(), std::size_t(2)); + ensure("member 0 non-empty", !actual[0]->isEmpty()); + ensure("member 1 non-empty", !actual[1]->isEmpty()); +} + +// MULTILINESTRING member: all component LineStrings extracted and noded as a unit +template<> template<> void object::test<5>() +{ + set_test_name("MULTILINESTRING member is noded as a unit"); + checkResult( + readArray({ + "MULTILINESTRING ((0 0, 10 10), (0 10, 10 0))", + "LINESTRING (5 -1, 5 11)"}), + {"MULTILINESTRING ((0 0, 5 5), (5 5, 10 10), (0 10, 5 5), (5 5, 10 0))", + "MULTILINESTRING ((5 -1, 5 5), (5 5, 5 11))"}); +} + +// Identical members: both preserved independently, linework not dissolved +template<> template<> void object::test<6>() +{ + set_test_name("identical members each keep their own linework"); + checkResult( + readArray({ + "LINESTRING (0 0, 10 0)", + "LINESTRING (0 0, 10 0)"}), + {"MULTILINESTRING ((0 0, 10 0))", + "MULTILINESTRING ((0 0, 10 0))"}); +} + +// Duplicate segments within same member: deduplication removes the second copy +template<> template<> void object::test<7>() +{ + set_test_name("duplicate segments within one member are deduplicated"); + auto input = readArray({ + "MULTILINESTRING ((0 0, 10 0), (0 0, 10 0))", + "LINESTRING (5 -5, 5 5)" + }); + auto actual = LineCollectionNoder::node(input); + ensure_equals("count preserved", actual.size(), std::size_t(2)); + ensure_equals("member 0 deduplicated to 2 segments", + (int)actual[0]->getNumGeometries(), 2); +} + +// Empty member: produces an empty MultiLineString in the corresponding output slot +template<> template<> void object::test<8>() +{ + set_test_name("empty member produces empty output slot"); + auto input = readArray({ + "LINESTRING EMPTY", + "LINESTRING (0 0, 10 0)" + }); + auto actual = LineCollectionNoder::node(input); + ensure_equals("count preserved", actual.size(), std::size_t(2)); + ensure("member 0 output is empty", actual[0]->isEmpty()); + ensure("member 1 output is non-empty", !actual[1]->isEmpty()); +} + +// Empty collection: returns an empty result vector +template<> template<> void object::test<9>() +{ + set_test_name("empty collection returns empty result"); + std::vector empty; + auto actual = LineCollectionNoder::node(empty); + ensure_equals("empty result", actual.size(), std::size_t(0)); +} + +// Non-linear member (POLYGON): no LineString components, output slot is empty +template<> template<> void object::test<10>() +{ + set_test_name("polygon member yields empty output slot"); + auto input = readArray({ + "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))", + "LINESTRING (5 -5, 5 15)" + }); + auto actual = LineCollectionNoder::node(input); + ensure_equals("count preserved", actual.size(), std::size_t(2)); + ensure("polygon member output is empty", actual[0]->isEmpty()); +} + +// Nested GEOMETRYCOLLECTION member: all linear components extracted +template<> template<> void object::test<11>() +{ + set_test_name("nested GEOMETRYCOLLECTION member"); + checkResult( + readArray({ + "GEOMETRYCOLLECTION (LINESTRING (0 0, 10 0), LINESTRING (0 5, 10 5))", + "LINESTRING (5 -1, 5 10)"}), + {"MULTILINESTRING ((0 0, 5 0), (5 0, 10 0), (0 5, 5 5), (5 5, 10 5))", + "MULTILINESTRING ((5 -1, 5 0), (5 0, 5 5), (5 5, 5 10))"}); +} + +// gridSize < 0: treated as 0 (uses IteratedNoder), does not crash +template<> template<> void object::test<12>() +{ + set_test_name("gridSize < 0 falls back to standard noding"); + auto input = readArray({"LINESTRING (0 0, 10 10)", "LINESTRING (0 10, 10 0)"}); + auto actual = LineCollectionNoder::node(input, -1.0); + ensure_equals("count preserved", actual.size(), std::size_t(2)); + ensure_equals("member 0 split at crossing", (int)actual[0]->getNumGeometries(), 2); +} + +// Z propagation: new node point gets Z averaged from both lines' interpolated values +template<> template<> void object::test<13>() +{ + set_test_name("Z values averaged at new node points from both intersecting lines"); + // Line 0: Z 0->1 at t=0.5 -> zp=0.5; Line 1: Z 5->10 at t=0.5 -> zq=7.5; avg=4.0 + checkResult( + readArray({ + "LINESTRING Z (0 0 0, 1 1 1)", + "LINESTRING Z (0 1 5, 1 0 10)"}), + {"MULTILINESTRING Z ((0 0 0, 0.5 0.5 4), (0.5 0.5 4, 1 1 1))", + "MULTILINESTRING Z ((0 1 5, 0.5 0.5 4), (0.5 0.5 4, 1 0 10))"}); +} + +// M propagation: new node point gets M averaged from both lines' interpolated values +template<> template<> void object::test<14>() +{ + set_test_name("M values averaged at new node points from both intersecting lines"); + // Line 0: M 0->1 at t=0.5 -> mp=0.5; Line 1: M 5->10 at t=0.5 -> mq=7.5; avg=4.0 + checkResult( + readArray({ + "LINESTRING M (0 0 0, 1 1 1)", + "LINESTRING M (0 1 5, 1 0 10)"}), + {"MULTILINESTRING M ((0 0 0, 0.5 0.5 4), (0.5 0.5 4, 1 1 1))", + "MULTILINESTRING M ((0 1 5, 0.5 0.5 4), (0.5 0.5 4, 1 0 10))"}); +} + +// ZM propagation: both Z and M averaged at new node points +template<> template<> void object::test<15>() +{ + set_test_name("ZM values averaged at new node points from both intersecting lines"); + // Line 0 at t=0.5: Z=0.5 M=150; Line 1 at t=0.5: Z=75 M=7.5; avg Z=37.75 M=78.75 + checkResult( + readArray({ + "LINESTRING ZM (0 0 0 100, 1 1 1 200)", + "LINESTRING ZM (0 1 50 5, 1 0 100 10)"}), + {"MULTILINESTRING ZM ((0 0 0 100, 0.5 0.5 37.75 78.75), (0.5 0.5 37.75 78.75, 1 1 1 200))", + "MULTILINESTRING ZM ((0 1 50 5, 0.5 0.5 37.75 78.75), (0.5 0.5 37.75 78.75, 1 0 100 10))"}); +} + +} // namespace tut From 29ffb387ca8ddc721b6a276414abd254eadb395e Mon Sep 17 00:00:00 2001 From: Paul Ramsey Date: Thu, 11 Jun 2026 21:16:16 +0000 Subject: [PATCH 2/3] Fix unit test name to match convention --- tests/unit/operation/linenode/LineCollectionNoderTest.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/operation/linenode/LineCollectionNoderTest.cpp b/tests/unit/operation/linenode/LineCollectionNoderTest.cpp index d9e0af152b..dbb3aeaad0 100644 --- a/tests/unit/operation/linenode/LineCollectionNoderTest.cpp +++ b/tests/unit/operation/linenode/LineCollectionNoderTest.cpp @@ -42,7 +42,7 @@ struct test_linecollectionnoder_data { typedef test_group group; typedef group::object object; -group test_linecollectionnoder_group("operation::linenode::LineCollectionNoder"); +group test_linecollectionnoder_group("geos::operation::linenode::LineCollectionNoder"); // Two crossing linestrings: each split at the crossing point template<> template<> void object::test<1>() From 5d31af1c78a8ef290266858ce14de90930d79f0c Mon Sep 17 00:00:00 2001 From: Paul Ramsey Date: Fri, 26 Jun 2026 18:49:48 +0000 Subject: [PATCH 3/3] Factor out duplicate code from LineCollectionNoder and GeometryNoder --- include/geos/noding/OrientedCoordinateArray.h | 31 +++++++++++++++++++ src/noding/GeometryNoder.cpp | 7 ++--- .../linenode/LineCollectionNoder.cpp | 7 ++--- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/include/geos/noding/OrientedCoordinateArray.h b/include/geos/noding/OrientedCoordinateArray.h index eae9ec64e2..9272d0c6cd 100644 --- a/include/geos/noding/OrientedCoordinateArray.h +++ b/include/geos/noding/OrientedCoordinateArray.h @@ -21,6 +21,7 @@ #include #include +#include // Forward declarations namespace geos { @@ -108,6 +109,36 @@ operator< (const OrientedCoordinateArray& oca1, return oca1.compareTo(oca2) < 0; } +/** \brief + * Deduplicates coordinate sequences in an orientation-independent way. + * + * Tracks the set of sequences seen so far (as OrientedCoordinateArrays) + * and reports whether each newly offered sequence is novel. Used by + * noders to drop edges that are geometrically identical regardless of + * direction. + * + * NOTE: like OrientedCoordinateArray, this stores only pointers to the + * sequences offered to add(). Every sequence passed in must outlive the + * EdgeDeduplicator. + */ +class GEOS_DLL EdgeDeduplicator { +public: + + /** + * @param seq a coordinate sequence; must outlive this object + * @return true if seq was not equivalent to any previously added + * sequence (and is now recorded), false if it is a duplicate + */ + bool add(const geom::CoordinateSequence& seq) { + return m_seen.insert(OrientedCoordinateArray(seq)).second; + } + +private: + + std::set m_seen; + +}; + } // namespace geos.noding } // namespace geos diff --git a/src/noding/GeometryNoder.cpp b/src/noding/GeometryNoder.cpp index 5296e729c5..4577798a52 100644 --- a/src/noding/GeometryNoder.cpp +++ b/src/noding/GeometryNoder.cpp @@ -316,7 +316,7 @@ GeometryNoder::toGeometry(std::vector>& nodedEdges) { const geom::GeometryFactory* geomFact = argGeom1->getFactory(); - std::set< OrientedCoordinateArray > ocas; + EdgeDeduplicator dedup; std::vector pathsToKeep; @@ -327,11 +327,8 @@ GeometryNoder::toGeometry(std::vector>& nodedEdges) continue; } - const auto& coords = path->getCoordinates(); - OrientedCoordinateArray oca1(*coords); - // Check if an equivalent edge is known - if(ocas.insert(oca1).second) { + if(dedup.add(*path->getCoordinates())) { pathsToKeep.push_back(path.get()); } } diff --git a/src/operation/linenode/LineCollectionNoder.cpp b/src/operation/linenode/LineCollectionNoder.cpp index 66dfafc591..f638aa2376 100644 --- a/src/operation/linenode/LineCollectionNoder.cpp +++ b/src/operation/linenode/LineCollectionNoder.cpp @@ -40,7 +40,7 @@ using geos::geom::PrecisionModel; using geos::noding::IteratedNoder; using geos::noding::NodedSegmentString; using geos::noding::Noder; -using geos::noding::OrientedCoordinateArray; +using geos::noding::EdgeDeduplicator; using geos::noding::SegmentString; using geos::noding::snapround::SnapRoundingNoder; @@ -108,14 +108,13 @@ LineCollectionNoder::buildResult( { const void* tag = reinterpret_cast(static_cast(index)); - std::set seen; + EdgeDeduplicator dedup; std::vector> lines; for (const auto& ss : nodedSegs) { if (ss->getData() != tag) continue; const auto& coords = ss->getCoordinates(); - OrientedCoordinateArray oca(*coords); - if (seen.insert(oca).second) { + if (dedup.add(*coords)) { lines.push_back(m_geomFactory->createLineString(coords)); } }