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( <> ) 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 <> = 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 <> = 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( <>, 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 <> = data indices = parse_indices(index_data, triangle_count, []) {:ok, Enum.reverse(indices)} end end defp parse_indices(<<>>, 0, acc), do: acc defp parse_indices( <>, 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