336 lines
9.0 KiB
Elixir
336 lines
9.0 KiB
Elixir
defmodule MoundHunters.MoundParser do
|
|
@moduledoc """
|
|
Parser for .mound binary lidar files.
|
|
|
|
Supports:
|
|
- Header-only parsing
|
|
- Full file parsing (header + vertices + indices)
|
|
- Batch header parsing for directories
|
|
"""
|
|
|
|
defmodule Header do
|
|
@moduledoc "Represents a .mound file header"
|
|
|
|
defstruct [
|
|
:magic,
|
|
:version,
|
|
:point_count,
|
|
:triangle_count,
|
|
:min_x,
|
|
:min_y,
|
|
:min_z,
|
|
:max_x,
|
|
:max_y,
|
|
:max_z
|
|
]
|
|
|
|
@type t :: %__MODULE__{
|
|
magic: binary(),
|
|
version: non_neg_integer(),
|
|
point_count: non_neg_integer(),
|
|
triangle_count: non_neg_integer(),
|
|
min_x: float(),
|
|
min_y: float(),
|
|
min_z: float(),
|
|
max_x: float(),
|
|
max_y: float(),
|
|
max_z: float()
|
|
}
|
|
end
|
|
|
|
defmodule MoundFile do
|
|
@moduledoc "Represents a complete parsed .mound file"
|
|
|
|
defstruct [
|
|
:header,
|
|
:vertices,
|
|
:indices
|
|
]
|
|
|
|
@type vertex :: {float(), float(), float()}
|
|
@type triangle :: {non_neg_integer(), non_neg_integer(), non_neg_integer()}
|
|
|
|
@type t :: %__MODULE__{
|
|
header: Header.t(),
|
|
vertices: [vertex()],
|
|
indices: [triangle()]
|
|
}
|
|
end
|
|
|
|
@header_size 64
|
|
@magic "LIDR"
|
|
|
|
@doc """
|
|
Parse only the header of a .mound file.
|
|
|
|
## Examples
|
|
|
|
iex> MoundHunters.MoundParser.parse_header("path/to/file.mound")
|
|
{:ok, %MoundParser.Header{...}}
|
|
|
|
iex> MoundHunters.MoundParser.parse_header("nonexistent.mound")
|
|
{:error, :enoent}
|
|
"""
|
|
@spec parse_header(Path.t()) :: {:ok, Header.t()} | {:error, term()}
|
|
def parse_header(path) do
|
|
with {:ok, file} <- File.open(path, [:read, :binary]),
|
|
{:ok, header_data} <- :file.pread(file, 0, @header_size),
|
|
:ok <- File.close(file),
|
|
{:ok, header} <- decode_header(header_data) do
|
|
{:ok, header}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Parse a complete .mound file including header, vertices, and indices.
|
|
|
|
## Examples
|
|
|
|
iex> MoundHunters.MoundParser.parse_file("path/to/file.mound")
|
|
{:ok, %MoundParser.MoundFile{...}}
|
|
"""
|
|
@spec parse_file(Path.t()) :: {:ok, MoundFile.t()} | {:error, term()}
|
|
def parse_file(path) do
|
|
with {:ok, data} <- File.read(path),
|
|
{:ok, mound_file} <- decode_file(data) do
|
|
{:ok, mound_file}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Parse headers for all .mound files in a directory.
|
|
|
|
Returns a list of tuples with filename and header.
|
|
|
|
## Examples
|
|
|
|
iex> MoundHunters.MoundParser.parse_directory_headers("tiles/")
|
|
{:ok, [
|
|
{"tile_001.mound", %MoundParser.Header{...}},
|
|
{"tile_002.mound", %MoundParser.Header{...}}
|
|
]}
|
|
"""
|
|
@spec parse_directory_headers(Path.t()) :: {:ok, [{String.t(), Header.t()}]} | {:error, term()}
|
|
def parse_directory_headers(dir_path) do
|
|
case File.ls(dir_path) do
|
|
{:ok, files} ->
|
|
results =
|
|
files
|
|
|> Enum.filter(&String.ends_with?(&1, ".mound"))
|
|
|> Enum.map(fn filename ->
|
|
full_path = Path.join(dir_path, filename)
|
|
|
|
case parse_header(full_path) do
|
|
{:ok, header} -> {:ok, {filename, header}}
|
|
{:error, reason} -> {:error, {filename, reason}}
|
|
end
|
|
end)
|
|
|
|
# Separate successes from failures
|
|
{successes, failures} =
|
|
Enum.split_with(results, fn
|
|
{:ok, _} -> true
|
|
{:error, _} -> false
|
|
end)
|
|
|
|
successes = Enum.map(successes, fn {:ok, result} -> result end)
|
|
|
|
case failures do
|
|
[] -> {:ok, successes}
|
|
_ -> {:ok, successes, Enum.map(failures, fn {:error, err} -> err end)}
|
|
end
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
# Private functions
|
|
|
|
defp decode_header(
|
|
<<magic::binary-size(4), version::little-unsigned-32, point_count::little-unsigned-32,
|
|
triangle_count::little-unsigned-32, min_x::little-float-32, min_y::little-float-32,
|
|
min_z::little-float-32, max_x::little-float-32, max_y::little-float-32,
|
|
max_z::little-float-32, _reserved::binary-size(24)>>
|
|
) do
|
|
if magic == @magic do
|
|
{:ok,
|
|
%Header{
|
|
magic: magic,
|
|
version: version,
|
|
point_count: point_count,
|
|
triangle_count: triangle_count,
|
|
min_x: min_x,
|
|
min_y: min_y,
|
|
min_z: min_z,
|
|
max_x: max_x,
|
|
max_y: max_y,
|
|
max_z: max_z
|
|
}}
|
|
else
|
|
{:error, {:invalid_magic, magic}}
|
|
end
|
|
end
|
|
|
|
defp decode_header(_), do: {:error, :invalid_header}
|
|
|
|
defp decode_file(data) when byte_size(data) < @header_size do
|
|
{:error, :file_too_small}
|
|
end
|
|
|
|
defp decode_file(data) do
|
|
<<header_data::binary-size(@header_size), rest::binary>> = data
|
|
|
|
with {:ok, header} <- decode_header(header_data),
|
|
{:ok, vertices, indices_data} <- decode_vertices(rest, header.point_count),
|
|
{:ok, indices} <- decode_indices(indices_data, header.triangle_count) do
|
|
{:ok,
|
|
%MoundFile{
|
|
header: header,
|
|
vertices: vertices,
|
|
indices: indices
|
|
}}
|
|
end
|
|
end
|
|
|
|
defp decode_vertices(data, point_count) do
|
|
vertex_size = point_count * 12
|
|
expected_size = point_count * 12
|
|
|
|
if byte_size(data) < expected_size do
|
|
{:error, :insufficient_vertex_data}
|
|
else
|
|
<<vertex_data::binary-size(vertex_size), rest::binary>> = data
|
|
vertices = parse_vertices(vertex_data, point_count, [])
|
|
{:ok, Enum.reverse(vertices), rest}
|
|
end
|
|
end
|
|
|
|
defp parse_vertices(<<>>, 0, acc), do: acc
|
|
|
|
defp parse_vertices(
|
|
<<x::little-float-32, y::little-float-32, z::little-float-32, rest::binary>>,
|
|
count,
|
|
acc
|
|
)
|
|
when count > 0 do
|
|
parse_vertices(rest, count - 1, [{x, y, z} | acc])
|
|
end
|
|
|
|
defp decode_indices(data, triangle_count) do
|
|
expected_size = triangle_count * 12
|
|
|
|
if byte_size(data) < expected_size do
|
|
{:error, :insufficient_index_data}
|
|
else
|
|
<<index_data::binary-size(expected_size), _rest::binary>> = data
|
|
indices = parse_indices(index_data, triangle_count, [])
|
|
{:ok, Enum.reverse(indices)}
|
|
end
|
|
end
|
|
|
|
defp parse_indices(<<>>, 0, acc), do: acc
|
|
|
|
defp parse_indices(
|
|
<<i1::little-unsigned-32, i2::little-unsigned-32, i3::little-unsigned-32, rest::binary>>,
|
|
count,
|
|
acc
|
|
)
|
|
when count > 0 do
|
|
parse_indices(rest, count - 1, [{i1, i2, i3} | acc])
|
|
end
|
|
|
|
@doc """
|
|
Get file size information without fully parsing.
|
|
|
|
Returns expected file size based on header counts.
|
|
"""
|
|
@spec expected_file_size(Header.t()) :: non_neg_integer()
|
|
def expected_file_size(%Header{point_count: pc, triangle_count: tc}) do
|
|
@header_size + pc * 12 + tc * 12
|
|
end
|
|
|
|
@doc """
|
|
Validate that a file has the correct size based on its header.
|
|
"""
|
|
@spec validate_file_size(Path.t()) :: {:ok, :valid} | {:error, term()}
|
|
def validate_file_size(path) do
|
|
with {:ok, %{size: actual_size}} <- File.stat(path),
|
|
{:ok, header} <- parse_header(path) do
|
|
expected = expected_file_size(header)
|
|
|
|
if actual_size == expected do
|
|
{:ok, :valid}
|
|
else
|
|
{:error, {:size_mismatch, expected: expected, actual: actual_size}}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Converts Web Mercator coordinates (EPSG:3857) to WGS84 lat/lng (EPSG:4326).
|
|
|
|
Web Mercator uses meters from origin, this converts back to decimal degrees.
|
|
|
|
## Examples
|
|
|
|
iex> MoundHunters.MoundParser.web_mercator_to_latlon(0, 0)
|
|
{0.0, 0.0}
|
|
|
|
iex> MoundHunters.MoundParser.web_mercator_to_latlon(1113194.91, 5009377.09)
|
|
{10.0, 40.0}
|
|
"""
|
|
@spec web_mercator_to_latlon(float(), float()) :: {lat :: float(), lon :: float()}
|
|
def web_mercator_to_latlon(x, y) do
|
|
# Web Mercator origin offset (meters)
|
|
origin_shift = :math.pi() * 6_378_137.0
|
|
|
|
# Convert x to longitude
|
|
lon = x / origin_shift * 180.0
|
|
|
|
# Convert y to latitude
|
|
lat = y / origin_shift * 180.0
|
|
|
|
lat =
|
|
180.0 / :math.pi() *
|
|
(2.0 * :math.atan(:math.exp(lat * :math.pi() / 180.0)) - :math.pi() / 2.0)
|
|
|
|
{lat, lon}
|
|
end
|
|
|
|
@doc """
|
|
Converts a bounding box from Web Mercator to lat/lng.
|
|
|
|
Returns a map with min/max lat/lng values.
|
|
|
|
## Examples
|
|
|
|
iex> MoundHunters.MoundParser.bounds_web_mercator_to_latlon(
|
|
...> %{min_x: -9_000_000, max_x: -8_900_000, min_y: 4_900_000, max_y: 5_000_000}
|
|
...> )
|
|
%{min_lat: ..., max_lat: ..., min_lng: ..., max_lng: ...}
|
|
"""
|
|
@spec bounds_web_mercator_to_latlon(%{
|
|
min_x: float(),
|
|
max_x: float(),
|
|
min_y: float(),
|
|
max_y: float()
|
|
}) :: %{
|
|
min_lat: float(),
|
|
max_lat: float(),
|
|
min_lng: float(),
|
|
max_lng: float()
|
|
}
|
|
def bounds_web_mercator_to_latlon(%{min_x: min_x, max_x: max_x, min_y: min_y, max_y: max_y}) do
|
|
{min_lat, min_lng} = web_mercator_to_latlon(min_x, min_y)
|
|
{max_lat, max_lng} = web_mercator_to_latlon(max_x, max_y)
|
|
|
|
%{
|
|
min_lat: min_lat,
|
|
max_lat: max_lat,
|
|
min_lng: min_lng,
|
|
max_lng: max_lng
|
|
}
|
|
end
|
|
end
|