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..9c24946d6a 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, @@ -5563,6 +5569,47 @@ extern GEOSGeometry GEOS_DLL * GEOSVoronoiDiagram( extern GEOSGeometry GEOS_DLL *GEOSNode(const GEOSGeometry* g); +/** +* Nodes a collection of geometries against each other, returning a +* GeometryCollection of the same size in which member i is the noded +* form of input member i. +* +* Unlike GEOSNode(), which collects all edges into a single flattened +* result, this preserves the per-member structure of the input: +* linework shared (or nearly shared) between members is not dissolved, +* and every member that touches a node is split there. +* +* Each output member is a MultiLineString (or a MultiCurve, when the +* corresponding input contributed curved components). Areal members are +* reduced to their boundary linework — a polygon's rings are noded like +* any other lines — so a polygon member yields its noded boundary. Point +* members (and any member with no linear or areal component) yield an +* empty MultiLineString. +* +* A non-collection input (for example a single LineString or Polygon) is +* treated as a one-member collection, producing a one-member result. +* +* When \p gridSize is 0.0 (or any non-positive value), exact noding is +* used. When \p gridSize is greater than 0.0, snap-rounding to that grid +* is used for robust output on inputs with near-coincident coordinates. +* Snap-rounding is linear-only: if the input contains curved components, +* \p gridSize is ignored and exact arc noding is used. +* +* \param input A collection of geometries to node against each other. +* \param gridSize Snap-rounding grid size when greater than 0; any +* non-positive value selects exact noding. +* \return A GeometryCollection of the same size as the input, or NULL on +* exception. Caller is responsible for freeing with +* GEOSGeom_destroy(). +* \see geos::noding::GeometryNoder::nodeCollection +* +* \since 3.15 +*/ +extern GEOSGeometry GEOS_DLL *GEOSNodeCollection( + const GEOSGeometry* input, + double gridSize); + + /** Split a linear or polygonal input * * Linear inputs can be split with points, lines, and/or polygons. diff --git a/capi/geos_ts_c.cpp b/capi/geos_ts_c.cpp index 7b06105cff..e5216210d3 100644 --- a/capi/geos_ts_c.cpp +++ b/capi/geos_ts_c.cpp @@ -2042,6 +2042,26 @@ extern "C" { }); } + Geometry* + GEOSNodeCollection_r(GEOSContextHandle_t extHandle, const Geometry* input, double gridSize) + { + return execute(extHandle, [&]() { + // Each member of the input collection is noded against the + // others; the result is a collection of the same size, member + // i being the noded form of input member i. + std::vector geoms(input->getNumGeometries()); + for (std::size_t i = 0; i < geoms.size(); i++) { + geoms[i] = input->getGeometryN(i); + } + + auto noded = geos::noding::GeometryNoder::nodeCollection(geoms, gridSize); + + auto out = input->getFactory()->createGeometryCollection(std::move(noded)); + out->setSRID(input->getSRID()); + return out.release(); + }); + } + Geometry* GEOSSplit_r(GEOSContextHandle_t extHandle, const Geometry* g, const Geometry* edge) { diff --git a/include/geos/noding/GeometryNoder.h b/include/geos/noding/GeometryNoder.h index 151bbb8ae9..9a55250df9 100644 --- a/include/geos/noding/GeometryNoder.h +++ b/include/geos/noding/GeometryNoder.h @@ -21,7 +21,10 @@ #include #include // for NonConstVect +#include +#include #include // for unique_ptr +#include // Forward declarations namespace geos { @@ -30,6 +33,7 @@ class CircularArcIntersector; } namespace geom { class Geometry; +class PrecisionModel; } namespace noding { class ArcIntersectionAdder; @@ -47,14 +51,58 @@ class GEOS_DLL GeometryNoder { static std::unique_ptr node(const geom::Geometry& geom1, const geom::Geometry& geom2); + /** + * Nodes a collection of linear (or curved) geometries against each + * other, returning a collection of the same size with a 1:1 + * relationship to the input: output element `i` is the noded form + * of input element `i`. + * + * Unlike node(const geom::Geometry&), which collects all input + * edges into a single flattened result, this preserves the identity + * of each input member. Linework that is shared (or nearly shared) + * between members is not dissolved — every member that touches a + * node is split there. + * + * Each output element is a MultiLineString (or a MultiCurve, if the + * corresponding input contributed curved components). Consistent with + * node(const geom::Geometry&), areal members are reduced to their + * boundary linework: a polygon's exterior and interior rings are noded + * like any other lines (and participate in noding the other members), + * so its slot returns the noded boundary as a MultiLineString. Point + * members — and any member with no linear or areal component — add + * nothing to the noding and yield an empty MultiLineString in their slot. + * + * When `gridSize <= 0.0` (the default is 0.0) exact noding is used + * (IteratedNoder for linear input, SimpleNoder with arc support when + * curves are present). When `gridSize > 0.0` a SnapRoundingNoder is + * used for robust output on near-coincident coordinates + * (see https://github.com/libgeos/geos/issues/877). Snap-rounding is + * linear-only: if the input contains curved components, `gridSize` + * is ignored and exact arc noding is used. + * + * @param geoms input members; the caller retains ownership and the + * vector (and the geometries it points at) must outlive the call. + * Members must be distinct geometry objects (no aliasing). + * @param gridSize snap-rounding grid size when > 0; any value <= 0.0 + * selects exact noding + * @return one noded geometry per input member, in input order + */ + static std::vector> nodeCollection( + const std::vector& geoms, + double gridSize = 0.0); + GeometryNoder(const geom::Geometry& g); GeometryNoder(const geom::Geometry& g1, const geom::Geometry& g2); + GeometryNoder(const std::vector& geoms, double gridSize = 0.0); + ~GeometryNoder(); std::unique_ptr getNoded(); + std::vector> getNodedCollection(); + void setOnlyFirstGeomEdges(bool onlyFirstGeomEdges); void setPreserveCompoundCurves(bool preserve); @@ -67,6 +115,8 @@ class GEOS_DLL GeometryNoder { bool isInResult(const PathString& ps) const; + static bool collectionHasCurves(const std::vector& geoms); + const geom::Geometry* argGeom1; const geom::Geometry* argGeom2; const bool argGeomHasCurves; @@ -74,7 +124,13 @@ class GEOS_DLL GeometryNoder { bool onlyFirstGeomEdges; bool preserveCompoundCurves; + // Collection-noding state (null/empty for single/two-geometry modes) + const std::vector* argColl = nullptr; + double m_gridSize = 0.0; + std::map m_contextToMember; + std::unique_ptr noder; + std::unique_ptr m_pm; std::unique_ptr m_cai; std::unique_ptr m_aia; @@ -84,6 +140,22 @@ class GEOS_DLL GeometryNoder { std::unique_ptr toGeometry(std::vector>& noded) const; + std::vector> toGeometryCollection( + std::vector>& noded) const; + + /** + * Builds one output geometry for a single source geometry slot from + * the noded paths selected for it. `selected` are the candidate + * paths (deduplicated here); `blockers` are paths from other slots + * whose endpoints must not be merged across when reconstructing + * compound curves. `srcGeom` is the originating geometry (used for + * its factory and, when preserving compound curves, its structure). + */ + std::unique_ptr buildSlot( + const std::vector& selected, + const std::vector& blockers, + const geom::Geometry& srcGeom) const; + }; } // namespace geos.noding diff --git a/src/noding/GeometryNoder.cpp b/src/noding/GeometryNoder.cpp index 5296e729c5..f0169f1f44 100644 --- a/src/noding/GeometryNoder.cpp +++ b/src/noding/GeometryNoder.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include @@ -281,6 +282,14 @@ GeometryNoder::node(const geom::Geometry& geom1, const geom::Geometry& geom2) return noder.getNoded(); } +/* public static */ +std::vector> +GeometryNoder::nodeCollection(const std::vector& geoms, double gridSize) +{ + GeometryNoder noder(geoms, gridSize); + return noder.getNodedCollection(); +} + /* public */ GeometryNoder::GeometryNoder(const geom::Geometry& g) : @@ -302,6 +311,32 @@ GeometryNoder::GeometryNoder(const geom::Geometry& g1, const geom::Geometry& g2) preserveCompoundCurves(false) {} +/* private static */ +bool +GeometryNoder::collectionHasCurves(const std::vector& geoms) +{ + for (const auto* g : geoms) { + if (g != nullptr && g->hasCurvedComponents()) + return true; + } + return false; +} + +GeometryNoder::GeometryNoder(const std::vector& geoms, double gridSize) + : + // argGeom1 points at the first member purely so factory/precision-model + // lookups have a geometry to reach through; collection mode drives output + // off argColl, not argGeom1. + argGeom1(geoms.empty() ? nullptr : geoms.front()), + argGeom2(nullptr), + argGeomHasCurves(collectionHasCurves(geoms)), + argGeomHasCompoundCurves(false), + onlyFirstGeomEdges(false), + preserveCompoundCurves(false), + argColl(&geoms), + m_gridSize(gridSize) +{} + GeometryNoder::~GeometryNoder() = default; bool @@ -312,45 +347,35 @@ GeometryNoder::isInResult(const PathString& ps) const /* private */ std::unique_ptr -GeometryNoder::toGeometry(std::vector>& nodedEdges) const +GeometryNoder::buildSlot( + const std::vector& selected, + const std::vector& blockers, + const geom::Geometry& srcGeom) const { - const geom::GeometryFactory* geomFact = argGeom1->getFactory(); - - std::set< OrientedCoordinateArray > ocas; + const geom::GeometryFactory* geomFact = srcGeom.getFactory(); + // Deduplicate equivalent edges (orientation-independent). + std::set ocas; std::vector pathsToKeep; - - - - for(auto& path : nodedEdges) { - if (!isInResult(*path)) { - continue; - } - - const auto& coords = path->getCoordinates(); - OrientedCoordinateArray oca1(*coords); - - // Check if an equivalent edge is known - if(ocas.insert(oca1).second) { - pathsToKeep.push_back(path.get()); + pathsToKeep.reserve(selected.size()); + for (auto* path : selected) { + OrientedCoordinateArray oca(*path->getCoordinates()); + if (ocas.insert(oca).second) { + pathsToKeep.push_back(path); } } if (preserveCompoundCurves && argGeomHasCompoundCurves) { - util::Assert::isTrue(onlyFirstGeomEdges); - - CurveRebuilder rebuilder(*argGeom1); + CurveRebuilder rebuilder(srcGeom); - for (auto& path : pathsToKeep) { + for (auto* path : pathsToKeep) { rebuilder.add(path); } - // Any edge that begins at a point where a geomB edge starts/ends is not - // eligible for merging. - for (auto& path : nodedEdges) { - if (!isInResult(*path)) { - rebuilder.disableNodes(path.get()); - } + // Any edge that begins at a point where another slot's edge + // starts/ends is not eligible for merging. + for (auto* path : blockers) { + rebuilder.disableNodes(path); } return rebuilder.getGeometry(); @@ -358,10 +383,10 @@ GeometryNoder::toGeometry(std::vector>& nodedEdges) // Create a geometry out of the noded substrings. std::vector> lines; - lines.reserve(nodedEdges.size()); + lines.reserve(pathsToKeep.size()); bool resultArcs = false; - for (const auto& path : pathsToKeep) { + for (auto* path : pathsToKeep) { const auto& coords = path->getCoordinates(); const bool isLinear = dynamic_cast(path); @@ -381,6 +406,73 @@ GeometryNoder::toGeometry(std::vector>& nodedEdges) } } +/* private */ +std::unique_ptr +GeometryNoder::toGeometry(std::vector>& nodedEdges) const +{ + std::vector selected; + std::vector blockers; + + for (auto& path : nodedEdges) { + if (isInResult(*path)) { + selected.push_back(path.get()); + } else { + blockers.push_back(path.get()); + } + } + + if (preserveCompoundCurves && argGeomHasCompoundCurves) { + util::Assert::isTrue(onlyFirstGeomEdges); + } + + return buildSlot(selected, blockers, *argGeom1); +} + +/* private */ +std::vector> +GeometryNoder::toGeometryCollection(std::vector>& nodedEdges) const +{ + const std::size_t n = argColl->size(); + + // Partition the noded paths by the source member they originated from. + std::vector> byMember(n); + for (auto& path : nodedEdges) { + auto it = m_contextToMember.find(path->getData()); + if (it != m_contextToMember.end()) { + byMember[it->second].push_back(path.get()); + } + } + + std::vector> result; + result.reserve(n); + + for (std::size_t i = 0; i < n; i++) { + const geom::Geometry* member = (*argColl)[i]; + + if (byMember[i].empty()) { + // No linear/curved component: empty result in this slot. + result.push_back(member->getFactory()->createMultiLineString()); + continue; + } + + // Edges from every other member block compound-curve merging + // across foreign nodes (only needed when reconstructing curves). + std::vector blockers; + if (preserveCompoundCurves && argGeomHasCompoundCurves) { + for (auto& path : nodedEdges) { + auto it = m_contextToMember.find(path->getData()); + if (it == m_contextToMember.end() || it->second != i) { + blockers.push_back(path.get()); + } + } + } + + result.push_back(buildSlot(byMember[i], blockers, *member)); + } + + return result; +} + /* public */ std::unique_ptr GeometryNoder::getNoded() @@ -409,6 +501,42 @@ GeometryNoder::getNoded() return noded; } +/* public */ +std::vector> +GeometryNoder::getNodedCollection() +{ + const std::size_t n = argColl->size(); + + std::vector> result; + if (n == 0) + return result; + + // Extract paths from every member into one list, recording which + // member each path (identified by its context pointer) came from. + std::vector> lineList; + for (std::size_t i = 0; i < n; i++) { + std::size_t before = lineList.size(); + extractPathStrings(*(*argColl)[i], lineList); + for (std::size_t j = before; j < lineList.size(); j++) { + m_contextToMember[lineList[j]->getData()] = i; + } + } + + // No linear/curved content anywhere: an empty result for each slot. + if (lineList.empty()) { + result.reserve(n); + for (std::size_t i = 0; i < n; i++) + result.push_back((*argColl)[i]->getFactory()->createMultiLineString()); + return result; + } + + Noder& p_noder = getNoder(); + p_noder.computePathNodes(PathString::toRawPointerVector(lineList)); + auto nodedEdges = p_noder.getNodedPaths(); + + return toGeometryCollection(nodedEdges); +} + /* private static */ void GeometryNoder::extractPathStrings(const geom::Geometry& g, @@ -426,11 +554,16 @@ GeometryNoder::getNoder() if(!noder) { const geom::PrecisionModel* pm = argGeom1->getFactory()->getPrecisionModel(); if (argGeomHasCurves) { + // Snap-rounding cannot node arcs; curved input always uses + // exact arc noding and ignores any requested gridSize. noder = std::make_unique(); m_cai = std::make_unique(argGeom1->getPrecisionModel()); m_aia = std::make_unique(*m_cai); detail::down_cast(noder.get())->setArcIntersector(*m_aia); + } else if (m_gridSize > 0.0) { + m_pm = std::make_unique(1.0 / m_gridSize); + noder = std::make_unique(m_pm.get()); } else { noder = std::make_unique(pm); } diff --git a/tests/unit/capi/GEOSNodeCollectionTest.cpp b/tests/unit/capi/GEOSNodeCollectionTest.cpp new file mode 100644 index 0000000000..b38c9367df --- /dev/null +++ b/tests/unit/capi/GEOSNodeCollectionTest.cpp @@ -0,0 +1,224 @@ +// +// Test Suite for C-API GEOSNodeCollection + +#include +// geos +#include + +#include "capi_test_utils.h" + +#include + +namespace tut { +// +// Test Group +// + +// Common data used in test cases. +struct test_capigeosnodecollection_data : public capitest::utility { + + // Clone member i of coll, normalize it, and compare (exactly) to the + // normalized expected WKT. Verifies both the per-slot content and the + // 1:1 positional mapping to the input. + void check_member(GEOSGeometry* coll, int i, const char* expectedWKT) + { + const GEOSGeometry* m = GEOSGetGeometryN(coll, i); + ensure(std::string("member exists ") + std::to_string(i), m != nullptr); + + GEOSGeometry* got = GEOSGeom_clone(m); + GEOSGeometry* exp = GEOSGeomFromWKT(expectedWKT); + GEOSNormalize(got); + GEOSNormalize(exp); + ensure_equals(std::string("member ") + std::to_string(i), + static_cast(GEOSEqualsExact(got, exp, 0.0)), 1); + GEOSGeom_destroy(got); + GEOSGeom_destroy(exp); + } +}; + +typedef test_group group; +typedef group::object object; + +group test_capigeosnodecollection_group("capi::GEOSNodeCollection"); + +// +// Test Cases +// + +// Crossing lines keep their per-member identity (unlike GEOSNode, which +// would flatten both into one MultiLineString). +template<> +template<> +void object::test<1> +() +{ + input_ = GEOSGeomFromWKT( + "GEOMETRYCOLLECTION (" + "LINESTRING (0 0, 10 10)," + "LINESTRING (0 10, 10 0))"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(nullptr != result_); + + ensure_equals("1:1 mapping", GEOSGetNumGeometries(result_), 2); + check_member(result_, 0, "MULTILINESTRING ((0 0, 5 5), (5 5, 10 10))"); + check_member(result_, 1, "MULTILINESTRING ((0 10, 5 5), (5 5, 10 0))"); + + // Result container is a GeometryCollection of MultiLineStrings. + ensure_equals("top-level type", GEOSGeomTypeId(result_), GEOS_GEOMETRYCOLLECTION); + ensure_equals("slot 0 type", GEOSGeomTypeId(GEOSGetGeometryN(result_, 0)), GEOS_MULTILINESTRING); + ensure_equals("slot 1 type", GEOSGeomTypeId(GEOSGetGeometryN(result_, 1)), GEOS_MULTILINESTRING); +} + +// Mixed input types: polygon boundary is noded as linework, point yields +// an empty slot. +template<> +template<> +void object::test<2> +() +{ + input_ = GEOSGeomFromWKT( + "GEOMETRYCOLLECTION (" + "LINESTRING (-5 5, 15 5)," + "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))," + "POINT (3 3))"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(nullptr != result_); + + ensure_equals("1:1 mapping", GEOSGetNumGeometries(result_), 3); + check_member(result_, 0, "MULTILINESTRING ((-5 5, 0 5), (0 5, 10 5), (10 5, 15 5))"); + check_member(result_, 1, + "MULTILINESTRING ((0 0, 10 0, 10 5), (10 5, 10 10, 0 10, 0 5), (0 5, 0 0))"); + check_member(result_, 2, "MULTILINESTRING EMPTY"); +} + +// gridSize > 0 snaps near-coincident coordinates onto a node. +template<> +template<> +void object::test<3> +() +{ + input_ = GEOSGeomFromWKT( + "GEOMETRYCOLLECTION (" + "LINESTRING (0 0, 10 0)," + "LINESTRING (5 0.4, 5 10))"); + result_ = GEOSNodeCollection(input_, 1.0); + ensure(nullptr != result_); + + ensure_equals("1:1 mapping", GEOSGetNumGeometries(result_), 2); + check_member(result_, 0, "MULTILINESTRING ((0 0, 5 0), (5 0, 10 0))"); + check_member(result_, 1, "MULTILINESTRING ((5 0, 5 10))"); +} + +// A single (non-collection) input is treated as a one-member collection. +template<> +template<> +void object::test<4> +() +{ + input_ = GEOSGeomFromWKT("LINESTRING (0 0, 10 10, 10 0, 0 10)"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(nullptr != result_); + + ensure_equals("single member", GEOSGetNumGeometries(result_), 1); + check_member(result_, 0, + "MULTILINESTRING ((0 0, 5 5), (5 5, 10 10, 10 0, 5 5), (5 5, 0 10))"); +} + +// A non-collection POINT is treated as a one-member collection; its slot +// is an empty MultiLineString. +template<> +template<> +void object::test<5> +() +{ + input_ = GEOSGeomFromWKT("POINT (3 3)"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(nullptr != result_); + + ensure_equals("top-level type", GEOSGeomTypeId(result_), GEOS_GEOMETRYCOLLECTION); + ensure_equals("single member", GEOSGetNumGeometries(result_), 1); + check_member(result_, 0, "MULTILINESTRING EMPTY"); +} + +// A non-collection POLYGON is treated as a one-member collection; its +// boundary is returned as noded linework. +template<> +template<> +void object::test<6> +() +{ + input_ = GEOSGeomFromWKT("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(nullptr != result_); + + ensure_equals("top-level type", GEOSGeomTypeId(result_), GEOS_GEOMETRYCOLLECTION); + ensure_equals("single member", GEOSGetNumGeometries(result_), 1); + ensure_equals("slot type", GEOSGeomTypeId(GEOSGetGeometryN(result_, 0)), GEOS_MULTILINESTRING); + // An uncrossed ring comes back as its single closed boundary line. + check_member(result_, 0, "MULTILINESTRING ((0 0, 10 0, 10 10, 0 10, 0 0))"); +} + +// An empty atomic input still yields a one-member collection. +template<> +template<> +void object::test<7> +() +{ + input_ = GEOSGeomFromWKT("POINT EMPTY"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(nullptr != result_); + + ensure_equals("top-level type", GEOSGeomTypeId(result_), GEOS_GEOMETRYCOLLECTION); + ensure_equals("single member", GEOSGetNumGeometries(result_), 1); + check_member(result_, 0, "MULTILINESTRING EMPTY"); +} + +// Empty members mixed with non-empty members keep their slots. +template<> +template<> +void object::test<8> +() +{ + input_ = GEOSGeomFromWKT( + "GEOMETRYCOLLECTION (" + "LINESTRING (0 0, 10 0)," + "LINESTRING EMPTY)"); + result_ = GEOSNodeCollection(input_, 0.0); + ensure(nullptr != result_); + + ensure_equals("1:1 mapping", GEOSGetNumGeometries(result_), 2); + check_member(result_, 0, "MULTILINESTRING ((0 0, 10 0))"); + check_member(result_, 1, "MULTILINESTRING EMPTY"); +} + +// Curved + linear with gridSize > 0: gridSize is ignored for curved input, +// and the curved member's slot is returned as a MultiCurve. +template<> +template<> +void object::test<9> +() +{ + input_ = GEOSGeomFromWKT( + "GEOMETRYCOLLECTION (" + "LINESTRING (0 -1, 0 2)," + "CIRCULARSTRING (-1 0, 0 1, 1 0))"); + result_ = GEOSNodeCollection(input_, 1.0); // gridSize > 0 + ensure(nullptr != result_); + + ensure_equals("top-level type", GEOSGeomTypeId(result_), GEOS_GEOMETRYCOLLECTION); + ensure_equals("1:1 mapping", GEOSGetNumGeometries(result_), 2); + + const GEOSGeometry* lineSlot = GEOSGetGeometryN(result_, 0); + ensure_equals("line slot type", GEOSGeomTypeId(lineSlot), GEOS_MULTILINESTRING); + ensure_equals("line slot parts", GEOSGetNumGeometries(lineSlot), 2); + + const GEOSGeometry* arcSlot = GEOSGetGeometryN(result_, 1); + ensure_equals("arc slot type", GEOSGeomTypeId(arcSlot), GEOS_MULTICURVE); + ensure_equals("arc slot parts", GEOSGetNumGeometries(arcSlot), 2); + for (int i = 0; i < GEOSGetNumGeometries(arcSlot); i++) { + ensure_equals("arc piece type", + GEOSGeomTypeId(GEOSGetGeometryN(arcSlot, i)), GEOS_CIRCULARSTRING); + } +} + +} // namespace tut diff --git a/tests/unit/noding/GeometryNoderTest.cpp b/tests/unit/noding/GeometryNoderTest.cpp index bf694b56f0..3dc2f34669 100644 --- a/tests/unit/noding/GeometryNoderTest.cpp +++ b/tests/unit/noding/GeometryNoderTest.cpp @@ -5,6 +5,8 @@ #include "utility.h" +#include + using geos::geom::Geometry; using geos::noding::GeometryNoder; @@ -72,4 +74,366 @@ void object::test<3>() ensure_equals_exact_geometry_xyzm(result.get(), expected.get(), 0); } -} // namespace tut +// Compare a normalized result slot against expected WKT. +namespace { +void check_slot(geos::io::WKTReader& reader, const Geometry* got, const std::string& wkt) +{ + auto expected = reader.read(wkt); + auto g = got->clone(); + g->normalize(); + expected->normalize(); + ensure_equals_exact_geometry_xyzm(g.get(), expected.get(), 0); +} +} + +template<> +template<> +void object::test<4>() +{ + set_test_name("collection: crossing lines keep per-member identity"); + + auto a = reader_.read("LINESTRING (0 0, 10 10)"); + auto b = reader_.read("LINESTRING (0 10, 10 0)"); + + std::vector input{a.get(), b.get()}; + auto result = GeometryNoder::nodeCollection(input); + + ensure_equals("1:1 mapping", result.size(), 2u); + check_slot(reader_, result[0].get(), "MULTILINESTRING ((0 0, 5 5), (5 5, 10 10))"); + check_slot(reader_, result[1].get(), "MULTILINESTRING ((0 10, 5 5), (5 5, 10 0))"); +} + +template<> +template<> +void object::test<5>() +{ + set_test_name("collection: shared linework retained in both members"); + + // The two members overlap along (5 0)-(10 0); that shared edge must + // appear in BOTH outputs (not dissolved as GEOSNode would). + auto a = reader_.read("LINESTRING (0 0, 10 0)"); + auto b = reader_.read("LINESTRING (5 0, 10 0, 10 10)"); + + std::vector input{a.get(), b.get()}; + auto result = GeometryNoder::nodeCollection(input); + + ensure_equals("1:1 mapping", result.size(), 2u); + check_slot(reader_, result[0].get(), "MULTILINESTRING ((0 0, 5 0), (5 0, 10 0))"); + check_slot(reader_, result[1].get(), "MULTILINESTRING ((5 0, 10 0), (10 0, 10 10))"); +} + +template<> +template<> +void object::test<6>() +{ + set_test_name("collection: non-linear member yields empty slot"); + + auto a = reader_.read("LINESTRING (0 0, 10 10)"); + auto pt = reader_.read("POINT (5 5)"); + auto b = reader_.read("LINESTRING (0 10, 10 0)"); + + std::vector input{a.get(), pt.get(), b.get()}; + auto result = GeometryNoder::nodeCollection(input); + + ensure_equals("1:1 mapping", result.size(), 3u); + check_slot(reader_, result[0].get(), "MULTILINESTRING ((0 0, 5 5), (5 5, 10 10))"); + ensure("point slot empty", result[1]->isEmpty()); + ensure_equals("point slot type", result[1]->getGeometryTypeId(), geos::geom::GEOS_MULTILINESTRING); + check_slot(reader_, result[2].get(), "MULTILINESTRING ((0 10, 5 5), (5 5, 10 0))"); +} + +template<> +template<> +void object::test<7>() +{ + set_test_name("collection: gridSize snap-rounding nodes near-coincident input"); + + // b's endpoint (5 0.4) lies just off a; snap-rounding to a grid of 1.0 + // pulls it onto (5 0), creating a node that splits a there. + auto a = reader_.read("LINESTRING (0 0, 10 0)"); + auto b = reader_.read("LINESTRING (5 0.4, 5 10)"); + + std::vector input{a.get(), b.get()}; + auto result = GeometryNoder::nodeCollection(input, 1.0); + + ensure_equals("1:1 mapping", result.size(), 2u); + check_slot(reader_, result[0].get(), "MULTILINESTRING ((0 0, 5 0), (5 0, 10 0))"); + check_slot(reader_, result[1].get(), "MULTILINESTRING ((5 0, 5 10))"); +} + +template<> +template<> +void object::test<8>() +{ + set_test_name("collection: curved member noded and returned as arcs"); + + // Vertical line crosses the top of a unit semicircle arc at (0 1). + auto line = reader_.read("LINESTRING (0 -1, 0 2)"); + auto arc = reader_.read("CIRCULARSTRING (-1 0, 0 1, 1 0)"); + + std::vector input{line.get(), arc.get()}; + auto result = GeometryNoder::nodeCollection(input); + + ensure_equals("1:1 mapping", result.size(), 2u); + + // The line slot splits at (0 1). + ensure_equals("line slot type", result[0]->getGeometryTypeId(), geos::geom::GEOS_MULTILINESTRING); + ensure_equals("line split count", result[0]->getNumGeometries(), 2u); + + // The arc slot stays curved: a MultiCurve of two CircularStrings. + ensure_equals("arc slot type", result[1]->getGeometryTypeId(), geos::geom::GEOS_MULTICURVE); + ensure_equals("arc split count", result[1]->getNumGeometries(), 2u); + for (std::size_t i = 0; i < result[1]->getNumGeometries(); i++) { + ensure_equals("arc piece is a CircularString", + result[1]->getGeometryN(i)->getGeometryTypeId(), geos::geom::GEOS_CIRCULARSTRING); + } +} + +template<> +template<> +void object::test<9>() +{ + set_test_name("collection: mixed types — polygon boundary noded, point slot empty"); + + // A line crosses a polygon; a point contributes nothing. Consistent + // with node(): areal members are reduced to noded boundary linework, + // points yield an empty slot. + auto line = reader_.read("LINESTRING (-5 5, 15 5)"); + auto poly = reader_.read("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))"); + auto pt = reader_.read("POINT (3 3)"); + + std::vector input{line.get(), poly.get(), pt.get()}; + auto result = GeometryNoder::nodeCollection(input); + + ensure_equals("1:1 mapping", result.size(), 3u); + + // Line slot: split where it crosses the polygon edges. + check_slot(reader_, result[0].get(), + "MULTILINESTRING ((-5 5, 0 5), (0 5, 10 5), (10 5, 15 5))"); + + // Polygon slot: boundary returned as noded linework, split at the + // two points where the line crosses it. + check_slot(reader_, result[1].get(), + "MULTILINESTRING ((0 0, 10 0, 10 5), (10 5, 10 10, 0 10, 0 5), (0 5, 0 0))"); + + // Point slot: empty MultiLineString. + ensure("point slot empty", result[2]->isEmpty()); + ensure_equals("point slot type", result[2]->getGeometryTypeId(), geos::geom::GEOS_MULTILINESTRING); +} + +template<> +template<> +void object::test<10>() +{ + set_test_name("single input: mixed-type GeometryCollection flattens to noded linework"); + + // The existing node() path: points are dropped, polygon boundaries are + // noded as lines along with the linear members, and everything is + // dissolved into one MultiLineString. + auto input = reader_.read( + "GEOMETRYCOLLECTION (" + "LINESTRING (-5 5, 15 5)," + "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))," + "POINT (3 3))"); + + auto result = GeometryNoder::node(*input); + ensure(result != nullptr); + + check_slot(reader_, result.get(), + "MULTILINESTRING (" + "(-5 5, 0 5), (0 5, 10 5), (10 5, 15 5)," + "(0 0, 10 0, 10 5), (10 5, 10 10, 0 10, 0 5), (0 5, 0 0))"); +} + +template<> +template<> +void object::test<11>() +{ + set_test_name("collection: gridSize is ignored (not applied) when input is curved"); + + // Snap-rounding cannot node arcs — if the curve detection failed and a + // positive gridSize selected SnapRoundingNoder, the arc paths would + // throw UnsupportedOperationException. A positive gridSize here must be + // silently ignored in favour of exact arc noding, giving the same + // curve-preserving result as the gridSize == 0 case (test<8>). + auto line = reader_.read("LINESTRING (0 -1, 0 2)"); + auto arc = reader_.read("CIRCULARSTRING (-1 0, 0 1, 1 0)"); + + std::vector input{line.get(), arc.get()}; + auto result = GeometryNoder::nodeCollection(input, 1.0); // gridSize > 0 + + ensure_equals("1:1 mapping", result.size(), 2u); + + // Line slot still splits at (0 1). + ensure_equals("line slot type", result[0]->getGeometryTypeId(), geos::geom::GEOS_MULTILINESTRING); + ensure_equals("line split count", result[0]->getNumGeometries(), 2u); + + // Arc slot stays curved: exact arc noding ran, gridSize was not applied. + ensure_equals("arc slot type", result[1]->getGeometryTypeId(), geos::geom::GEOS_MULTICURVE); + ensure_equals("arc split count", result[1]->getNumGeometries(), 2u); + for (std::size_t i = 0; i < result[1]->getNumGeometries(); i++) { + ensure_equals("arc piece is a CircularString", + result[1]->getGeometryN(i)->getGeometryTypeId(), geos::geom::GEOS_CIRCULARSTRING); + } +} + +template<> +template<> +void object::test<12>() +{ + set_test_name("collection: multi-part member stays in one slot (MultiLineString)"); + + // A MultiLineString member's parts must all land in its single slot, + // proving slot identity is per top-level member, not per component. + auto a = reader_.read("MULTILINESTRING ((0 0, 10 0), (0 5, 10 5))"); + auto b = reader_.read("LINESTRING (5 -1, 5 6)"); + + std::vector input{a.get(), b.get()}; + auto result = GeometryNoder::nodeCollection(input); + + ensure_equals("1:1 mapping", result.size(), 2u); + + // Both parts of member 0 split where member 1 crosses — all four pieces + // in the single slot 0. + check_slot(reader_, result[0].get(), + "MULTILINESTRING ((0 0, 5 0), (5 0, 10 0), (0 5, 5 5), (5 5, 10 5))"); + check_slot(reader_, result[1].get(), + "MULTILINESTRING ((5 -1, 5 0), (5 0, 5 5), (5 5, 5 6))"); +} + +template<> +template<> +void object::test<13>() +{ + set_test_name("collection: multi-part member stays in one slot (MultiPolygon)"); + + // Both polygons' boundaries are noded into the single MultiPolygon slot. + auto a = reader_.read( + "MULTIPOLYGON (" + "((0 0, 4 0, 4 4, 0 4, 0 0))," + "((6 0, 10 0, 10 4, 6 4, 6 0)))"); + auto b = reader_.read("LINESTRING (-1 2, 11 2)"); + + std::vector input{a.get(), b.get()}; + auto result = GeometryNoder::nodeCollection(input); + + ensure_equals("1:1 mapping", result.size(), 2u); + + // Slot 0: each square's closed ring is broken at its two crossing + // points and at its own start/end vertex -> 3 pieces each, 6 total, + // all in one slot. + ensure_equals("polygon slot type", result[0]->getGeometryTypeId(), geos::geom::GEOS_MULTILINESTRING); + ensure_equals("polygon slot parts", result[0]->getNumGeometries(), 6u); + + // Slot 1: the line crosses four boundary points -> 5 segments. + ensure_equals("line slot parts", result[1]->getNumGeometries(), 5u); +} + +template<> +template<> +void object::test<14>() +{ + set_test_name("collection: identical members are preserved independently per slot"); + + // Two identical lines plus a crosser. In-slot dedup must not dissolve + // the duplicate across slots: each identical member yields its own + // full (split) output. + auto a = reader_.read("LINESTRING (0 0, 10 0)"); + auto b = reader_.read("LINESTRING (0 0, 10 0)"); + auto c = reader_.read("LINESTRING (5 -5, 5 5)"); + + std::vector input{a.get(), b.get(), c.get()}; + auto result = GeometryNoder::nodeCollection(input); + + ensure_equals("1:1 mapping", result.size(), 3u); + check_slot(reader_, result[0].get(), "MULTILINESTRING ((0 0, 5 0), (5 0, 10 0))"); + check_slot(reader_, result[1].get(), "MULTILINESTRING ((0 0, 5 0), (5 0, 10 0))"); + check_slot(reader_, result[2].get(), "MULTILINESTRING ((5 -5, 5 0), (5 0, 5 5))"); +} + +template<> +template<> +void object::test<15>() +{ + set_test_name("collection: polygon with hole — exterior and interior rings both noded"); + + auto a = reader_.read( + "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (3 3, 7 3, 7 7, 3 7, 3 3))"); + auto b = reader_.read("LINESTRING (-1 5, 11 5)"); + + std::vector input{a.get(), b.get()}; + auto result = GeometryNoder::nodeCollection(input); + + ensure_equals("1:1 mapping", result.size(), 2u); + + // Exterior ring (3 pieces: 2 crossings + start vertex) + hole ring + // (3 pieces likewise) = 6, all in the single polygon slot. + ensure_equals("polygon slot type", result[0]->getGeometryTypeId(), geos::geom::GEOS_MULTILINESTRING); + ensure_equals("polygon slot parts", result[0]->getNumGeometries(), 6u); + + // Line crosses exterior (0, 10) and hole (3, 7) at y=5 -> 5 segments. + ensure_equals("line slot parts", result[1]->getNumGeometries(), 5u); +} + +template<> +template<> +void object::test<16>() +{ + set_test_name("collection: Z and M preserved and interpolated at new nodes"); + + auto a = reader_.read("LINESTRING ZM (0 0 0 0, 10 10 10 100)"); + auto b = reader_.read("LINESTRING ZM (0 10 50 0, 10 0 50 0)"); + + std::vector input{a.get(), b.get()}; + auto result = GeometryNoder::nodeCollection(input); + + ensure_equals("1:1 mapping", result.size(), 2u); + + // Original vertices keep their Z/M exactly; the shared constructed node + // at (5 5) carries the interpolated value (GEOS averages the two + // crossing segments at the node: Z=(5+50)/2=27.5, M=(50+0)/2=25). + check_slot(reader_, result[0].get(), + "MULTILINESTRING ZM ((0 0 0 0, 5 5 27.5 25), (5 5 27.5 25, 10 10 10 100))"); + check_slot(reader_, result[1].get(), + "MULTILINESTRING ZM ((0 10 50 0, 5 5 27.5 25), (5 5 27.5 25, 10 0 50 0))"); +} + +template<> +template<> +void object::test<17>() +{ + set_test_name("collection: Z carried through curved-member noding"); + + auto line = reader_.read("LINESTRING Z (0 -1 5, 0 2 5)"); + auto arc = reader_.read("CIRCULARSTRING Z (-1 0 0, 0 1 10, 1 0 20)"); + + std::vector input{line.get(), arc.get()}; + auto result = GeometryNoder::nodeCollection(input); + + ensure_equals("1:1 mapping", result.size(), 2u); + ensure("line slot has Z", result[0]->hasZ()); + ensure_equals("arc slot type", result[1]->getGeometryTypeId(), geos::geom::GEOS_MULTICURVE); + ensure("arc slot has Z", result[1]->hasZ()); +} + +template<> +template<> +void object::test<18>() +{ + set_test_name("collection: negative gridSize behaves as exact (no snapping)"); + + // b's endpoint (5 0.4) is off a. With snap-rounding it would pull onto + // (5 0) and split a (see test<7>); with exact noding (gridSize <= 0) + // there is no intersection, so nothing is split. + auto a = reader_.read("LINESTRING (0 0, 10 0)"); + auto b = reader_.read("LINESTRING (5 0.4, 5 10)"); + + std::vector input{a.get(), b.get()}; + auto result = GeometryNoder::nodeCollection(input, -1.0); + + ensure_equals("1:1 mapping", result.size(), 2u); + check_slot(reader_, result[0].get(), "MULTILINESTRING ((0 0, 10 0))"); + check_slot(reader_, result[1].get(), "MULTILINESTRING ((5 0.4, 5 10))"); +} + +} // namespace tut \ No newline at end of file