diff --git a/libs/http-datasource/src/http-server.cpp b/libs/http-datasource/src/http-server.cpp index 422ab1b6..e39cb847 100644 --- a/libs/http-datasource/src/http-server.cpp +++ b/libs/http-datasource/src/http-server.cpp @@ -153,6 +153,12 @@ void HttpServer::go(std::string const& interfaceAddr, uint16_t port, uint32_t wa // Allow derived class to set up the server. setup(app); + // Raise Drogon's default WebSocket client message size limit + // (128 KB) to 10 MB. Large tile requests with many tile IDs + // can exceed the default and trigger connection shutdown, + // cascading into a crash (std::terminate). + app.setClientMaxWebSocketMessageSize(10 * 1024 * 1024); + app.addListener(interfaceAddr, port); app.registerBeginningAdvice([this]() { diff --git a/libs/http-service/src/tiles-ws-controller.cpp b/libs/http-service/src/tiles-ws-controller.cpp index f9b05c77..672a0edb 100644 --- a/libs/http-service/src/tiles-ws-controller.cpp +++ b/libs/http-service/src/tiles-ws-controller.cpp @@ -1095,61 +1095,68 @@ class TilesWsSession : public std::enable_shared_from_this return; if (!layer) return; - std::optional> stringPoolCommit; - std::vector pullDispatches; - { - std::lock_guard lock(mutex_); - if (cancelled_) - return; - auto requestedTileKey = matchDesiredTileKeyLocked( - layer->id(), - layer->layerInfo() ? std::max(1U, layer->layerInfo()->stages_) : 1U); - // Late-arriving tile for an outdated request: drop before serialization work. - if (!requestedTileKey.has_value()) { - return; - } + try { + std::optional> stringPoolCommit; + std::vector pullDispatches; - if (currentWriteBatch_.has_value()) { - raise("TilesWsSession writer callback re-entered"); - } - currentWriteBatch_.emplace(); - writer_->write(layer); - auto batch = std::move(*currentWriteBatch_); - currentWriteBatch_.reset(); - - // If a StringPool message was generated, the writer updates writerOffsets_ - // to the new highest string ID for this node after emitting it. - const auto nodeId = layer->nodeId(); - const auto it = writerOffsets_.find(nodeId); - if (it != writerOffsets_.end()) { - const auto newOffset = it->second; - for (auto const& m : batch) { - if (m.type == TileLayerStream::MessageType::StringPool) { - stringPoolCommit = std::make_pair(nodeId, newOffset); - break; - } + { + std::lock_guard lock(mutex_); + if (cancelled_) + return; + auto requestedTileKey = matchDesiredTileKeyLocked( + layer->id(), + layer->layerInfo() ? std::max(1U, layer->layerInfo()->stages_) : 1U); + // Late-arriving tile for an outdated request: drop before serialization work. + if (!requestedTileKey.has_value()) { + return; } - } - for (auto& m : batch) { - OutgoingFrame frame; - frame.bytes = std::move(m.bytes); - frame.type = m.type; - if (m.type == TileLayerStream::MessageType::StringPool) { - frame.stringPoolCommit = stringPoolCommit; - frame.requestedTileKey = *requestedTileKey; + if (currentWriteBatch_.has_value()) { + raise("TilesWsSession writer callback re-entered"); + } + currentWriteBatch_.emplace(); + writer_->write(layer); + auto batch = std::move(*currentWriteBatch_); + currentWriteBatch_.reset(); + + // If a StringPool message was generated, the writer updates writerOffsets_ + // to the new highest string ID for this node after emitting it. + const auto nodeId = layer->nodeId(); + const auto it = writerOffsets_.find(nodeId); + if (it != writerOffsets_.end()) { + const auto newOffset = it->second; + for (auto const& m : batch) { + if (m.type == TileLayerStream::MessageType::StringPool) { + stringPoolCommit = std::make_pair(nodeId, newOffset); + break; + } + } } - if (m.type == TileLayerStream::MessageType::TileFeatureLayer - || m.type == TileLayerStream::MessageType::TileSourceDataLayer) { - frame.requestedTileKey = *requestedTileKey; + + for (auto& m : batch) { + OutgoingFrame frame; + frame.bytes = std::move(m.bytes); + frame.type = m.type; + if (m.type == TileLayerStream::MessageType::StringPool) { + frame.stringPoolCommit = stringPoolCommit; + frame.requestedTileKey = *requestedTileKey; + } + if (m.type == TileLayerStream::MessageType::TileFeatureLayer + || m.type == TileLayerStream::MessageType::TileSourceDataLayer) { + frame.requestedTileKey = *requestedTileKey; + } + enqueueOutgoingLocked(std::move(frame)); } - enqueueOutgoingLocked(std::move(frame)); + // Newly queued frames can immediately satisfy blocked pull waiters. + drainReadyPullWaitersLocked(pullDispatches); } - // Newly queued frames can immediately satisfy blocked pull waiters. - drainReadyPullWaitersLocked(pullDispatches); + dispatchPullResults(std::move(pullDispatches)); + } + catch (const std::exception& e) { + log().error("Failed to stream tile layer: {}", e.what()); + cancelNoStatus(); } - dispatchPullResults(std::move(pullDispatches)); } /// Update per-request completion state and emit status when it changes. @@ -1197,7 +1204,15 @@ class TilesWsSession : public std::enable_shared_from_this cancelNoStatus(); return; } - conn->send(encodeStreamMessage(type, payload), drogon::WebSocketMessageType::Binary); + try { + conn->send( + encodeStreamMessage(type, payload), + drogon::WebSocketMessageType::Binary); + } + catch (const std::exception& e) { + log().warn("WebSocket send failed: {}", e.what()); + cancelNoStatus(); + } } /// Send a status frame describing the current request statuses. @@ -1339,66 +1354,73 @@ class TilesWebSocketController final : public drogon::WebSocketControllergetContext(); - if (!session) { - // This is a defensive fallback for unexpected context loss. - session = std::make_shared(service_, conn, AuthHeaders{}); - session->registerForMetrics(); - { - std::lock_guard lock(gSessionRegistryMutex); - gSessionRegistry[session->clientId()] = session; + try { + auto session = conn->getContext(); + if (!session) { + // This is a defensive fallback for unexpected context loss. + session = std::make_shared(service_, conn, AuthHeaders{}); + session->registerForMetrics(); + { + std::lock_guard lock(gSessionRegistryMutex); + gSessionRegistry[session->clientId()] = session; + } + conn->setContext(session); } - conn->setContext(session); - } - - if (type != drogon::WebSocketMessageType::Text) { - const auto payload = nlohmann::json::object({ - {"type", "mapget.tiles.status"}, - {"allDone", true}, - {"requests", nlohmann::json::array()}, - {"message", "Expected a text message containing JSON."}, - }).dump(); - conn->send(encodeStreamMessage(TileLayerStream::MessageType::Status, payload), drogon::WebSocketMessageType::Binary); - return; - } - nlohmann::json j; - try { - j = nlohmann::json::parse(message); - } - catch (const std::exception& e) { - const auto payload = nlohmann::json::object({ - {"type", "mapget.tiles.status"}, - {"allDone", true}, - {"requests", nlohmann::json::array()}, - {"message", fmt::format("Invalid JSON: {}", e.what())}, - }).dump(); - conn->send(encodeStreamMessage(TileLayerStream::MessageType::Status, payload), drogon::WebSocketMessageType::Binary); - return; - } + if (type != drogon::WebSocketMessageType::Text) { + const auto payload = nlohmann::json::object({ + {"type", "mapget.tiles.status"}, + {"allDone", true}, + {"requests", nlohmann::json::array()}, + {"message", "Expected a text message containing JSON."}, + }).dump(); + conn->send(encodeStreamMessage(TileLayerStream::MessageType::Status, payload), drogon::WebSocketMessageType::Binary); + return; + } - // Patch per-connection string pool offsets if supplied. - if (j.contains("stringPoolOffsets")) { - std::string errorMessage; - if (!session->applyStringPoolOffsetsPatch(j["stringPoolOffsets"], errorMessage)) { + nlohmann::json j; + try { + j = nlohmann::json::parse(message); + } + catch (const std::exception& e) { const auto payload = nlohmann::json::object({ {"type", "mapget.tiles.status"}, {"allDone", true}, {"requests", nlohmann::json::array()}, - {"message", std::move(errorMessage)}, + {"message", fmt::format("Invalid JSON: {}", e.what())}, }).dump(); conn->send(encodeStreamMessage(TileLayerStream::MessageType::Status, payload), drogon::WebSocketMessageType::Binary); return; } - } - const auto requestId = session->allocateRequestId(j); - session->updateFromClientRequest(j, requestId); + // Patch per-connection string pool offsets if supplied. + if (j.contains("stringPoolOffsets")) { + std::string errorMessage; + if (!session->applyStringPoolOffsetsPatch(j["stringPoolOffsets"], errorMessage)) { + const auto payload = nlohmann::json::object({ + {"type", "mapget.tiles.status"}, + {"allDone", true}, + {"requests", nlohmann::json::array()}, + {"message", std::move(errorMessage)}, + }).dump(); + conn->send(encodeStreamMessage(TileLayerStream::MessageType::Status, payload), drogon::WebSocketMessageType::Binary); + return; + } + } + + const auto requestId = session->allocateRequestId(j); + session->updateFromClientRequest(j, requestId); + } + catch (const std::exception& e) { + log().error("WebSocket message handler failed: {}", e.what()); + } } /// Abort outstanding backend work once the websocket is closed. diff --git a/libs/model/include/mapget/model/featurelayer.h b/libs/model/include/mapget/model/featurelayer.h index 8f942849..9f7a4e62 100644 --- a/libs/model/include/mapget/model/featurelayer.h +++ b/libs/model/include/mapget/model/featurelayer.h @@ -63,6 +63,27 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool public: // Keep ModelPool::resolve overloads visible alongside the override below. using ModelPool::resolve; + using Ptr = std::shared_ptr; + + struct CloneCacheKey + { + TileFeatureLayer const* model_ = nullptr; + uint32_t address_ = 0; + + [[nodiscard]] bool operator==(CloneCacheKey const& other) const = default; + }; + + struct CloneCacheKeyHash + { + [[nodiscard]] size_t operator()(CloneCacheKey const& key) const noexcept + { + auto const modelHash = std::hash{}(key.model_); + auto const addressHash = std::hash{}(key.address_); + return modelHash ^ (addressHash + 0x9e3779b9U + (modelHash << 6U) + (modelHash >> 2U)); + } + }; + + using CloneCache = std::unordered_map; /** * This constructor initializes a new TileFeatureLayer instance. @@ -257,9 +278,6 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool model_ptr find(std::string_view const& type, KeyValueViewPairs const& queryIdParts) const; model_ptr find(std::string_view const& type, KeyValuePairs const& queryIdParts) const; - /** Shared pointer type */ - using Ptr = std::shared_ptr; - /** Optional staged-loading index (0-based) for this feature tile. */ [[nodiscard]] std::optional stage() const override; void setStage(std::optional stage) override; @@ -340,7 +358,7 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool * be appended to the existing feature. */ void clone( - std::unordered_map& clonedModelNodes, + CloneCache& clonedModelNodes, TileFeatureLayer::Ptr const& otherLayer, Feature const& otherFeature, std::string_view const& type, @@ -352,7 +370,7 @@ class TileFeatureLayer : public TileLayer, public simfil::ModelPool * of nodes which are referenced multiple times. */ simfil::ModelNode::Ptr clone( - std::unordered_map& clonedModelNodes, + CloneCache& clonedModelNodes, TileFeatureLayer::Ptr const& otherLayer, simfil::ModelNode::Ptr const& otherNode); diff --git a/libs/model/src/attrlayer.cpp b/libs/model/src/attrlayer.cpp index 6ae5ca14..8a596080 100644 --- a/libs/model/src/attrlayer.cpp +++ b/libs/model/src/attrlayer.cpp @@ -92,7 +92,8 @@ bool AttributeLayerList::forEachLayer( { if (!cb) return false; - for(auto const& [stringId, value] : fields()) { + auto local = localObject(); + for (auto const& [stringId, value] : local->fields()) { if (auto layerName = model().strings()->resolve(stringId)) { if (value->addr().column() != TileFeatureLayer::ColumnId::AttributeLayers) { log().warn("Don't add anything other than AttributeLayers into AttributeLayerLists!"); @@ -103,6 +104,9 @@ bool AttributeLayerList::forEachLayer( return false; } } + if (auto ext = extension()) { + return ext->forEachLayer(cb); + } return true; } diff --git a/libs/model/src/featureid.cpp b/libs/model/src/featureid.cpp index ce6b3598..88a8489d 100644 --- a/libs/model/src/featureid.cpp +++ b/libs/model/src/featureid.cpp @@ -226,8 +226,11 @@ std::string FeatureId::toString() const } if (values_) { - for (auto const& value : *values_) { - appendNodeValueToString(result, value); + auto const limit = std::min(partNames_.size(), visibleValueIndices_.size()); + for (size_t i = 0; i < limit; ++i) { + appendNodeValueToString( + result, + values_->at(static_cast(visibleValueIndices_[i]))); } } diff --git a/libs/model/src/featurelayer.cpp b/libs/model/src/featurelayer.cpp index 0ef9d5cf..88b66410 100644 --- a/libs/model/src/featurelayer.cpp +++ b/libs/model/src/featurelayer.cpp @@ -2034,17 +2034,18 @@ TileFeatureLayer::setStrings(std::shared_ptr const& newDict) } ModelNode::Ptr TileFeatureLayer::clone( - std::unordered_map& cache, + CloneCache& cache, const TileFeatureLayer::Ptr& otherLayer, const ModelNode::Ptr& otherNode) { - auto it = cache.find(otherNode->addr().value_); + auto const cacheKey = CloneCacheKey{otherLayer.get(), otherNode->addr().value_}; + auto it = cache.find(cacheKey); if (it != cache.end()) { return it->second; } using namespace simfil; - ModelNode::Ptr& newCacheNode = cache[otherNode->addr().value_]; + ModelNode::Ptr& newCacheNode = cache[cacheKey]; switch (otherNode->addr().column()) { case Objects: { auto resolved = otherLayer->resolve(otherNode); @@ -2296,12 +2297,12 @@ ModelNode::Ptr TileFeatureLayer::clone( newCacheNode = resolve(otherNode->addr()); } } - cache.insert({otherNode->addr().value_, newCacheNode}); + cache.insert({cacheKey, newCacheNode}); return newCacheNode; } void TileFeatureLayer::clone( - std::unordered_map& clonedModelNodes, + CloneCache& clonedModelNodes, const TileFeatureLayer::Ptr& otherLayer, const Feature& otherFeature, const std::string_view& type, @@ -2323,6 +2324,12 @@ void TileFeatureLayer::clone( return clone(clonedModelNodes, otherLayer, n); }; + auto owningLayer = + [](auto const& nodePtr) -> TileFeatureLayer::Ptr + { + return std::static_pointer_cast(nodePtr->model().shared_from_this()); + }; + // Adopt attributes if (auto attrs = otherFeature.attributesOrNull()) { auto baseAttrs = cloneTarget->attributes(); @@ -2336,21 +2343,23 @@ void TileFeatureLayer::clone( // Adopt attribute layers if (auto attrLayers = otherFeature.attributeLayersOrNull()) { auto baseAttrLayers = cloneTarget->attributeLayers(); - for (auto const& [key, value] : attrLayers->fields()) { - if (auto keyStr = otherLayer->strings()->resolve(key)) { - baseAttrLayers->addLayer(*keyStr, resolve(*lookupOrClone(value))); - } - } + attrLayers->forEachLayer( + [this, &baseAttrLayers, &clonedModelNodes, &owningLayer](std::string_view layerName, model_ptr const& layer) + { + auto cloned = clone(clonedModelNodes, owningLayer(layer), ModelNode::Ptr(layer)); + baseAttrLayers->addLayer(layerName, resolve(*cloned)); + return true; + }); } // Adopt geometries if (auto geom = otherFeature.geomOrNull()) { auto baseGeom = cloneTarget->geom(); geom->forEachGeometry( - [this, &baseGeom, &lookupOrClone](auto&& geomElement) + [this, &baseGeom, &clonedModelNodes, &owningLayer](auto&& geomElement) { baseGeom->addGeometry( - resolve(*lookupOrClone(geomElement))); + resolve(*clone(clonedModelNodes, owningLayer(geomElement), ModelNode::Ptr(geomElement)))); return true; }); } @@ -2358,9 +2367,9 @@ void TileFeatureLayer::clone( // Adopt relations if (otherFeature.numRelations()) { otherFeature.forEachRelation( - [this, &cloneTarget, &lookupOrClone](auto&& rel) + [this, &cloneTarget, &clonedModelNodes, &owningLayer](auto&& rel) { - auto newRel = resolve(*lookupOrClone(rel)); + auto newRel = resolve(*clone(clonedModelNodes, owningLayer(rel), ModelNode::Ptr(rel))); cloneTarget->addRelation(newRel); return true; }); diff --git a/libs/service/src/service.cpp b/libs/service/src/service.cpp index f90bf6b1..162ac00e 100644 --- a/libs/service/src/service.cpp +++ b/libs/service/src/service.cpp @@ -762,7 +762,7 @@ struct Service::Impl : public Service::Controller // Adopt new attributes, features and relations for the base feature // from the auxiliary feature. - std::unordered_map clonedModelNodes; + TileFeatureLayer::CloneCache clonedModelNodes; for (auto const& auxFeature : *auxTile) { // Note: A single secondary feature ID may resolve to multiple diff --git a/test/unit/test-model.cpp b/test/unit/test-model.cpp index a2b8e726..df651f99 100644 --- a/test/unit/test-model.cpp +++ b/test/unit/test-model.cpp @@ -1,4 +1,5 @@ #include +#include #include "mapget/model/featurelayer.h" #include "mapget/model/sourcedatareference.h" @@ -961,6 +962,213 @@ TEST_CASE("FeatureLayer Overlay Merged Views", "[test.featurelayer.overlay]") } } +TEST_CASE("FeatureLayer Overlay AttributeLayerList iteration uses owning model", "[test.featurelayer.overlay]") +{ + auto layerInfo = LayerInfo::fromJson(R"({ + "layerId": "WayLayer", + "type": "Features", + "featureTypes": [ + { + "name": "Way", + "uniqueIdCompositions": [ + [ + { + "partId": "wayId", + "description": "Globally unique 32b integer.", + "datatype": "U32" + } + ] + ] + } + ] + })"_json); + + auto strings = std::make_shared("OverlayNode"); + + auto makeTile = [&](std::string const& nodeName) { + return std::make_shared( + TileId::fromWgs84(42., 11., 13), + nodeName, + "OverlayMap", + layerInfo, + strings); + }; + + auto base = makeTile("OverlayNode"); + auto overlay = makeTile("OverlayNode"); + + auto baseFeature = base->newFeature("Way", {{"wayId", 1}}); + auto dummyBaseLayer = baseFeature->attributeLayers()->newLayer("dummyBaseLayer"); + auto dummyBaseAttr = dummyBaseLayer->newAttribute("dummyBaseAttr"); + REQUIRE(dummyBaseAttr->addField("value", "dummy").has_value()); + auto baseLayer = baseFeature->attributeLayers()->newLayer("baseLayer"); + auto baseAttr = baseLayer->newAttribute("baseAttr"); + REQUIRE(baseAttr->addField("value", "base").has_value()); + + auto overlayFeature = overlay->newFeature("Way", {{"wayId", 1}}); + auto overlayLayer = overlayFeature->attributeLayers()->newLayer("overlayLayer"); + auto overlayAttr = overlayLayer->newAttribute("overlayAttr"); + REQUIRE(overlayAttr->addField("value", "overlay").has_value()); + + base->attachOverlay(overlay); + + auto mergedFeature = base->at(0); + REQUIRE(mergedFeature); + + std::vector> layersSeen; + auto mergedLayers = mergedFeature->attributeLayersOrNull(); + REQUIRE(mergedLayers); + REQUIRE(mergedLayers->forEachLayer( + [&](std::string_view layerName, model_ptr const& layer) { + return layer->forEachAttribute([&](model_ptr const& attr) { + std::string fieldValue; + REQUIRE(attr->forEachField([&](std::string_view const& key, simfil::ModelNode::Ptr const& value) { + if (key == "value") { + auto scalar = value->value(); + if (auto const* s = std::get_if(&scalar)) { + fieldValue = *s; + } else if (auto const* sv = std::get_if(&scalar)) { + fieldValue = std::string(*sv); + } + } + return true; + })); + layersSeen.emplace_back(std::string(layerName), std::string(attr->name()), fieldValue); + return true; + }); + })); + + REQUIRE(layersSeen == std::vector>{ + {"dummyBaseLayer", "dummyBaseAttr", "dummy"}, + {"baseLayer", "baseAttr", "base"}, + {"overlayLayer", "overlayAttr", "overlay"}, + }); +} + +TEST_CASE("FeatureLayer clone preserves merged staged attribute layers geometry and relations", "[test.featurelayer.overlay]") +{ + auto layerInfo = LayerInfo::fromJson(R"({ + "layerId": "WayLayer", + "type": "Features", + "featureTypes": [ + { + "name": "Way", + "uniqueIdCompositions": [ + [ + { + "partId": "wayId", + "description": "Globally unique 32b integer.", + "datatype": "U32" + } + ] + ] + } + ] + })"_json); + + auto strings = std::make_shared("OverlayNode"); + + auto makeTile = [&](std::string const& nodeName) { + return std::make_shared( + TileId::fromWgs84(42., 11., 13), + nodeName, + "OverlayMap", + layerInfo, + strings); + }; + + auto target = makeTile("TargetNode"); + auto sourceBase = makeTile("OverlayNode"); + auto sourceOverlay = makeTile("OverlayNode"); + + auto sourceBaseFeature = sourceBase->newFeature("Way", {{"wayId", 1}}); + auto sourceBaseGeom = sourceBaseFeature->geom()->newGeometry(GeomType::Points, 1); + sourceBaseGeom->append({10., 10., 0.}); + auto dummyBaseLayer = sourceBaseFeature->attributeLayers()->newLayer("dummyBaseLayer"); + auto dummyBaseAttr = dummyBaseLayer->newAttribute("dummyBaseAttr"); + REQUIRE(dummyBaseAttr->addField("value", "dummy").has_value()); + auto sourceBaseLayer = sourceBaseFeature->attributeLayers()->newLayer("baseLayer"); + auto sourceBaseAttr = sourceBaseLayer->newAttribute("baseAttr"); + REQUIRE(sourceBaseAttr->addField("value", "base").has_value()); + sourceBaseFeature->addRelation("baseRel", sourceBase->newFeatureId("Way", {{"wayId", 100}})); + + auto sourceOverlayFeature = sourceOverlay->newFeature("Way", {{"wayId", 1}}); + auto sourceOverlayGeom = sourceOverlayFeature->geom()->newGeometry(GeomType::Points, 1); + sourceOverlayGeom->append({20., 20., 0.}); + auto sourceOverlayLayer = sourceOverlayFeature->attributeLayers()->newLayer("overlayLayer"); + auto sourceOverlayAttr = sourceOverlayLayer->newAttribute("overlayAttr"); + REQUIRE(sourceOverlayAttr->addField("value", "overlay").has_value()); + sourceOverlayFeature->addRelation("overlayRel", sourceOverlay->newFeatureId("Way", {{"wayId", 101}})); + + sourceBase->attachOverlay(sourceOverlay); + + auto mergedSourceFeature = sourceBase->at(0); + REQUIRE(mergedSourceFeature); + + TileFeatureLayer::CloneCache clonedModelNodes; + target->clone(clonedModelNodes, sourceBase, *mergedSourceFeature, "Way", {{"wayId", 1}}); + + auto clonedFeature = target->at(0); + REQUIRE(clonedFeature); + + std::vector> layersSeen; + auto clonedLayers = clonedFeature->attributeLayersOrNull(); + REQUIRE(clonedLayers); + REQUIRE(clonedLayers->forEachLayer( + [&](std::string_view layerName, model_ptr const& layer) { + return layer->forEachAttribute([&](model_ptr const& attr) { + std::string fieldValue; + REQUIRE(attr->forEachField([&](std::string_view const& key, simfil::ModelNode::Ptr const& value) { + if (key == "value") { + auto scalar = value->value(); + if (auto const* s = std::get_if(&scalar)) { + fieldValue = *s; + } else if (auto const* sv = std::get_if(&scalar)) { + fieldValue = std::string(*sv); + } + } + return true; + })); + layersSeen.emplace_back(std::string(layerName), std::string(attr->name()), fieldValue); + return true; + }); + })); + + REQUIRE(layersSeen == std::vector>{ + {"dummyBaseLayer", "dummyBaseAttr", "dummy"}, + {"baseLayer", "baseAttr", "base"}, + {"overlayLayer", "overlayAttr", "overlay"}, + }); + + std::vector firstPoints; + auto clonedGeom = clonedFeature->geomOrNull(); + REQUIRE(clonedGeom); + REQUIRE(clonedGeom->forEachGeometry([&](model_ptr const& geom) { + bool gotPoint = false; + geom->forEachPoint([&](glm::dvec3 const& point) { + firstPoints.push_back(point); + gotPoint = true; + return false; + }); + REQUIRE(gotPoint); + return true; + })); + REQUIRE(firstPoints.size() == 2); + REQUIRE(firstPoints[0].x == 10.0); + REQUIRE(firstPoints[0].y == 10.0); + REQUIRE(firstPoints[0].z == 0.0); + REQUIRE(firstPoints[1].x == 20.0); + REQUIRE(firstPoints[1].y == 20.0); + REQUIRE(firstPoints[1].z == 0.0); + + std::vector relationNames; + REQUIRE(clonedFeature->forEachRelation([&](model_ptr const& relation) { + relationNames.emplace_back(relation->name()); + return true; + })); + REQUIRE(relationNames == std::vector{"baseRel", "overlayRel"}); +} + TEST_CASE("FeatureLayer Overlay Size Check", "[test.featurelayer.overlay]") { auto layerInfo = LayerInfo::fromJson(R"({