From bf1b35ccaa938926066dd5718bcc15c9b82ece1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Karas?= Date: Thu, 2 Apr 2026 15:47:49 +0200 Subject: [PATCH 1/5] Group multi-node junctions in SuggestedLanesPostprocessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complex OSM intersections are modeled as multiple nodes connected by short internal segments that maintain the same lane configuration. The lane suggestion algorithm previously processed each node independently, seeing only a subset of exits and producing wrong lane recommendations. Scan backwards through the route buffer to detect consecutive junction nodes that are close together (<50m) and share the same lane config, then aggregate their exits into a single evaluation. Filter exits pointing back toward the incoming direction to suppress opposite carriageway false positives in grouped junctions. Fixes the Průmyslová/Černokostecká test case. --- Tests/src/RoutePostprocessorTest.cpp | 10 +- .../osmscout/routing/RoutePostprocessor.h | 16 +- .../osmscout/routing/RoutePostprocessor.cpp | 198 ++++++++++++++---- 3 files changed, 172 insertions(+), 52 deletions(-) diff --git a/Tests/src/RoutePostprocessorTest.cpp b/Tests/src/RoutePostprocessorTest.cpp index 4fc33a11e..9e425e0a4 100644 --- a/Tests/src/RoutePostprocessorTest.cpp +++ b/Tests/src/RoutePostprocessorTest.cpp @@ -272,7 +272,7 @@ class MockContext: public osmscout::PostprocessorContext osmscout::Duration GetTime([[maybe_unused]] osmscout::DatabaseId dbId, [[maybe_unused]] const osmscout::Way &way, [[maybe_unused]] const osmscout::Distance &deltaDistance) const override { - assert(false); + // DistanceAndTimePostprocessor using this method, but we don't need valid time for testing return osmscout::Duration(); } @@ -486,6 +486,7 @@ class MockContext: public osmscout::PostprocessorContext void Postprocess(RouteDescription &description, MockContext &context) { + RoutePostprocessor::DistanceAndTimePostprocessor().Process(context, description); RoutePostprocessor::LanesPostprocessor().Process(context, description); RoutePostprocessor::SuggestedLanesPostprocessor().Process(context, description); @@ -1077,10 +1078,9 @@ TEST_CASE("Describe complex city junction: Průmyslová, Černokostecká") auto nodeIt = description.Nodes().begin(); auto suggestedLanes = nodeIt->GetDescription(); REQUIRE(suggestedLanes); - // TODO: improve lane evaluation on similar junctions - // REQUIRE(suggestedLanes->GetFrom() == 0); - // REQUIRE(suggestedLanes->GetTo() == 1); - // REQUIRE(suggestedLanes->GetTurn() == LaneTurn::Left); + REQUIRE(suggestedLanes->GetFrom() == 0); + REQUIRE(suggestedLanes->GetTo() == 1); + REQUIRE(suggestedLanes->GetTurn() == LaneTurn::Left); } } diff --git a/libosmscout/include/osmscout/routing/RoutePostprocessor.h b/libosmscout/include/osmscout/routing/RoutePostprocessor.h index cda18cc8e..717792410 100644 --- a/libosmscout/include/osmscout/routing/RoutePostprocessor.h +++ b/libosmscout/include/osmscout/routing/RoutePostprocessor.h @@ -459,15 +459,19 @@ namespace osmscout { private: RouteDescription::LaneDescriptionRef GetLaneDescription(const RouteDescription::Node &node) const; - /** Evaluate suggested lanes on nodes from "backBuffer", followed by "node". - * Node itself is junction, where count of lanes (on way from the node) - * is smaller than on the previous node (back of backBuffer). + /** Evaluate suggested lanes on nodes from "backBuffer", followed by junction node(s). + * Junction nodes are crossings where the route transitions from the incoming lane + * configuration (back of backBuffer) to a different one. Multiple consecutive junction + * nodes that are close together and connected by segments with the same lane configuration + * are grouped into a single logical junction — their exits are aggregated. * - * @param node - * @param backBuffer buffer of traveled nodes, recent node at back + * @param junctionNodes one or more consecutive junction nodes forming a logical junction + * @param lastNode the node after the last junction node (provides outgoing way info) + * @param backBuffer buffer of traveled nodes before the junction, recent node at back */ void EvaluateLaneSuggestion(const PostprocessorContext& context, - const RouteDescription::Node &node, + const std::vector &junctionNodes, + const RouteDescription::Node &lastNode, const std::list &backBuffer) const; private: diff --git a/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp b/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp index b8490e083..5365291e3 100644 --- a/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp +++ b/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp @@ -29,8 +29,9 @@ #include #include #include -#include #include +#include +#include namespace osmscout { @@ -1851,75 +1852,143 @@ namespace osmscout { } // end of anonymous namespace void RoutePostprocessor::SuggestedLanesPostprocessor::EvaluateLaneSuggestion(const PostprocessorContext& postprocessor, - const RouteDescription::Node &node, + const std::vector &junctionNodes, + const RouteDescription::Node &lastNode, const std::list &backBuffer) const { - if (node.GetPathObject().GetType()!=refWay) { + assert(!junctionNodes.empty()); + const RouteDescription::Node &firstJunctionNode = *junctionNodes.front(); + const RouteDescription::Node &lastJunctionNode = *junctionNodes.back(); + + if (firstJunctionNode.GetPathObject().GetType()!=refWay) { return; // areas not considered now } assert(!backBuffer.empty()); const RouteDescription::Node &prevNode=*backBuffer.back(); - DatabaseId dbId = node.GetDatabaseId(); + DatabaseId dbId = firstJunctionNode.GetDatabaseId(); if (prevNode.GetDatabaseId() != dbId) { return; // we consider nodes just from the same database for simplicity } - auto lanes = GetLaneDescription(node); + auto lanes = GetLaneDescription(lastNode); + if (!lanes) { + lanes = GetLaneDescription(lastJunctionNode); + } assert(lanes); auto prevLanes = GetLaneDescription(prevNode); assert(prevLanes); - // read lanes on the ALL junction ways, use heuristics what lanes we can use to move forward - Id nodeId = postprocessor.GetNodeId(node); + // Collect node IDs internal to the junction group (to exclude from exits) + std::set internalNodeIds; + for (const auto* jNode : junctionNodes) { + internalNodeIds.insert(postprocessor.GetNodeId(*jNode)); + } + + // Collect internal way file offsets (connecting segments between grouped nodes) to exclude. + // These are the path objects of all junction nodes except the last — the last node's + // pathObject is the outgoing way leaving the junction, not an internal connector. + // The incoming way and outgoing way are excluded separately via prevNodeId/nextNodeId checks. + std::set internalWayOffsets; + for (size_t idx = 0; idx + 1 < junctionNodes.size(); ++idx) { + const auto* jNode = junctionNodes[idx]; + if (jNode->GetPathObject().Valid() && jNode->GetPathObject().GetType() == refWay) { + internalWayOffsets.insert(jNode->GetPathObject().GetFileOffset()); + } + } WayRef prevWay = postprocessor.GetWay(prevNode.GetDBFileOffset()); Id prevNodeId = prevWay->GetId(prevNode.GetCurrentNodeIndex()); - // bearing from current node to previous + // bearing from first junction node to previous node Bearing prevNodeBearing = GetSphericalBearingInitial(prevWay->nodes[prevNode.GetTargetNodeIndex()].GetCoord(), prevWay->nodes[prevNode.GetCurrentNodeIndex()].GetCoord()); - WayRef way = postprocessor.GetWay(node.GetDBFileOffset()); - Id nextNodeId = way->GetId(node.GetTargetNodeIndex()); - Bearing nextNodeBearing = GetSphericalBearingInitial(way->nodes[node.GetCurrentNodeIndex()].GetCoord(), - way->nodes[node.GetTargetNodeIndex()].GetCoord()); + // Outgoing way: from the last junction node + WayRef outWay; + Id nextNodeId; + Bearing nextNodeBearing; + if (lastJunctionNode.GetPathObject().Valid() && lastJunctionNode.GetPathObject().GetType() == refWay) { + outWay = postprocessor.GetWay(lastJunctionNode.GetDBFileOffset()); + nextNodeId = outWay->GetId(lastJunctionNode.GetTargetNodeIndex()); + nextNodeBearing = GetSphericalBearingInitial(outWay->nodes[lastJunctionNode.GetCurrentNodeIndex()].GetCoord(), + outWay->nodes[lastJunctionNode.GetTargetNodeIndex()].GetCoord()); + } else { + return; // no valid outgoing way + } + + // Collect exits from ALL junction nodes + size_t totalObjects = 0; + for (const auto* jNode : junctionNodes) { + totalObjects += jNode->GetObjects().size(); + } std::vector junctionExits; // excluding incoming and outgoing way std::vector junctionLeftExits; std::vector junctionRightExits; - junctionExits.reserve(node.GetObjects().size()); - junctionLeftExits.reserve(node.GetObjects().size()); - junctionRightExits.reserve(node.GetObjects().size()); - - for (const auto &o: node.GetObjects()){ - if (!o.IsWay()) { - continue; // areas not considered now - } - - way=postprocessor.GetWay(DBFileOffset(node.GetDatabaseId(), o.GetFileOffset())); - for (size_t i = 0; i < way->nodes.size(); i++) { - if (way->nodes[i].GetId() == nodeId) { - if (i < way->nodes.size()-1 && postprocessor.CanUseForward(dbId, nodeId, o)) { - Id junctionNodeId = way->nodes[i+1].GetId(); - if (junctionNodeId!=prevNodeId && junctionNodeId!=nextNodeId) { - auto bearing = GetSphericalBearingInitial(way->nodes[i].GetCoord(), way->nodes[i + 1].GetCoord()); - auto wayLanes = postprocessor.GetLanes(node.GetDatabaseId(), way, true); - junctionExits.push_back({junctionNodeId, bearing, Bearing(), wayLanes}); + junctionExits.reserve(totalObjects); + junctionLeftExits.reserve(totalObjects); + junctionRightExits.reserve(totalObjects); + + // Track exit node IDs we've already added to avoid duplicates from overlapping object lists + std::set addedExitNodeIds; + + for (const auto* jNode : junctionNodes) { + Id jNodeId = postprocessor.GetNodeId(*jNode); + + for (const auto &o: jNode->GetObjects()) { + if (!o.IsWay()) { + continue; // areas not considered now + } + + // Skip internal junction connector ways + if (internalWayOffsets.count(o.GetFileOffset()) > 0) { + continue; + } + + WayRef way = postprocessor.GetWay(DBFileOffset(dbId, o.GetFileOffset())); + for (size_t i = 0; i < way->nodes.size(); i++) { + if (way->nodes[i].GetId() == jNodeId) { + if (i < way->nodes.size()-1 && postprocessor.CanUseForward(dbId, jNodeId, o)) { + Id junctionNodeId = way->nodes[i+1].GetId(); + if (junctionNodeId!=prevNodeId && junctionNodeId!=nextNodeId + && internalNodeIds.count(junctionNodeId) == 0 + && addedExitNodeIds.count(junctionNodeId) == 0) { + auto bearing = GetSphericalBearingInitial(way->nodes[i].GetCoord(), way->nodes[i + 1].GetCoord()); + auto wayLanes = postprocessor.GetLanes(dbId, way, true); + junctionExits.push_back({junctionNodeId, bearing, Bearing(), wayLanes}); + addedExitNodeIds.insert(junctionNodeId); + } } - } - if (i > 0 && postprocessor.CanUseBackward(dbId, nodeId, o)) { - Id junctionNodeId = way->nodes[i-1].GetId(); - if (junctionNodeId!=prevNodeId && junctionNodeId!=nextNodeId) { - auto bearing = GetSphericalBearingInitial(way->nodes[i].GetCoord(), way->nodes[i - 1].GetCoord()); - auto wayLanes = postprocessor.GetLanes(node.GetDatabaseId(), way, false); - junctionExits.push_back({junctionNodeId, bearing, Bearing(), wayLanes}); + if (i > 0 && postprocessor.CanUseBackward(dbId, jNodeId, o)) { + Id junctionNodeId = way->nodes[i-1].GetId(); + if (junctionNodeId!=prevNodeId && junctionNodeId!=nextNodeId + && internalNodeIds.count(junctionNodeId) == 0 + && addedExitNodeIds.count(junctionNodeId) == 0) { + auto bearing = GetSphericalBearingInitial(way->nodes[i].GetCoord(), way->nodes[i - 1].GetCoord()); + auto wayLanes = postprocessor.GetLanes(dbId, way, false); + junctionExits.push_back({junctionNodeId, bearing, Bearing(), wayLanes}); + addedExitNodeIds.insert(junctionNodeId); + } } + break; } - break; } } } + // Filter exits whose bearing is too close to the incoming direction (opposite carriageway / U-turn). + // An exit within ~30° of prevNodeBearing is going back toward where we came from. + if (junctionNodes.size() > 1) { + junctionExits.erase( + std::remove_if(junctionExits.begin(), junctionExits.end(), + [&prevNodeBearing](const JunctionExit &exit) { + Bearing diff = exit.bearing - prevNodeBearing; + // diff near 0° means exit goes same direction as "junction→prev", i.e., back where we came from + return diff < Bearing::Degrees(30) || diff > Bearing::Degrees(330); + }), + junctionExits.end()); + } + Bearing prevNodeRelativeToNextBearing = prevNodeBearing - nextNodeBearing; for (JunctionExit &exit: junctionExits){ Bearing relativeToNextBearing = exit.bearing - nextNodeBearing; // relative to next @@ -2032,8 +2101,6 @@ namespace osmscout { bool RoutePostprocessor::SuggestedLanesPostprocessor::Process(const PostprocessorContext& postprocessor, RouteDescription& description) { - using namespace std::string_view_literals; - // buffer of traveled nodes, recent node at back std::list backBuffer; for (auto& node : description.Nodes()) { @@ -2055,9 +2122,58 @@ namespace osmscout { if (prevLanes->GetLaneCount() > lanes->GetLaneCount() // lane count was decreased || prevLanes->GetLaneTurns() != lanes->GetLaneTurns()) { // lane turns changed - EvaluateLaneSuggestion(postprocessor, node, backBuffer); + // Group junction nodes: scan backwards through backBuffer for preceding junction nodes + // that are close together and have the same lane config. This handles complex intersections + // modeled as multiple OSM nodes connected by short internal segments. + std::vector junctionNodes; + junctionNodes.push_back(&node); + + size_t backwardCount = 0; + for (auto bufIt = backBuffer.rbegin(); bufIt != backBuffer.rend(); ++bufIt) { + // Keep at least one node in backBuffer as the prevNode for evaluation + if (backwardCount + 1 >= backBuffer.size()) { + break; + } + + const RouteDescription::Node* candidate = *bufIt; + + // Must be a real junction (multiple ways crossing) + if (candidate->GetObjects().size() <= 1) { + break; + } + // Must be close to the most recently grouped junction node + Distance dist = GetSphericalDistance(candidate->GetLocation(), + junctionNodes.back()->GetLocation()); + if (dist > Meters(50)) { + break; + } + // The segment from this node should have the same lane config as the approach + auto candidateLanes = GetLaneDescription(*candidate); + if (!candidateLanes || + candidateLanes->GetLaneCount() != prevLanes->GetLaneCount() || + candidateLanes->GetLaneTurns() != prevLanes->GetLaneTurns()) { + break; + } + + junctionNodes.push_back(candidate); + backwardCount++; + } + + // Reverse so junctionNodes is in route order (earliest first) + std::reverse(junctionNodes.begin(), junctionNodes.end()); + + // Remove backward-grouped nodes from backBuffer + for (size_t i = 0; i < backwardCount && !backBuffer.empty(); ++i) { + backBuffer.pop_back(); + } + + if (!backBuffer.empty()) { + EvaluateLaneSuggestion(postprocessor, junctionNodes, node, backBuffer); + } backBuffer.clear(); + backBuffer.push_back(&node); + continue; } } From 2283b1224c24808cf24bc0191dc052d9c3048ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Karas?= Date: Wed, 20 May 2026 23:03:27 +0200 Subject: [PATCH 2/5] propagate suggested lanes to grouped nodes --- .../include/osmscout/routing/RoutePostprocessor.h | 2 +- .../src/osmscout/routing/RoutePostprocessor.cpp | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/libosmscout/include/osmscout/routing/RoutePostprocessor.h b/libosmscout/include/osmscout/routing/RoutePostprocessor.h index 717792410..f5add6b0c 100644 --- a/libosmscout/include/osmscout/routing/RoutePostprocessor.h +++ b/libosmscout/include/osmscout/routing/RoutePostprocessor.h @@ -470,7 +470,7 @@ namespace osmscout { * @param backBuffer buffer of traveled nodes before the junction, recent node at back */ void EvaluateLaneSuggestion(const PostprocessorContext& context, - const std::vector &junctionNodes, + const std::vector &junctionNodes, const RouteDescription::Node &lastNode, const std::list &backBuffer) const; diff --git a/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp b/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp index 5365291e3..c3ff2156d 100644 --- a/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp +++ b/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp @@ -1852,7 +1852,7 @@ namespace osmscout { } // end of anonymous namespace void RoutePostprocessor::SuggestedLanesPostprocessor::EvaluateLaneSuggestion(const PostprocessorContext& postprocessor, - const std::vector &junctionNodes, + const std::vector &junctionNodes, const RouteDescription::Node &lastNode, const std::list &backBuffer) const { @@ -2091,6 +2091,15 @@ namespace osmscout { } nodePtr->AddDescription(suggested); } + // Also write suggestion to grouped junction nodes that have the same lane config + // as the approach. These nodes were removed from backBuffer during grouping but + // still represent approach segments that need the suggestion. + for (auto* jNode : junctionNodes) { + auto nodeLanes = GetLaneDescription(*jNode); + if (nodeLanes && *prevLanes == *nodeLanes) { + jNode->AddDescription(suggested); + } + } } RouteDescription::LaneDescriptionRef RoutePostprocessor::SuggestedLanesPostprocessor::GetLaneDescription(const RouteDescription::Node &node) const @@ -2125,7 +2134,7 @@ namespace osmscout { // Group junction nodes: scan backwards through backBuffer for preceding junction nodes // that are close together and have the same lane config. This handles complex intersections // modeled as multiple OSM nodes connected by short internal segments. - std::vector junctionNodes; + std::vector junctionNodes; junctionNodes.push_back(&node); size_t backwardCount = 0; @@ -2135,7 +2144,7 @@ namespace osmscout { break; } - const RouteDescription::Node* candidate = *bufIt; + RouteDescription::Node* candidate = *bufIt; // Must be a real junction (multiple ways crossing) if (candidate->GetObjects().size() <= 1) { From 46ec37fe91c9b4c18f56d6117490e97d90d76bce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Karas?= Date: Thu, 21 May 2026 22:20:21 +0200 Subject: [PATCH 3/5] use lanes from node group --- .../src/osmscout/routing/RoutePostprocessor.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp b/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp index c3ff2156d..fb5899e02 100644 --- a/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp +++ b/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp @@ -1876,7 +1876,16 @@ namespace osmscout { lanes = GetLaneDescription(lastJunctionNode); } assert(lanes); - auto prevLanes = GetLaneDescription(prevNode); + // When backward grouping pulled approach nodes out of backBuffer, use their lane + // config (from firstJunctionNode) instead of the now-exposed backBuffer.back(). + // The grouped nodes were verified to have the approach config during grouping. + RouteDescription::LaneDescriptionRef prevLanes; + if (junctionNodes.size() > 1) { + prevLanes = GetLaneDescription(firstJunctionNode); + } + if (!prevLanes) { + prevLanes = GetLaneDescription(prevNode); + } assert(prevLanes); // Collect node IDs internal to the junction group (to exclude from exits) From d4971bf22bc7796ec2975e44d8deabde39038bd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Karas?= Date: Wed, 27 May 2026 23:47:19 +0200 Subject: [PATCH 4/5] fix suggested lanes on highway --- libosmscout/src/osmscout/routing/RoutePostprocessor.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp b/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp index fb5899e02..f54762d62 100644 --- a/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp +++ b/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp @@ -2159,10 +2159,10 @@ namespace osmscout { if (candidate->GetObjects().size() <= 1) { break; } - // Must be close to the most recently grouped junction node + // Must be close to the trigger node (first junction node) Distance dist = GetSphericalDistance(candidate->GetLocation(), - junctionNodes.back()->GetLocation()); - if (dist > Meters(50)) { + junctionNodes.front()->GetLocation()); + if (dist > Meters(35)) { break; } // The segment from this node should have the same lane config as the approach From 6ca65577c0d2a12dd38cb1c67e6e02bf52a7214d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Karas?= Date: Fri, 29 May 2026 16:24:58 +0200 Subject: [PATCH 5/5] remove too aggresive grouping --- .../src/osmscout/routing/RoutePostprocessor.cpp | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp b/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp index f54762d62..221e8492e 100644 --- a/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp +++ b/libosmscout/src/osmscout/routing/RoutePostprocessor.cpp @@ -1894,18 +1894,6 @@ namespace osmscout { internalNodeIds.insert(postprocessor.GetNodeId(*jNode)); } - // Collect internal way file offsets (connecting segments between grouped nodes) to exclude. - // These are the path objects of all junction nodes except the last — the last node's - // pathObject is the outgoing way leaving the junction, not an internal connector. - // The incoming way and outgoing way are excluded separately via prevNodeId/nextNodeId checks. - std::set internalWayOffsets; - for (size_t idx = 0; idx + 1 < junctionNodes.size(); ++idx) { - const auto* jNode = junctionNodes[idx]; - if (jNode->GetPathObject().Valid() && jNode->GetPathObject().GetType() == refWay) { - internalWayOffsets.insert(jNode->GetPathObject().GetFileOffset()); - } - } - WayRef prevWay = postprocessor.GetWay(prevNode.GetDBFileOffset()); Id prevNodeId = prevWay->GetId(prevNode.GetCurrentNodeIndex()); // bearing from first junction node to previous node @@ -1949,11 +1937,6 @@ namespace osmscout { continue; // areas not considered now } - // Skip internal junction connector ways - if (internalWayOffsets.count(o.GetFileOffset()) > 0) { - continue; - } - WayRef way = postprocessor.GetWay(DBFileOffset(dbId, o.GetFileOffset())); for (size_t i = 0; i < way->nodes.size(); i++) { if (way->nodes[i].GetId() == jNodeId) {