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 end