using NetTopologySuite.Geometries; using NetTopologySuite.LinearReferencing; using OperationControl.Mapas.Editing; using OperationControl.Mapas.Models; using Point = NetTopologySuite.Geometries.Point; namespace OperationControl.Mapas.Services { /// /// Núcleo geométrico das operações de recorte. /// /// Não conhece: /// - Mapsui; /// - WPF; /// - GNSS; /// - Controller; /// - Undo/Redo. /// /// Recebe geometrias WGS84 e altera somente /// o MapaDocumento informado. /// public sealed class MapaRecorteService { private const double ToleranciaFragmentoMetros = 0.05; private const double ToleranciaIntersecaoMetros = 0.02; private readonly GeometryFactory _geometryFactory = new( new PrecisionModel(), 4326 ); /* * ========================================================= * POLÍGONO * ========================================================= */ public MapaResultadoRecorte RecortarPorPoligono( MapaDocumento documento, IEnumerable pontosPoligono, IReadOnlyCollection? ruasPermitidas = null) { ValidarDocumento( documento ); Polygon poligono = CriarPoligono( pontosPoligono ); HashSet? filtroIds = CriarFiltroIds( ruasPermitidas ); var resultado = new MapaResultadoRecorte(); var alteracoes = new List(); foreach ( var rua in documento.Ruas.ToArray()) { if ( rua == null || rua.Geometria == null || rua.Geometria.IsEmpty || rua.Geometria.NumPoints < 2 ) { continue; } if ( filtroIds != null && !filtroIds.Contains( rua.Id ) ) { continue; } resultado.RuasAnalisadas++; /* * Filtro barato primeiro. */ if ( !rua.Geometria .EnvelopeInternal .Intersects( poligono .EnvelopeInternal ) ) { resultado .RuasIgnoradas++; continue; } try { if ( !rua.Geometria .Intersects( poligono ) ) { resultado .RuasIgnoradas++; continue; } double comprimentoAntes = CalcularComprimentoMetros( rua.Geometria ); Geometry restante = rua.Geometria .Difference( poligono ); /* * Rua totalmente coberta pelo * polígono. */ if ( restante == null || restante.IsEmpty ) { alteracoes.Add( AlteracaoRua.Remover( rua, comprimentoAntes ) ); resultado .RuasRemovidasIds .Add( rua.Id ); resultado .ComprimentoRemovidoMetros += comprimentoAntes; continue; } List fragmentos = ExtrairLineStrings( restante ); /* * Nenhum segmento linear restou. */ if ( fragmentos.Count == 0 ) { alteracoes.Add( AlteracaoRua.Remover( rua, comprimentoAntes ) ); resultado .RuasRemovidasIds .Add( rua.Id ); resultado .ComprimentoRemovidoMetros += comprimentoAntes; continue; } /* * O polígono removeu uma parte * central da rua. * * Exemplo: * * ----XXXXXXXX---- * * Isso exigiria criar duas ruas. * * Nesta V1 deixamos intacta * e marcamos como ambígua. */ if ( fragmentos.Count != 1 ) { resultado .RuasAmbiguasIds .Add( rua.Id ); continue; } LineString novoTrecho = NormalizarOrientacao( fragmentos[0], rua.Geometria ); double comprimentoDepois = CalcularComprimentoMetros( novoTrecho ); double removido = comprimentoAntes - comprimentoDepois; /* * Pode acontecer quando o polígono * apenas encosta na rua. */ if ( removido <= ToleranciaFragmentoMetros ) { resultado .RuasIgnoradas++; continue; } /* * Restou praticamente zero. */ if ( comprimentoDepois <= ToleranciaFragmentoMetros ) { alteracoes.Add( AlteracaoRua.Remover( rua, comprimentoAntes ) ); resultado .RuasRemovidasIds .Add( rua.Id ); resultado .ComprimentoRemovidoMetros += comprimentoAntes; continue; } alteracoes.Add( AlteracaoRua.Substituir( rua, novoTrecho, comprimentoDepois ) ); resultado .RuasAlteradasIds .Add( rua.Id ); resultado .ComprimentoRemovidoMetros += removido; } catch ( TopologyException ) { /* * Nunca fazemos uma decisão destrutiva * quando a topologia é duvidosa. */ resultado .RuasAmbiguasIds .Add( rua.Id ); } } AplicarAlteracoes( documento, alteracoes ); return resultado; } /* * ========================================================= * LINHA DE BORDA * ========================================================= */ public MapaResultadoRecorte ApararPorLinha( MapaDocumento documento, IEnumerable pontosLinha, IReadOnlyCollection? ruasPermitidas = null) { ValidarDocumento( documento ); LineString linhaCorte = CriarLinha( pontosLinha ); HashSet? filtroIds = CriarFiltroIds( ruasPermitidas ); var resultado = new MapaResultadoRecorte(); var alteracoes = new List(); foreach ( var rua in documento.Ruas.ToArray()) { if ( rua == null || rua.Geometria == null || rua.Geometria.IsEmpty || rua.Geometria.NumPoints < 2 ) { continue; } if ( filtroIds != null && !filtroIds.Contains( rua.Id ) ) { continue; } resultado.RuasAnalisadas++; if ( !rua.Geometria .EnvelopeInternal .Intersects( linhaCorte .EnvelopeInternal ) ) { resultado .RuasIgnoradas++; continue; } try { Geometry intersecao = rua.Geometria .Intersection( linhaCorte ); if ( intersecao == null || intersecao.IsEmpty ) { resultado .RuasIgnoradas++; continue; } bool possuiTrechoSobreposto = false; List pontosIntersecao = ExtrairPontosIntersecao( intersecao, ref possuiTrechoSobreposto ); /* * Se a linha GNSS andar exatamente * sobre uma rua, não existe uma * interpretação segura. */ if ( possuiTrechoSobreposto ) { resultado .RuasAmbiguasIds .Add( rua.Id ); continue; } /* * Exigimos exatamente UM cruzamento * da borda com a rua. */ if ( pontosIntersecao.Count != 1 ) { if ( pontosIntersecao.Count == 0 ) { resultado .RuasIgnoradas++; } else { resultado .RuasAmbiguasIds .Add( rua.Id ); } continue; } var indexed = new LengthIndexedLine( rua.Geometria ); double indice = indexed.Project( pontosIntersecao[0] ); Geometry trechoA = indexed.ExtractLine( indexed.StartIndex, indice ); Geometry trechoB = indexed.ExtractLine( indice, indexed.EndIndex ); var fragmentosA = ExtrairLineStrings( trechoA ); var fragmentosB = ExtrairLineStrings( trechoB ); if ( fragmentosA.Count != 1 || fragmentosB.Count != 1 ) { resultado .RuasAmbiguasIds .Add( rua.Id ); continue; } LineString linhaA = NormalizarOrientacao( fragmentosA[0], rua.Geometria ); LineString linhaB = NormalizarOrientacao( fragmentosB[0], rua.Geometria ); double comprimentoA = CalcularComprimentoMetros( linhaA ); double comprimentoB = CalcularComprimentoMetros( linhaB ); double menor = Math.Min( comprimentoA, comprimentoB ); /* * Cruzamento praticamente no extremo. * Não existe recorte material. */ if ( menor <= ToleranciaFragmentoMetros ) { resultado .RuasIgnoradas++; continue; } /* * Se os dois lados forem praticamente * iguais, não existe um "menor lado" * confiável. */ if ( Math.Abs( comprimentoA - comprimentoB ) <= ToleranciaFragmentoMetros ) { resultado .RuasAmbiguasIds .Add( rua.Id ); continue; } LineString manter; double comprimentoManter; if ( comprimentoA > comprimentoB ) { manter = linhaA; comprimentoManter = comprimentoA; } else { manter = linhaB; comprimentoManter = comprimentoB; } manter = NormalizarOrientacao( manter, rua.Geometria ); alteracoes.Add( AlteracaoRua.Substituir( rua, manter, comprimentoManter ) ); resultado .RuasAlteradasIds .Add( rua.Id ); resultado .ComprimentoRemovidoMetros += menor; } catch ( TopologyException ) { resultado .RuasAmbiguasIds .Add( rua.Id ); } } AplicarAlteracoes( documento, alteracoes ); return resultado; } /* * ========================================================= * CONSTRUÇÃO DAS GEOMETRIAS DE ENTRADA * ========================================================= */ private Polygon CriarPoligono( IEnumerable pontos) { List normalizados = NormalizarPontos( pontos, 3 ); Coordinate primeiro = normalizados[0]; Coordinate ultimo = normalizados[ normalizados.Count - 1 ]; if ( DistanciaMetros( primeiro, ultimo ) <= 0.001 ) { normalizados[ normalizados.Count - 1 ] = CopiarCoordinate( primeiro ); } else { normalizados.Add( CopiarCoordinate( primeiro ) ); } if ( normalizados.Count < 4 ) { throw new InvalidOperationException( "O polígono de recorte " + "não possui vértices suficientes." ); } var ring = _geometryFactory .CreateLinearRing( normalizados .ToArray() ); var poligono = _geometryFactory .CreatePolygon( ring ); if ( poligono.IsEmpty || !poligono.IsValid || poligono.Area <= 0 ) { throw new InvalidOperationException( "O polígono de recorte " + "é inválido ou se auto-intersecta." ); } return poligono; } private LineString CriarLinha( IEnumerable pontos) { List normalizados = NormalizarPontos( pontos, 2 ); var linha = _geometryFactory .CreateLineString( normalizados .ToArray() ); if ( linha.IsEmpty || linha.NumPoints < 2 ) { throw new InvalidOperationException( "A linha de borda é inválida." ); } return linha; } private static List NormalizarPontos( IEnumerable pontos, int minimo) { if (pontos == null) { throw new ArgumentNullException( nameof(pontos) ); } var resultado = new List(); foreach ( Coordinate? ponto in pontos) { if (ponto == null) continue; ValidarCoordenada( ponto ); var copia = CopiarCoordinate( ponto ); if ( resultado.Count > 0 && DistanciaMetros( resultado[ resultado.Count - 1 ], copia ) <= 0.001 ) { continue; } resultado.Add( copia ); } if ( resultado.Count < minimo ) { throw new InvalidOperationException( $"São necessários pelo menos " + $"{minimo} pontos distintos." ); } return resultado; } /* * ========================================================= * EXTRAÇÃO DE RESULTADOS NTS * ========================================================= */ private static List ExtrairLineStrings( Geometry geometria) { var resultado = new List(); ExtrairLineStringsRecursivo( geometria, resultado ); return resultado .Where( x => x != null && !x.IsEmpty && x.NumPoints >= 2 && CalcularComprimentoMetros( x ) > 0.001 ) .ToList(); } private static void ExtrairLineStringsRecursivo( Geometry geometria, List resultado) { if ( geometria == null || geometria.IsEmpty ) { return; } if ( geometria is LineString linha ) { resultado.Add( linha ); return; } if ( geometria is GeometryCollection colecao ) { for ( int i = 0; i < colecao.NumGeometries; i++) { ExtrairLineStringsRecursivo( colecao .GetGeometryN( i ), resultado ); } } } private static List ExtrairPontosIntersecao( Geometry geometria, ref bool possuiTrechoLinear) { var resultado = new List(); ExtrairPontosIntersecaoRecursivo( geometria, resultado, ref possuiTrechoLinear ); /* * Elimina duplicatas que podem surgir * quando o cruzamento ocorre exatamente * em um vértice. */ var unicos = new List(); foreach ( var ponto in resultado) { bool repetido = unicos.Any( existente => DistanciaMetros( existente, ponto ) <= ToleranciaIntersecaoMetros ); if (!repetido) { unicos.Add( ponto ); } } return unicos; } private static void ExtrairPontosIntersecaoRecursivo( Geometry geometria, List resultado, ref bool possuiTrechoLinear) { if ( geometria == null || geometria.IsEmpty ) { return; } if ( geometria is Point ponto) { resultado.Add( CopiarCoordinate( ponto.Coordinate ) ); return; } if ( geometria is LineString linha) { if ( CalcularComprimentoMetros( linha ) > ToleranciaIntersecaoMetros ) { possuiTrechoLinear = true; } return; } if ( geometria is GeometryCollection colecao) { for ( int i = 0; i < colecao.NumGeometries; i++) { ExtrairPontosIntersecaoRecursivo( colecao .GetGeometryN( i ), resultado, ref possuiTrechoLinear ); } } } /* * ========================================================= * COMMIT DAS ALTERAÇÕES * ========================================================= */ private static void AplicarAlteracoes( MapaDocumento documento, IEnumerable alteracoes) { bool alterou = false; foreach ( var alteracao in alteracoes) { if ( alteracao.RemoverRua ) { documento .Ruas .Remove( alteracao.Rua ); alterou = true; continue; } if ( alteracao.NovaGeometria == null ) { continue; } alteracao .Rua .Geometria = alteracao .NovaGeometria; alteracao .Rua .ComprimentoMetros = alteracao .NovoComprimentoMetros; alterou = true; } if (alterou) { documento.Modificado = true; } } /* * ========================================================= * ORIENTAÇÃO * ========================================================= */ private static LineString NormalizarOrientacao( LineString fragmento, LineString original) { Coordinate inicioOriginal = original .Coordinates .First(); Coordinate inicioFragmento = fragmento .Coordinates .First(); Coordinate fimFragmento = fragmento .Coordinates .Last(); double distanciaInicio = DistanciaMetros( inicioOriginal, inicioFragmento ); double distanciaFim = DistanciaMetros( inicioOriginal, fimFragmento ); Coordinate[] coords = fragmento .Coordinates .Select( CopiarCoordinate ) .ToArray(); if ( distanciaFim < distanciaInicio ) { Array.Reverse( coords ); } var resultado = original .Factory .CreateLineString( coords ); resultado.SRID = original.SRID; resultado.UserData = original.UserData; return resultado; } /* * ========================================================= * DISTÂNCIA WGS84 * ========================================================= */ public static double CalcularComprimentoMetros( LineString linha) { if ( linha == null || linha.IsEmpty || linha.NumPoints < 2 ) { return 0; } Coordinate[] coords = linha.Coordinates; double total = 0; for ( int i = 1; i < coords.Length; i++) { total += DistanciaMetros( coords[i - 1], coords[i] ); } return total; } private static double DistanciaMetros( Coordinate a, Coordinate b) { const double raioTerra = 6371008.8; double lat1 = GrausParaRadianos( a.Y ); double lat2 = GrausParaRadianos( b.Y ); double deltaLat = GrausParaRadianos( b.Y - a.Y ); double deltaLon = GrausParaRadianos( b.X - a.X ); double senLat = Math.Sin( deltaLat / 2.0 ); double senLon = Math.Sin( deltaLon / 2.0 ); double h = senLat * senLat + Math.Cos( lat1 ) * Math.Cos( lat2 ) * senLon * senLon; h = Math.Clamp( h, 0, 1 ); double c = 2.0 * Math.Asin( Math.Sqrt( h ) ); return raioTerra * c; } private static double GrausParaRadianos( double graus) { return graus * Math.PI / 180.0; } /* * ========================================================= * VALIDAÇÃO * ========================================================= */ private static void ValidarDocumento( MapaDocumento documento) { if (documento == null) { throw new ArgumentNullException( nameof(documento) ); } } private static void ValidarCoordenada( Coordinate ponto) { if ( !double.IsFinite( ponto.X ) || !double.IsFinite( ponto.Y ) || ponto.X < -180 || ponto.X > 180 || ponto.Y < -90 || ponto.Y > 90 ) { throw new ArgumentOutOfRangeException( nameof(ponto), "Coordenada WGS84 inválida." ); } } private static Coordinate CopiarCoordinate( Coordinate origem) { return new Coordinate( origem.X, origem.Y ); } private static HashSet? CriarFiltroIds( IReadOnlyCollection? ruasPermitidas) { if ( ruasPermitidas == null ) { /* * null = todas as ruas */ return null; } return new HashSet( ruasPermitidas .Where( x => !string.IsNullOrWhiteSpace( x ) ), StringComparer.OrdinalIgnoreCase ); } /* * ========================================================= * MODELO INTERNO DE ALTERAÇÃO * ========================================================= */ private sealed class AlteracaoRua { public MapaRua Rua { get; } public bool RemoverRua { get; } public LineString? NovaGeometria { get; } public double NovoComprimentoMetros { get; } private AlteracaoRua( MapaRua rua, bool removerRua, LineString? novaGeometria, double novoComprimentoMetros) { Rua = rua; RemoverRua = removerRua; NovaGeometria = novaGeometria; NovoComprimentoMetros = novoComprimentoMetros; } public static AlteracaoRua Remover( MapaRua rua, double comprimentoAnterior) { return new AlteracaoRua( rua, true, null, 0 ); } public static AlteracaoRua Substituir( MapaRua rua, LineString geometria, double comprimento) { return new AlteracaoRua( rua, false, geometria, comprimento ); } } } }