agrobot_base/AgroBase/OperationControl/Mapas/Services/MapaImportService.cs

1517 lines
40 KiB
C#

using NetTopologySuite.Features;
using NetTopologySuite.Geometries;
using NetTopologySuite.IO;
using OperationControl.Mapas.Models;
using ProjNet.CoordinateSystems;
using ProjNet.CoordinateSystems.Transformations;
using System.Globalization;
using System.IO;
using static AgroBase.Models.Enums;
namespace OperationControl.Mapas.Services
{
public class MapaImportService
{
private readonly GeometryFactory _geometryFactoryWgs84 =
new GeometryFactory(
new PrecisionModel(),
4326
);
private sealed class FeatureOrigem
{
public int Indice { get; set; }
public Geometry Geometria { get; set; } = null!;
public Dictionary<string, object?> Atributos { get; set; }
= new(StringComparer.OrdinalIgnoreCase);
}
private sealed class FeaturePreparada
{
public FeatureOrigem Origem { get; set; } = null!;
public string? IdOriginal { get; set; }
public List<LineString> Linhas { get; set; } = new();
}
public MapaDocumento Importar(
string caminhoArquivo,
TipoMapaOperacao tipo)
{
if (string.IsNullOrWhiteSpace(caminhoArquivo))
{
throw new ArgumentException(
"O caminho do arquivo de mapa não foi informado.",
nameof(caminhoArquivo)
);
}
if (!File.Exists(caminhoArquivo))
{
throw new FileNotFoundException(
"O arquivo de mapa não foi encontrado.",
caminhoArquivo
);
}
string extensao =
Path.GetExtension(caminhoArquivo)
.ToLowerInvariant();
var avisos = new List<string>();
MapaDocumento documento;
switch (extensao)
{
case ".json":
case ".geojson":
documento = ImportarGeoJson(
caminhoArquivo,
tipo,
avisos
);
break;
case ".shp":
documento = ImportarShapefile(
caminhoArquivo,
tipo,
avisos
);
break;
default:
throw new InvalidOperationException(
$"Formato de mapa não suportado: '{extensao}'."
);
}
if (!documento.PossuiRuas)
{
throw new InvalidOperationException(
"O arquivo não produziu nenhuma rua válida."
);
}
documento.AvisosImportacao = avisos;
return documento;
}
#region GEOJSON
private MapaDocumento ImportarGeoJson(
string caminhoArquivo,
TipoMapaOperacao tipo,
List<string> avisos)
{
string json =
File.ReadAllText(
caminhoArquivo,
System.Text.Encoding.UTF8
);
if (string.IsNullOrWhiteSpace(json))
{
throw new InvalidOperationException(
"O arquivo GeoJSON está vazio."
);
}
FeatureCollection featureCollection;
try
{
var reader = new GeoJsonReader();
featureCollection =
reader.Read<FeatureCollection>(json);
}
catch (Exception ex)
{
throw new InvalidOperationException(
"Não foi possível interpretar o arquivo como GeoJSON.",
ex
);
}
if (featureCollection == null ||
featureCollection.Count == 0)
{
throw new InvalidOperationException(
"O GeoJSON não possui features."
);
}
var features = new List<FeatureOrigem>();
int indice = 0;
foreach (var feature in featureCollection)
{
indice++;
if (feature?.Geometry == null ||
feature.Geometry.IsEmpty)
{
continue;
}
features.Add(
new FeatureOrigem
{
Indice = indice,
Geometria = feature.Geometry,
Atributos =
CopiarAtributos(
feature.Attributes
)
}
);
}
if (features.Count == 0)
{
throw new InvalidOperationException(
"O GeoJSON não possui geometrias válidas."
);
}
/*
* GeoJSON operacional é assumido como WGS84.
*
* Não tentamos adivinhar projeções em arquivos GeoJSON.
* Se as coordenadas estiverem fora dos limites físicos
* de longitude/latitude, a importação será rejeitada.
*/
return ConstruirDocumento(
caminhoArquivo,
tipo,
MapaFormatoOrigem.GeoJson,
features,
(x, y) => (x, y),
avisos
);
}
#endregion
#region SHAPEFILE
private MapaDocumento ImportarShapefile(
string caminhoArquivo,
TipoMapaOperacao tipo,
List<string> avisos)
{
ValidarArquivosShapefile(
caminhoArquivo
);
var features =
LerFeaturesShapefile(
caminhoArquivo
);
if (features.Count == 0)
{
throw new InvalidOperationException(
"O Shapefile não possui features válidas."
);
}
var transformacao =
CriarTransformacaoShapefileParaWgs84(
caminhoArquivo,
features,
avisos
);
return ConstruirDocumento(
caminhoArquivo,
tipo,
MapaFormatoOrigem.Shapefile,
features,
transformacao,
avisos
);
}
private List<FeatureOrigem> LerFeaturesShapefile(
string caminhoShp)
{
var resultado =
new List<FeatureOrigem>();
var geometryFactory =
new GeometryFactory(
new PrecisionModel(),
0
);
using var reader =
new ShapefileDataReader(
caminhoShp,
geometryFactory
);
var header = reader.DbaseHeader;
int indice = 0;
while (reader.Read())
{
indice++;
Geometry? geometria =
reader.Geometry;
if (geometria == null ||
geometria.IsEmpty)
{
continue;
}
var atributos =
new Dictionary<string, object?>(
StringComparer.OrdinalIgnoreCase
);
for (int i = 0;
i < header.NumFields;
i++)
{
var field =
header.Fields[i];
object? valor = null;
/*
* Dependendo da implementação do reader,
* a geometria pode ocupar o ordinal zero.
*/
try
{
valor =
reader.GetValue(i + 1);
}
catch
{
try
{
valor =
reader.GetValue(i);
}
catch
{
valor = null;
}
}
if (valor is DBNull)
valor = null;
atributos[field.Name] =
valor;
}
resultado.Add(
new FeatureOrigem
{
Indice = indice,
Geometria = geometria,
Atributos = atributos
}
);
}
return resultado;
}
private static void ValidarArquivosShapefile(
string caminhoShp)
{
string caminhoDbf =
Path.ChangeExtension(
caminhoShp,
".dbf"
);
if (!File.Exists(caminhoDbf))
{
throw new FileNotFoundException(
"O arquivo .dbf correspondente ao Shapefile não foi encontrado.",
caminhoDbf
);
}
}
private Func<double, double, (double lon, double lat)>
CriarTransformacaoShapefileParaWgs84(
string caminhoShp,
IEnumerable<FeatureOrigem> features,
List<string> avisos)
{
string caminhoPrj =
Path.ChangeExtension(
caminhoShp,
".prj"
);
bool pareceGeografico =
CoordenadasParecemLongitudeLatitude(
features
);
/*
* =====================================================
* SHP SEM PRJ
* =====================================================
*/
if (!File.Exists(caminhoPrj))
{
if (pareceGeografico)
{
avisos.Add(
"O Shapefile não possui arquivo .prj. " +
"Como as coordenadas já parecem longitude/latitude, " +
"o mapa foi assumido como WGS84."
);
return (x, y) => (x, y);
}
throw new InvalidOperationException(
"O Shapefile não possui arquivo .prj e as " +
"coordenadas não parecem estar em longitude/latitude. " +
"Não é possível determinar a projeção com segurança."
);
}
string wkt =
File.ReadAllText(
caminhoPrj,
System.Text.Encoding.UTF8
);
/*
* =====================================================
* PRJ VAZIO
* =====================================================
*/
if (string.IsNullOrWhiteSpace(wkt))
{
if (pareceGeografico)
{
avisos.Add(
"O arquivo .prj está vazio. " +
"Como as coordenadas parecem longitude/latitude, " +
"o mapa foi assumido como WGS84."
);
return (x, y) => (x, y);
}
throw new InvalidOperationException(
"O arquivo .prj está vazio e as coordenadas " +
"não parecem estar em longitude/latitude."
);
}
/*
* =====================================================
* PRJ NORMAL
* =====================================================
*/
try
{
var fabricaSistemas =
new CoordinateSystemFactory();
var sistemaOrigem =
fabricaSistemas.CreateFromWkt(
wkt
);
if (sistemaOrigem == null)
{
throw new InvalidOperationException(
"O sistema de coordenadas do .prj não pôde ser criado."
);
}
var sistemaDestino =
GeographicCoordinateSystem.WGS84;
var fabricaTransformacao =
new CoordinateTransformationFactory();
var transformacao =
fabricaTransformacao
.CreateFromCoordinateSystems(
sistemaOrigem,
sistemaDestino
);
if (transformacao?.MathTransform == null)
{
throw new InvalidOperationException(
"Não foi possível criar a transformação para WGS84."
);
}
return (x, y) =>
{
double[] convertido =
transformacao
.MathTransform
.Transform(
new[] { x, y }
);
return (
convertido[0],
convertido[1]
);
};
}
catch (Exception ex)
{
/*
* Caso que vocês já encontraram em campo:
*
* PRJ existe, está quebrado, mas o SHP já possui
* coordenadas aparentemente geográficas.
*/
if (pareceGeografico)
{
avisos.Add(
"O arquivo .prj não pôde ser interpretado. " +
"Como as coordenadas já parecem longitude/latitude, " +
"o PRJ foi ignorado. " +
$"Motivo: {ex.Message}"
);
return (x, y) => (x, y);
}
throw new InvalidOperationException(
"O arquivo .prj do Shapefile não pôde ser interpretado " +
"e as coordenadas não parecem estar em longitude/latitude. " +
"Não é possível determinar a projeção com segurança.",
ex
);
}
}
private static bool CoordenadasParecemLongitudeLatitude(
IEnumerable<FeatureOrigem> features)
{
int quantidade = 0;
foreach (var feature in features)
{
if (feature?.Geometria == null ||
feature.Geometria.IsEmpty)
{
continue;
}
foreach (
Coordinate coordenada
in feature.Geometria.Coordinates
)
{
double x = coordenada.X;
double y = coordenada.Y;
if (!double.IsFinite(x) ||
!double.IsFinite(y))
{
return false;
}
if (x < -180.0 ||
x > 180.0 ||
y < -90.0 ||
y > 90.0)
{
return false;
}
quantidade++;
}
}
return quantidade > 0;
}
#endregion
#region CONSTRUCAO DO DOCUMENTO
private MapaDocumento ConstruirDocumento(
string caminhoArquivo,
TipoMapaOperacao tipo,
MapaFormatoOrigem formato,
IEnumerable<FeatureOrigem> features,
Func<double, double, (double lon, double lat)> transformar,
List<string> avisos)
{
var documento =
new MapaDocumento
{
Nome =
Path.GetFileNameWithoutExtension(
caminhoArquivo
),
CaminhoOrigem =
Path.GetFullPath(
caminhoArquivo
),
FormatoOrigem = formato,
Tipo = tipo,
Modificado = false
};
/*
* ============================================================
* 1. PREPARA AS FEATURES ANTES DE ATRIBUIR IDs
* ============================================================
*
* Esse passo é proposital.
*
* Primeiro precisamos conhecer TODOS os IDs existentes no mapa.
* Só depois podemos começar a gerar IDs automáticos.
*
* Assim evitamos este caso:
*
* Feature 1 -> sem ID
* Feature 2 -> ID original "1"
*
* O gerador NÃO pode entregar "1" para a Feature 1 antes
* de descobrir que esse ID pertence originalmente à Feature 2.
*/
var featuresPreparadas =
new List<FeaturePreparada>();
foreach (var feature in features)
{
if (feature?.Geometria == null ||
feature.Geometria.IsEmpty)
{
continue;
}
string? idOriginal =
NormalizarIdOriginal(
ObterAtributoTexto(
feature.Atributos,
"Id",
"ID",
"id",
"OBJECTID",
"ObjectID",
"FID"
)
);
var linhas =
ExtrairLineStrings(
feature.Geometria
)
.Where(x =>
x != null &&
!x.IsEmpty &&
x.NumPoints >= 2
)
.ToList();
if (linhas.Count == 0)
{
continue;
}
featuresPreparadas.Add(
new FeaturePreparada
{
Origem = feature,
IdOriginal = idOriginal,
Linhas = linhas
}
);
}
if (featuresPreparadas.Count == 0)
{
throw new InvalidOperationException(
"O mapa não contém nenhuma LineString navegável."
);
}
/*
* ============================================================
* 2. CONTA AS OCORRÊNCIAS DOS IDs ORIGINAIS
* ============================================================
*
* Somente IDs que aparecem em UMA feature podem ser
* preservados como IDs canônicos.
*/
var ocorrenciasIdsOriginais =
featuresPreparadas
.Where(x =>
!string.IsNullOrWhiteSpace(
x.IdOriginal
)
)
.GroupBy(
x => x.IdOriginal!,
StringComparer.OrdinalIgnoreCase
)
.ToDictionary(
grupo => grupo.Key,
grupo => grupo.Count(),
StringComparer.OrdinalIgnoreCase
);
/*
* ============================================================
* 3. RESERVA TODOS OS IDs EXISTENTES NO ARQUIVO
* ============================================================
*
* Mesmo um ID duplicado fica reservado.
*
* Exemplo:
*
* original:
* 5
* 5
* sem ID
*
* O gerador nunca poderá entregar "5" para a terceira rua.
*/
var idsOriginaisReservados =
new HashSet<string>(
ocorrenciasIdsOriginais.Keys,
StringComparer.OrdinalIgnoreCase
);
/*
* IDs canônicos já efetivamente atribuídos às ruas.
*/
var idsUsados =
new HashSet<string>(
StringComparer.OrdinalIgnoreCase
);
int proximoIdNumerico = 1;
int quantidadeIdsGerados = 0;
int idsGeradosSemOriginal = 0;
int idsGeradosPorDuplicidade = 0;
int idsGeradosPorMultipart = 0;
/*
* ============================================================
* 4. CONSTRÓI AS RUAS
* ============================================================
*/
foreach (var preparada in featuresPreparadas)
{
FeatureOrigem feature =
preparada.Origem;
bool possuiIdOriginal =
!string.IsNullOrWhiteSpace(
preparada.IdOriginal
);
bool idOriginalEhUnico =
possuiIdOriginal &&
ocorrenciasIdsOriginais.TryGetValue(
preparada.IdOriginal!,
out int ocorrencias
) &&
ocorrencias == 1;
/*
* Uma Feature pode ser MultiLineString.
*
* Nesse caso, mesmo com ID original único, apenas a
* primeira LineString pode herdar esse ID.
*
* As demais precisam de IDs próprios.
*/
bool idOriginalConsumido = false;
foreach (
LineString linhaOrigem
in preparada.Linhas
)
{
LineString linhaWgs84 =
TransformarLinhaParaWgs84(
linhaOrigem,
transformar,
feature.Indice
);
string id;
bool idGerado;
/*
* ====================================================
* PRESERVA ID ORIGINAL VÁLIDO E ÚNICO
* ====================================================
*/
if (
idOriginalEhUnico &&
!idOriginalConsumido
)
{
id =
preparada.IdOriginal!;
/*
* Isso teoricamente nunca deve falhar,
* pois já verificamos unicidade.
*
* Mesmo assim deixamos uma guarda forte.
*/
if (!idsUsados.Add(id))
{
throw new InvalidOperationException(
$"Falha interna ao reservar o ID original '{id}'. " +
"O ID já havia sido atribuído a outra rua."
);
}
idGerado = false;
idOriginalConsumido = true;
}
else
{
/*
* =================================================
* GERA ID NUMÉRICO
* =================================================
*
* O gerador evita:
*
* - IDs já utilizados;
* - TODOS os IDs existentes originalmente no mapa.
*/
id =
GerarIdNumericoUnico(
idsUsados,
idsOriginaisReservados,
ref proximoIdNumerico
);
idGerado = true;
quantidadeIdsGerados++;
if (!possuiIdOriginal)
{
idsGeradosSemOriginal++;
}
else if (!idOriginalEhUnico)
{
idsGeradosPorDuplicidade++;
}
else
{
/*
* ID original era único, mas já foi usado
* pela primeira parte de uma MultiLineString.
*/
idsGeradosPorMultipart++;
}
}
string nome =
ObterAtributoTexto(
feature.Atributos,
"Name",
"NAME",
"Nome",
"NOME",
"LAYER"
)
?? $"Rua {id}";
double dist1 =
ObterAtributoDouble(
feature.Atributos,
"Dist1",
"DIST1"
);
double dist2 =
ObterAtributoDouble(
feature.Atributos,
"Dist2",
"DIST2"
);
double comprimento =
CalcularComprimentoMetros(
linhaWgs84
);
documento.Ruas.Add(
new MapaRua
{
Id = id,
IdOriginal =
preparada.IdOriginal,
IdGeradoAutomaticamente =
idGerado,
Nome = nome,
Geometria =
linhaWgs84,
ComprimentoMetros =
comprimento,
Dist1 =
dist1,
Dist2 =
dist2,
IndiceFeatureOrigem =
feature.Indice
}
);
}
}
/*
* ============================================================
* 5. REGISTRA DIAGNÓSTICO
* ============================================================
*/
if (quantidadeIdsGerados > 0)
{
var motivos =
new List<string>();
if (idsGeradosSemOriginal > 0)
{
motivos.Add(
$"sem ID original: {idsGeradosSemOriginal}"
);
}
if (idsGeradosPorDuplicidade > 0)
{
motivos.Add(
$"ID original duplicado: {idsGeradosPorDuplicidade}"
);
}
if (idsGeradosPorMultipart > 0)
{
motivos.Add(
$"partes adicionais de geometria multipart: {idsGeradosPorMultipart}"
);
}
string detalhe =
motivos.Count > 0
? $" ({string.Join(", ", motivos)})"
: string.Empty;
avisos.Add(
$"{quantidadeIdsGerados} rua(s) receberam " +
"IDs numéricos automaticamente" +
detalhe +
"."
);
}
if (documento.Ruas.Count == 0)
{
throw new InvalidOperationException(
"O mapa não contém nenhuma LineString navegável."
);
}
return documento;
}
private LineString TransformarLinhaParaWgs84(
LineString linha,
Func<double, double, (double lon, double lat)> transformar,
int indiceFeatureOrigem)
{
var coordenadas =
new List<Coordinate>();
foreach (
Coordinate coordenada
in linha.Coordinates
)
{
var convertido =
transformar(
coordenada.X,
coordenada.Y
);
ValidarCoordenadaWgs84(
convertido.lon,
convertido.lat,
indiceFeatureOrigem
);
coordenadas.Add(
new Coordinate(
convertido.lon,
convertido.lat
)
);
}
if (coordenadas.Count < 2)
{
throw new InvalidOperationException(
$"A feature {indiceFeatureOrigem} não possui " +
"pontos suficientes após a transformação."
);
}
return _geometryFactoryWgs84
.CreateLineString(
coordenadas.ToArray()
);
}
private static void ValidarCoordenadaWgs84(
double longitude,
double latitude,
int indiceFeatureOrigem)
{
bool valida =
double.IsFinite(longitude) &&
double.IsFinite(latitude) &&
longitude >= -180.0 &&
longitude <= 180.0 &&
latitude >= -90.0 &&
latitude <= 90.0;
if (!valida)
{
throw new InvalidOperationException(
$"A feature {indiceFeatureOrigem} produziu uma " +
"coordenada WGS84 inválida: " +
$"longitude={longitude}, latitude={latitude}."
);
}
}
#endregion
#region GEOMETRIAS
private static IEnumerable<LineString>
ExtrairLineStrings(
Geometry geometria)
{
if (geometria == null ||
geometria.IsEmpty)
{
yield break;
}
if (geometria is LineString linha)
{
yield return linha;
yield break;
}
if (geometria is MultiLineString multiLinha)
{
for (
int i = 0;
i < multiLinha.NumGeometries;
i++)
{
Geometry parte =
multiLinha.GetGeometryN(i);
foreach (
var linhaInterna
in ExtrairLineStrings(parte)
)
{
yield return linhaInterna;
}
}
yield break;
}
if (geometria is GeometryCollection colecao)
{
for (
int i = 0;
i < colecao.NumGeometries;
i++)
{
Geometry parte =
colecao.GetGeometryN(i);
foreach (
var linhaInterna
in ExtrairLineStrings(parte)
)
{
yield return linhaInterna;
}
}
yield break;
}
throw new InvalidOperationException(
"O mapa contém uma geometria não suportada: " +
$"{geometria.GeometryType}. " +
"São aceitas somente geometrias lineares."
);
}
#endregion
#region IDS
private static string? NormalizarIdOriginal(string? idOriginal)
{
if (string.IsNullOrWhiteSpace(idOriginal))
{
return null;
}
return idOriginal.Trim();
}
private static string GerarIdNumericoUnico(
HashSet<string> idsUsados,
HashSet<string> idsOriginaisReservados,
ref int proximoIdNumerico)
{
while (true)
{
string candidato =
proximoIdNumerico.ToString(
CultureInfo.InvariantCulture
);
proximoIdNumerico++;
/*
* Não podemos usar um ID que:
*
* 1. já foi atribuído;
* 2. existia originalmente em qualquer ponto do mapa.
*/
if (idsUsados.Contains(candidato))
{
continue;
}
if (idsOriginaisReservados.Contains(candidato))
{
continue;
}
idsUsados.Add(candidato);
return candidato;
}
}
#endregion
#region ATRIBUTOS
private static Dictionary<string, object?>
CopiarAtributos(
IAttributesTable? atributos)
{
var resultado =
new Dictionary<string, object?>(
StringComparer.OrdinalIgnoreCase
);
if (atributos == null)
{
return resultado;
}
foreach (
string nome
in atributos.GetNames()
)
{
try
{
object? valor =
atributos[nome];
if (valor is DBNull)
valor = null;
resultado[nome] =
valor;
}
catch
{
/*
* Um atributo estranho não deve impedir
* a leitura da geometria.
*/
}
}
return resultado;
}
private static object?
ObterAtributo(
IReadOnlyDictionary<string, object?> atributos,
params string[] nomes)
{
if (atributos == null)
return null;
foreach (string nome in nomes)
{
if (!atributos.TryGetValue(
nome,
out object? valor))
{
continue;
}
if (valor == null ||
valor is DBNull)
{
continue;
}
return valor;
}
return null;
}
private static string?
ObterAtributoTexto(
IReadOnlyDictionary<string, object?> atributos,
params string[] nomes)
{
object? valor =
ObterAtributo(
atributos,
nomes
);
if (valor == null)
return null;
string? texto =
Convert.ToString(
valor,
CultureInfo.InvariantCulture
);
return string.IsNullOrWhiteSpace(texto)
? null
: texto.Trim();
}
private static double
ObterAtributoDouble(
IReadOnlyDictionary<string, object?> atributos,
params string[] nomes)
{
object? valor =
ObterAtributo(
atributos,
nomes
);
if (valor == null)
return 0.0;
try
{
return Convert.ToDouble(
valor,
CultureInfo.InvariantCulture
);
}
catch
{
string? texto =
valor.ToString();
if (string.IsNullOrWhiteSpace(texto))
return 0.0;
if (double.TryParse(
texto,
NumberStyles.Any,
CultureInfo.InvariantCulture,
out double resultado))
{
return resultado;
}
if (double.TryParse(
texto,
NumberStyles.Any,
CultureInfo.GetCultureInfo("pt-BR"),
out resultado))
{
return resultado;
}
}
return 0.0;
}
#endregion
#region DISTANCIA
private static double CalcularComprimentoMetros(
LineString linha)
{
if (linha == null ||
linha.NumPoints < 2)
{
return 0.0;
}
double distancia = 0.0;
Coordinate[] coordenadas =
linha.Coordinates;
for (
int i = 1;
i < coordenadas.Length;
i++)
{
Coordinate anterior =
coordenadas[i - 1];
Coordinate atual =
coordenadas[i];
distancia +=
CalcularDistanciaHaversine(
anterior.Y,
anterior.X,
atual.Y,
atual.X
);
}
return distancia;
}
private static double CalcularDistanciaHaversine(
double latitude1,
double longitude1,
double latitude2,
double longitude2)
{
const double raioTerraMetros =
6371000.0;
static double ParaRadianos(
double graus)
{
return graus *
Math.PI /
180.0;
}
double dLat =
ParaRadianos(
latitude2 -
latitude1
);
double dLon =
ParaRadianos(
longitude2 -
longitude1
);
double lat1 =
ParaRadianos(
latitude1
);
double lat2 =
ParaRadianos(
latitude2
);
double a =
Math.Sin(dLat / 2.0) *
Math.Sin(dLat / 2.0) +
Math.Cos(lat1) *
Math.Cos(lat2) *
Math.Sin(dLon / 2.0) *
Math.Sin(dLon / 2.0);
double c =
2.0 *
Math.Atan2(
Math.Sqrt(a),
Math.Sqrt(1.0 - a)
);
return raioTerraMetros * c;
}
#endregion
}
}