using NetTopologySuite.Geometries; using OperationControl.Mapas.Models; using OperationControl.Mapas.Services; namespace OperationControl.Mapas.Editing { public sealed class MapaEdicaoService { private const int LimiteHistorico = 50; private readonly MapaDocumentoCloneService _cloneService = new(); private readonly MapaRecorteService _recorteService = new(); public void Iniciar(MapaEstadoEdicao estado, MapaDocumento documento) { if (estado == null) { throw new ArgumentNullException( nameof(estado) ); } if (documento == null) { throw new ArgumentNullException( nameof(documento) ); } estado.DocumentoOriginal = _cloneService.Clonar(documento); estado.DocumentoTrabalho = _cloneService.Clonar(documento); estado.RuasSelecionadasMutaveis.Clear(); estado.DocumentoTrabalho.Modificado = false; estado.HistoricoDesfazerMutavel.Clear(); estado.HistoricoRefazerMutavel.Clear(); } public void Cancelar(MapaEstadoEdicao estado) { ValidarEstado(estado); estado.DocumentoOriginal = null; estado.DocumentoTrabalho = null; estado.RuasSelecionadasMutaveis.Clear(); estado.HistoricoDesfazerMutavel.Clear(); estado.HistoricoRefazerMutavel.Clear(); } public bool AlternarRua(MapaEstadoEdicao estado, string ruaId) { var id = ObterIdCanonico( estado, ruaId ); if ( estado .RuasSelecionadasMutaveis .Contains( id ) ) { estado .RuasSelecionadasMutaveis .Remove( id ); return false; } estado .RuasSelecionadasMutaveis .Add( id ); return true; } public void SelecionarRua( MapaEstadoEdicao estado, string ruaId) { string id = ObterIdCanonico( estado, ruaId ); estado .RuasSelecionadasMutaveis .Add( id ); } public void RemoverRuaSelecao( MapaEstadoEdicao estado, string ruaId) { ValidarEstadoAtivo( estado ); if ( string.IsNullOrWhiteSpace( ruaId ) ) { return; } estado .RuasSelecionadasMutaveis .Remove( ruaId.Trim() ); } public void LimparSelecao( MapaEstadoEdicao estado) { ValidarEstadoAtivo( estado ); estado .RuasSelecionadasMutaveis .Clear(); } /* * Operação destrutiva de exclusão completa * de ruas selecionadas. * * A alteração é registrada no histórico * como uma única transação de Undo/Redo. */ public int RemoverRuasSelecionadas(MapaEstadoEdicao estado) { ValidarEstadoAtivo( estado ); if ( estado .RuasSelecionadasMutaveis .Count == 0 ) { return 0; } var documento = estado.DocumentoTrabalho!; /* * Descobrimos primeiro se realmente haverá * alguma mutação. * * Não queremos criar Ctrl+Z vazio. */ bool possuiRuaRemovivel = documento .Ruas .Any( rua => estado .RuasSelecionadasMutaveis .Contains( rua.Id ) ); if (!possuiRuaRemovivel) return 0; /* * Estado imediatamente ANTES da operação. * * A seleção também é preservada. */ var antes = CriarSnapshot( estado ); int quantidadeAntes = documento.Ruas.Count; documento.Ruas.RemoveAll( rua => estado .RuasSelecionadasMutaveis .Contains( rua.Id ) ); int removidas = quantidadeAntes - documento.Ruas.Count; estado .RuasSelecionadasMutaveis .Clear(); if (removidas <= 0) return 0; documento.Modificado = true; /* * Estado imediatamente DEPOIS. */ var depois = CriarSnapshot( estado ); RegistrarAlteracao( estado, removidas == 1 ? "Excluir 1 rua" : $"Excluir {removidas} ruas", antes, depois ); return removidas; } public MapaResultadoRecorte RecortarPorPoligono(MapaEstadoEdicao estado, IEnumerable pontosPoligono, bool somenteSelecionadas = false) { ValidarEstadoAtivo( estado ); if ( somenteSelecionadas && !estado.PossuiSelecao ) { throw new InvalidOperationException( "Nenhuma rua está selecionada " + "para receber o recorte." ); } /* * Snapshot ANTES da operação inteira. * * Se 80 ruas forem cortadas, * ainda será apenas um Ctrl+Z. */ var antes = CriarSnapshot( estado ); try { IReadOnlyCollection? escopo = somenteSelecionadas ? estado .RuasSelecionadas .ToArray() : null; var resultado = _recorteService .RecortarPorPoligono( estado .DocumentoTrabalho!, pontosPoligono, escopo ); if ( !resultado.HouveAlteracao ) { return resultado; } /* * Uma rua completamente removida * não pode continuar selecionada. */ estado .RuasSelecionadasMutaveis .ExceptWith( resultado .RuasRemovidasIds ); var depois = CriarSnapshot( estado ); RegistrarAlteracao( estado, $"Recorte por polígono " + $"({resultado.RuasAlteradas + resultado.RuasRemovidas} ruas)", antes, depois ); return resultado; } catch { /* * Segurança transacional. * * Se algo inesperado acontecer depois * de alguma alteração, o DocumentoTrabalho * volta integralmente ao snapshot anterior. */ RestaurarSnapshot( estado, antes ); throw; } } public MapaResultadoRecorte ApararPorLinha(MapaEstadoEdicao estado, IEnumerable pontosLinha, bool somenteSelecionadas = false) { ValidarEstadoAtivo( estado ); if ( somenteSelecionadas && !estado.PossuiSelecao ) { throw new InvalidOperationException( "Nenhuma rua está selecionada " + "para receber o recorte." ); } var antes = CriarSnapshot( estado ); try { IReadOnlyCollection? escopo = somenteSelecionadas ? estado .RuasSelecionadas .ToArray() : null; var resultado = _recorteService .ApararPorLinha( estado .DocumentoTrabalho!, pontosLinha, escopo ); if ( !resultado.HouveAlteracao ) { return resultado; } var depois = CriarSnapshot( estado ); RegistrarAlteracao( estado, $"Aparar por borda " + $"({resultado.RuasAlteradas} ruas)", antes, depois ); return resultado; } catch { RestaurarSnapshot( estado, antes ); throw; } } public bool RestaurarDocumentoFonte(MapaEstadoEdicao estado, MapaDocumento documentoFonte) { ValidarEstadoAtivo( estado ); if (documentoFonte == null) { throw new ArgumentNullException( nameof(documentoFonte) ); } /* * Não criamos histórico vazio. * * Se o documento de trabalho já corresponde * exatamente à fonte, não há nada para fazer. */ if ( DocumentosEquivalentes( estado.DocumentoTrabalho!, documentoFonte ) ) { return false; } /* * Tudo que existia imediatamente antes * da restauração pode voltar com Ctrl+Z. */ MapaEdicaoSnapshot antes = CriarSnapshot( estado ); /* * Nunca entregamos DocumentoFonte diretamente. * * O editor recebe outra cópia profunda. */ estado.DocumentoTrabalho = _cloneService .Clonar( documentoFonte ); /* * Embora geometricamente seja o documento-fonte, * dentro desta sessão isso representa uma alteração * em relação ao documento oficial atual. * * Portanto Apply precisa ficar habilitado. */ estado.DocumentoTrabalho.Modificado = true; /* * A seleção de edição pertence ao documento * anterior. * * Limpamos para evitar uma seleção cirúrgica * antiga sendo aplicada acidentalmente depois * da restauração. * * A seleção OPERACIONAL do Workspace não passa * por aqui. */ estado .RuasSelecionadasMutaveis .Clear(); MapaEdicaoSnapshot depois = CriarSnapshot( estado ); RegistrarAlteracao( estado, "Restaurar mapa original", antes, depois ); return true; } /* * Devolve uma cópia. * * Nunca entregamos diretamente * DocumentoTrabalho para outra camada * começar a mutá-lo escondido. */ public MapaDocumento ObterResultado(MapaEstadoEdicao estado) { ValidarEstadoAtivo(estado); return _cloneService.Clonar(estado.DocumentoTrabalho!); } private static string ObterIdCanonico( MapaEstadoEdicao estado, string ruaId) { ValidarEstadoAtivo( estado ); if ( string.IsNullOrWhiteSpace( ruaId ) ) { throw new ArgumentException( "ID da rua inválido.", nameof(ruaId) ); } var correspondencias = estado .DocumentoTrabalho! .Ruas .Where( x => string.Equals( x.Id, ruaId.Trim(), StringComparison .OrdinalIgnoreCase ) ) .ToList(); if (correspondencias.Count == 0) { throw new InvalidOperationException( $"Rua '{ruaId}' não pertence " + "ao documento em edição." ); } if (correspondencias.Count > 1) { throw new InvalidOperationException( $"O ID '{ruaId}' está ambíguo " + "no documento em edição." ); } return correspondencias[0].Id; } private MapaEdicaoSnapshot CriarSnapshot(MapaEstadoEdicao estado) { ValidarEstadoAtivo(estado); return new MapaEdicaoSnapshot( _cloneService.Clonar(estado.DocumentoTrabalho!), estado.RuasSelecionadasMutaveis ); } private void RestaurarSnapshot(MapaEstadoEdicao estado, MapaEdicaoSnapshot snapshot) { ValidarEstadoAtivo(estado); if (snapshot == null) { throw new ArgumentNullException( nameof(snapshot) ); } estado.DocumentoTrabalho = _cloneService.Clonar(snapshot.Documento); estado.RuasSelecionadasMutaveis.Clear(); var idsValidos = estado .DocumentoTrabalho .Ruas .Select(x => x.Id) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (string ruaId in snapshot.RuasSelecionadas) { if (idsValidos.Contains(ruaId)) { estado.RuasSelecionadasMutaveis.Add(ruaId); } } } private static void RegistrarAlteracao(MapaEstadoEdicao estado, string descricao, MapaEdicaoSnapshot antes, MapaEdicaoSnapshot depois) { var item = new MapaEdicaoHistoricoItem( descricao, antes, depois ); estado .HistoricoDesfazerMutavel .Add( item ); /* * Uma edição nova depois de um Undo * abre uma nova linha temporal. * * O Redo anterior deixa de fazer sentido. */ estado .HistoricoRefazerMutavel .Clear(); while ( estado .HistoricoDesfazerMutavel .Count > LimiteHistorico ) { estado .HistoricoDesfazerMutavel .RemoveAt( 0 ); } } public bool PodeDesfazer(MapaEstadoEdicao estado) { ValidarEstado( estado ); return estado.EmEdicao && estado.PodeDesfazer; } public bool PodeRefazer(MapaEstadoEdicao estado) { ValidarEstado( estado ); return estado.EmEdicao && estado.PodeRefazer; } public bool Desfazer(MapaEstadoEdicao estado) { ValidarEstadoAtivo( estado ); if (!estado.PodeDesfazer) return false; int indice = estado .HistoricoDesfazerMutavel .Count - 1; var item = estado .HistoricoDesfazerMutavel[ indice ]; estado .HistoricoDesfazerMutavel .RemoveAt( indice ); RestaurarSnapshot( estado, item.Antes ); estado .HistoricoRefazerMutavel .Add( item ); return true; } public bool Refazer(MapaEstadoEdicao estado) { ValidarEstadoAtivo( estado ); if (!estado.PodeRefazer) return false; int indice = estado .HistoricoRefazerMutavel .Count - 1; var item = estado .HistoricoRefazerMutavel[ indice ]; estado .HistoricoRefazerMutavel .RemoveAt( indice ); RestaurarSnapshot( estado, item.Depois ); estado .HistoricoDesfazerMutavel .Add( item ); return true; } private static void ValidarEstado( MapaEstadoEdicao estado) { if (estado == null) { throw new ArgumentNullException( nameof(estado) ); } } private static void ValidarEstadoAtivo( MapaEstadoEdicao estado) { ValidarEstado( estado ); if ( estado.DocumentoOriginal == null || estado.DocumentoTrabalho == null ) { throw new InvalidOperationException( "Não existe uma sessão de edição ativa." ); } } public IReadOnlyList SelecionarPorRetangulo( MapaEstadoEdicao estado, double minLatitude, double minLongitude, double maxLatitude, double maxLongitude, bool substituirSelecao = true) { if (estado == null) { throw new ArgumentNullException( nameof(estado) ); } if ( !estado.EmEdicao || estado.DocumentoTrabalho == null ) { throw new InvalidOperationException( "Não existe uma sessão de edição ativa." ); } double minLat = Math.Min( minLatitude, maxLatitude ); double maxLat = Math.Max( minLatitude, maxLatitude ); double minLon = Math.Min( minLongitude, maxLongitude ); double maxLon = Math.Max( minLongitude, maxLongitude ); if ( minLat < -90 || maxLat > 90 || minLon < -180 || maxLon > 180 ) { throw new ArgumentOutOfRangeException( "Retângulo WGS84 inválido." ); } var envelope = new Envelope( minLon, maxLon, minLat, maxLat ); /* * Documento canônico é WGS84. */ var geometryFactory = new GeometryFactory( new PrecisionModel(), 4326 ); Geometry area = geometryFactory .ToGeometry( envelope ); var encontrados = new List(); foreach ( var rua in estado .DocumentoTrabalho .Ruas) { if ( rua == null || rua.Geometria == null || rua.Geometria.IsEmpty ) { continue; } /* * Filtro barato primeiro. */ if ( !rua.Geometria .EnvelopeInternal .Intersects( envelope ) ) { continue; } /* * Teste real. * * Não basta o bounding box da rua * tocar o retângulo. */ if ( !rua.Geometria .Intersects( area ) ) { continue; } encontrados.Add( rua.Id ); } if (substituirSelecao) { LimparSelecao(estado); } foreach (string ruaId in encontrados) { SelecionarRua(estado, ruaId); } return encontrados; } private static bool DocumentosEquivalentes(MapaDocumento atual, MapaDocumento fonte) { if ( atual == null || fonte == null ) { return false; } /* * Modificado NÃO entra nessa comparação. * * Queremos saber se o conteúdo canônico * é equivalente, não o estado da sessão. */ if ( !string.Equals( atual.Nome, fonte.Nome, StringComparison.Ordinal ) || !string.Equals( atual.CaminhoOrigem, fonte.CaminhoOrigem, StringComparison.Ordinal ) || atual.FormatoOrigem != fonte.FormatoOrigem || atual.Tipo != fonte.Tipo ) { return false; } if ( atual.Ruas.Count != fonte.Ruas.Count ) { return false; } for ( int i = 0; i < atual.Ruas.Count; i++) { MapaRua a = atual.Ruas[i]; MapaRua b = fonte.Ruas[i]; if ( !string.Equals( a.Id, b.Id, StringComparison.Ordinal ) || !string.Equals( a.IdOriginal, b.IdOriginal, StringComparison.Ordinal ) || a.IdGeradoAutomaticamente != b.IdGeradoAutomaticamente || !string.Equals( a.Nome, b.Nome, StringComparison.Ordinal ) || Math.Abs( a.ComprimentoMetros - b.ComprimentoMetros ) > 0.000001 || Math.Abs( a.Dist1 - b.Dist1 ) > 0.000001 || Math.Abs( a.Dist2 - b.Dist2 ) > 0.000001 || a.IndiceFeatureOrigem != b.IndiceFeatureOrigem ) { return false; } bool geometriaANula = a.Geometria == null; bool geometriaBNula = b.Geometria == null; if ( geometriaANula != geometriaBNula ) { return false; } if ( !geometriaANula && !a.Geometria.EqualsExact( b.Geometria ) ) { return false; } } return true; } } }