move json to api, change tile loading api
This commit is contained in:
@@ -48,7 +48,7 @@ defmodule MoundHunters.Application do
|
|||||||
:ok ->
|
:ok ->
|
||||||
Logger.info("Created Mnesia schema")
|
Logger.info("Created Mnesia schema")
|
||||||
|
|
||||||
{:error, {_node1, {:already_exists, _node2}}} ->
|
{:error, {_, {:already_exists, _}}} ->
|
||||||
Logger.debug("Mnesia schema already exists")
|
Logger.debug("Mnesia schema already exists")
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
@@ -74,6 +74,8 @@ defmodule MoundHunters.Application do
|
|||||||
:max_lng,
|
:max_lng,
|
||||||
:status,
|
:status,
|
||||||
:error_message,
|
:error_message,
|
||||||
|
:jpg_available,
|
||||||
|
:png_available,
|
||||||
:created_at,
|
:created_at,
|
||||||
:updated_at
|
:updated_at
|
||||||
],
|
],
|
||||||
|
|||||||
269
lib/mound_hunters/mound_parser.ex
Normal file
269
lib/mound_hunters/mound_parser.ex
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
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
|
||||||
|
end
|
||||||
@@ -9,15 +9,52 @@ defmodule MoundHunters.Repo do
|
|||||||
@type tile_status :: :processing | :ready | :error
|
@type tile_status :: :processing | :ready | :error
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Get a tile by ID from Mnesia.
|
Get a tile by its ID.
|
||||||
|
Returns {:ok, tile} or {:error, :not_found}
|
||||||
"""
|
"""
|
||||||
def get_tile(tile_id) do
|
def get_tile(id) do
|
||||||
case :mnesia.transaction(fn ->
|
case :mnesia.transaction(fn ->
|
||||||
:mnesia.read(:tiles, tile_id)
|
:mnesia.read(:tiles, id)
|
||||||
end) do
|
end) do
|
||||||
{:atomic, [tile]} -> {:ok, tile_to_map(tile)}
|
{:atomic, [tile]} ->
|
||||||
{:atomic, []} -> {:error, :not_found}
|
{:ok, tile_to_map(tile)}
|
||||||
{:aborted, reason} -> {:error, reason}
|
|
||||||
|
{:atomic, []} ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
{:aborted, reason} ->
|
||||||
|
Logger.error("Failed to read tile #{id}: #{inspect(reason)}")
|
||||||
|
{:error, :database_error}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Get a tile that contains the given lat/lng coordinates.
|
||||||
|
Returns {:ok, tile} or {:error, :not_found}
|
||||||
|
|
||||||
|
If multiple tiles contain the point, returns the first match.
|
||||||
|
"""
|
||||||
|
def get_tile_at_coords(lat, lng) do
|
||||||
|
match_spec = [
|
||||||
|
{{:tiles, :"$1", :"$2", :"$3", :"$4", :"$5", :"$6", :"$7", :"$8", :"$9", :"$10", :"$11"},
|
||||||
|
[
|
||||||
|
{:andalso, {:"=<", :"$2", lat}, {:"=<", lat, :"$3"}},
|
||||||
|
{:andalso, {:"=<", :"$4", lng}, {:"=<", lng, :"$5"}}
|
||||||
|
], [:"$_"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
case :mnesia.transaction(fn ->
|
||||||
|
:mnesia.select(:tiles, match_spec)
|
||||||
|
end) do
|
||||||
|
{:atomic, [tile | _]} ->
|
||||||
|
{:ok, tile_to_map(tile)}
|
||||||
|
|
||||||
|
{:atomic, []} ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
{:aborted, reason} ->
|
||||||
|
Logger.error("Failed to query tiles at #{lat}, #{lng}: #{inspect(reason)}")
|
||||||
|
{:error, :database_error}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -31,7 +68,8 @@ defmodule MoundHunters.Repo do
|
|||||||
tile_record =
|
tile_record =
|
||||||
{:tiles, tile_id, Map.get(attrs, :min_lat), Map.get(attrs, :max_lat),
|
{:tiles, tile_id, Map.get(attrs, :min_lat), Map.get(attrs, :max_lat),
|
||||||
Map.get(attrs, :min_lng), Map.get(attrs, :max_lng), Map.get(attrs, :status),
|
Map.get(attrs, :min_lng), Map.get(attrs, :max_lng), Map.get(attrs, :status),
|
||||||
Map.get(attrs, :error_message), Map.get(attrs, :created_at, now), now}
|
Map.get(attrs, :error_message), Map.get(attrs, :jpg_available, false),
|
||||||
|
Map.get(attrs, :png_available, false), Map.get(attrs, :created_at, now), now}
|
||||||
|
|
||||||
case :mnesia.transaction(fn ->
|
case :mnesia.transaction(fn ->
|
||||||
:mnesia.write(tile_record)
|
:mnesia.write(tile_record)
|
||||||
@@ -73,8 +111,8 @@ defmodule MoundHunters.Repo do
|
|||||||
|
|
||||||
# Convert Mnesia tile record to map
|
# Convert Mnesia tile record to map
|
||||||
defp tile_to_map(
|
defp tile_to_map(
|
||||||
{:tiles, id, min_lat, max_lat, min_lng, max_lng, status, error_message, created_at,
|
{:tiles, id, min_lat, max_lat, min_lng, max_lng, status, error_message, jpg_available,
|
||||||
updated_at}
|
png_available, created_at, updated_at}
|
||||||
) do
|
) do
|
||||||
%{
|
%{
|
||||||
id: id,
|
id: id,
|
||||||
@@ -84,6 +122,8 @@ defmodule MoundHunters.Repo do
|
|||||||
max_lng: max_lng,
|
max_lng: max_lng,
|
||||||
status: status,
|
status: status,
|
||||||
error_message: error_message,
|
error_message: error_message,
|
||||||
|
jpg_available: jpg_available,
|
||||||
|
png_available: png_available,
|
||||||
created_at: created_at,
|
created_at: created_at,
|
||||||
updated_at: updated_at
|
updated_at: updated_at
|
||||||
}
|
}
|
||||||
@@ -98,6 +138,175 @@ defmodule MoundHunters.Repo do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Repopulate tiles table from .mound files in the configured directory.
|
||||||
|
Skips tiles that already exist in the database.
|
||||||
|
Returns {success_count, skip_count, error_count}.
|
||||||
|
"""
|
||||||
|
def repopulate_from_mounds do
|
||||||
|
tile_dir = Application.get_env(:mound_hunters, :tile_output_dir)
|
||||||
|
mound_dir = Path.join(tile_dir, "MOUND")
|
||||||
|
|
||||||
|
Logger.info("Scanning for .mound files in: #{mound_dir}")
|
||||||
|
|
||||||
|
case File.ls(mound_dir) do
|
||||||
|
{:ok, files} ->
|
||||||
|
results =
|
||||||
|
files
|
||||||
|
|> Enum.filter(&String.ends_with?(&1, ".mound"))
|
||||||
|
|> Enum.map(fn filename ->
|
||||||
|
# Extract tile_id from filename (e.g., "BS18921654.mound" -> "BS18921654")
|
||||||
|
tile_id = Path.rootname(filename)
|
||||||
|
full_path = Path.join(mound_dir, filename)
|
||||||
|
|
||||||
|
process_mound_file(tile_id, full_path)
|
||||||
|
end)
|
||||||
|
|
||||||
|
successes = Enum.count(results, &(&1 == :ok))
|
||||||
|
skips = Enum.count(results, &(&1 == :skipped))
|
||||||
|
errors = Enum.count(results, &match?({:error, _}, &1))
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
"Repopulation complete: #{successes} inserted, #{skips} skipped, #{errors} errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
{successes, skips, errors}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to list directory #{mound_dir}: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Scan tile directories and update jpg_available and png_available flags.
|
||||||
|
Returns {updated_count, not_found_count}.
|
||||||
|
"""
|
||||||
|
def update_tile_availability do
|
||||||
|
tile_dir = Application.get_env(:mound_hunters, :tile_output_dir)
|
||||||
|
mound_dir = Path.join(tile_dir, "MOUND")
|
||||||
|
|
||||||
|
Logger.info("Scanning tile availability in: #{mound_dir}")
|
||||||
|
|
||||||
|
# Get all tile IDs from database
|
||||||
|
case :mnesia.transaction(fn ->
|
||||||
|
:mnesia.match_object({:tiles, :_, :_, :_, :_, :_, :_, :_, :_, :_, :_, :_})
|
||||||
|
end) do
|
||||||
|
{:atomic, tiles} ->
|
||||||
|
results =
|
||||||
|
tiles
|
||||||
|
|> Enum.map(fn tile_record ->
|
||||||
|
tile = tile_to_map(tile_record)
|
||||||
|
tile_id = tile.id
|
||||||
|
|
||||||
|
jpg_path = Path.join(mound_dir, "#{tile_id}.jpg")
|
||||||
|
png_path = Path.join(mound_dir, "#{tile_id}.png")
|
||||||
|
|
||||||
|
jpg_available = File.exists?(jpg_path)
|
||||||
|
png_available = File.exists?(png_path)
|
||||||
|
|
||||||
|
# Only update if availability changed
|
||||||
|
if jpg_available != tile.jpg_available or png_available != tile.png_available do
|
||||||
|
attrs = %{
|
||||||
|
id: tile_id,
|
||||||
|
min_lat: tile.min_lat,
|
||||||
|
max_lat: tile.max_lat,
|
||||||
|
min_lng: tile.min_lng,
|
||||||
|
max_lng: tile.max_lng,
|
||||||
|
status: tile.status,
|
||||||
|
error_message: tile.error_message,
|
||||||
|
jpg_available: jpg_available,
|
||||||
|
png_available: png_available,
|
||||||
|
created_at: tile.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
case upsert_tile(attrs) do
|
||||||
|
{:ok, _} ->
|
||||||
|
Logger.debug("Updated #{tile_id}: jpg=#{jpg_available}, png=#{png_available}")
|
||||||
|
|
||||||
|
:updated
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to update #{tile_id}: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
:unchanged
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
updated = Enum.count(results, &(&1 == :updated))
|
||||||
|
unchanged = Enum.count(results, &(&1 == :unchanged))
|
||||||
|
errors = Enum.count(results, &match?({:error, _}, &1))
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
"Availability scan complete: #{updated} updated, #{unchanged} unchanged, #{errors} errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
{updated, unchanged, errors}
|
||||||
|
|
||||||
|
{:aborted, reason} ->
|
||||||
|
Logger.error("Failed to read tiles: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp process_mound_file(tile_id, file_path) do
|
||||||
|
# Check if tile already exists
|
||||||
|
case get_tile(tile_id) do
|
||||||
|
{:ok, _existing} ->
|
||||||
|
Logger.debug("Skipping existing tile: #{tile_id}")
|
||||||
|
:skipped
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
# Parse header and insert
|
||||||
|
case MoundHunters.MoundParser.parse_header(file_path) do
|
||||||
|
{:ok, header} ->
|
||||||
|
# Convert Web Mercator coordinates to lat/lng
|
||||||
|
min_lng = header.min_x
|
||||||
|
max_lng = header.max_x
|
||||||
|
min_lat = header.min_y
|
||||||
|
max_lat = header.max_y
|
||||||
|
|
||||||
|
# Check for jpg and png availability
|
||||||
|
tile_dir = Application.get_env(:mound_hunters, :tile_output_dir)
|
||||||
|
mound_dir = Path.join(tile_dir, "MOUND")
|
||||||
|
jpg_available = File.exists?(Path.join(mound_dir, "#{tile_id}.jpg"))
|
||||||
|
png_available = File.exists?(Path.join(mound_dir, "#{tile_id}.png"))
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
id: tile_id,
|
||||||
|
min_lat: min_lat,
|
||||||
|
max_lat: max_lat,
|
||||||
|
min_lng: min_lng,
|
||||||
|
max_lng: max_lng,
|
||||||
|
status: :ready,
|
||||||
|
error_message: nil,
|
||||||
|
jpg_available: jpg_available,
|
||||||
|
png_available: png_available
|
||||||
|
}
|
||||||
|
|
||||||
|
case upsert_tile(attrs) do
|
||||||
|
{:ok, _tile} ->
|
||||||
|
Logger.debug("Inserted tile: #{tile_id}")
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to insert tile #{tile_id}: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to parse header for #{tile_id}: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to check if tile exists #{tile_id}: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Generate random 8-character alphanumeric ID
|
# Generate random 8-character alphanumeric ID
|
||||||
defp generate_id do
|
defp generate_id do
|
||||||
:crypto.strong_rand_bytes(6)
|
:crypto.strong_rand_bytes(6)
|
||||||
|
|||||||
@@ -63,6 +63,66 @@ defmodule MoundHuntersWeb.ApiController do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Get tile metadata by ID.
|
||||||
|
GET /api/meta/tile/:id
|
||||||
|
Returns: tile metadata including bounds, status, availability flags
|
||||||
|
"""
|
||||||
|
def get_tile(conn) do
|
||||||
|
tile_id = conn.path_params["id"]
|
||||||
|
|
||||||
|
case MoundHunters.Repo.get_tile(tile_id) do
|
||||||
|
{:ok, tile} ->
|
||||||
|
conn
|
||||||
|
|> put_resp_content_type("application/json")
|
||||||
|
|> put_resp_header("cache-control", "public, max-age=60")
|
||||||
|
|> send_resp(200, Jason.encode!(tile))
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
send_error(conn, 404, "Tile not found")
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to get tile: #{inspect(reason)}")
|
||||||
|
send_error(conn, 500, "Failed to retrieve tile")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Get tile metadata by coordinates.
|
||||||
|
GET /api/meta/tile?lat=40.0&lng=-82.5
|
||||||
|
Returns: tile metadata for the tile containing the given coordinates
|
||||||
|
"""
|
||||||
|
def get_tile_by_coords(conn) do
|
||||||
|
with lat_str when is_binary(lat_str) <- conn.query_params["lat"],
|
||||||
|
lng_str when is_binary(lng_str) <- conn.query_params["lng"],
|
||||||
|
{lat, _} <- Float.parse(lat_str),
|
||||||
|
{lng, _} <- Float.parse(lng_str) do
|
||||||
|
case MoundHunters.Repo.get_tile_at_coords(lat, lng) do
|
||||||
|
{:ok, tile} ->
|
||||||
|
conn
|
||||||
|
|> put_resp_content_type("application/json")
|
||||||
|
|> put_resp_header("cache-control", "public, max-age=60")
|
||||||
|
|> send_resp(200, Jason.encode!(tile))
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
send_error(conn, 404, "No tile found at coordinates")
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to get tile by coords: #{inspect(reason)}")
|
||||||
|
send_error(conn, 500, "Failed to retrieve tile")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
nil ->
|
||||||
|
send_error(conn, 400, "Missing lat or lng parameter")
|
||||||
|
|
||||||
|
:error ->
|
||||||
|
send_error(conn, 400, "Invalid lat or lng format")
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
send_error(conn, 400, "Invalid request")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp send_error(conn, status, message) do
|
defp send_error(conn, status, message) do
|
||||||
conn
|
conn
|
||||||
|> put_private(:error_message, message)
|
|> put_private(:error_message, message)
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ defmodule MoundHuntersWeb.Router do
|
|||||||
MoundHuntersWeb.ApiController.get_share(conn)
|
MoundHuntersWeb.ApiController.get_share(conn)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Tile metadata API
|
||||||
|
get "/api/meta/tile/:id" do
|
||||||
|
conn = put_private(conn, :path_params, %{"id" => id})
|
||||||
|
MoundHuntersWeb.ApiController.get_tile(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/api/meta/tile" do
|
||||||
|
MoundHuntersWeb.ApiController.get_tile_by_coords(conn)
|
||||||
|
end
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
get "/health" do
|
get "/health" do
|
||||||
send_resp(conn, 200, Jason.encode!(%{status: "ok"}))
|
send_resp(conn, 200, Jason.encode!(%{status: "ok"}))
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/home/mark/projects/moundhunters/data/MOUND
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/mark/projects/moundhunters/data/PNG
|
|
||||||
@@ -176,6 +176,9 @@ const tileCache = ref({}); // Cached .mound data (for shading sandbox)
|
|||||||
const loadedPngTiles = ref(new Set()); // Track which PNG tiles are displayed on map
|
const loadedPngTiles = ref(new Set()); // Track which PNG tiles are displayed on map
|
||||||
const currentTileData = ref(null);
|
const currentTileData = ref(null);
|
||||||
|
|
||||||
|
const loadingState = ref({}); // {"-82.448446,40.051780": 'pending'}
|
||||||
|
// Values: 'looking_up' | 'found' | 'processing' | 'complete' | 'failed'
|
||||||
|
|
||||||
// Geometry state
|
// Geometry state
|
||||||
const drawMode = ref(null); // 'line', 'ray', or null
|
const drawMode = ref(null); // 'line', 'ray', or null
|
||||||
const drawPoints = ref([]);
|
const drawPoints = ref([]);
|
||||||
@@ -391,14 +394,12 @@ const BIBLIOGRAPHY = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// KNOWN_SITES with citations added to descriptions
|
// KNOWN_SITES with citations added to descriptions
|
||||||
// Citations use \cite{key} format for easy parsing
|
// Citations use \cite{key} format for easy parsing
|
||||||
|
|
||||||
const KNOWN_SITES = [
|
const KNOWN_SITES = [
|
||||||
{
|
{
|
||||||
"name": "Newark Octagon Earthworks",
|
"name": "Newark Octagon Earthworks",
|
||||||
"coordinates": [[-82.4463745, 40.0519828]],
|
"coordinates": [[-82.444270, 40.054705]],
|
||||||
"description": "Part of the Newark Earthworks complex, the Octagon is precisely aligned to the 18.6-year lunar cycle \\cite{hively_horn_1982}. Connected to a 20-acre Observatory Circle, this geometric earthwork demonstrates sophisticated astronomical knowledge. The walls and gateways encode all eight lunar standstill rise and set points \\cite{mickelson_lepper_2007}. The Octagon's eight walls (each approximately 550 feet long) and Observatory Circle form one of only two known circle-octagon pairs in the Hopewell world, the other being High Bank Works \\cite{hively_horn_1984}. First comprehensively surveyed by Squier and Davis in the 1840s \\cite{squier_davis_1848}. Inscribed as a UNESCO World Heritage Site in September 2023 \\cite{unesco_2023}.",
|
"description": "Part of the Newark Earthworks complex, the Octagon is precisely aligned to the 18.6-year lunar cycle \\cite{hively_horn_1982}. Connected to a 20-acre Observatory Circle, this geometric earthwork demonstrates sophisticated astronomical knowledge. The walls and gateways encode all eight lunar standstill rise and set points \\cite{mickelson_lepper_2007}. The Octagon's eight walls (each approximately 550 feet long) and Observatory Circle form one of only two known circle-octagon pairs in the Hopewell world, the other being High Bank Works \\cite{hively_horn_1984}. First comprehensively surveyed by Squier and Davis in the 1840s \\cite{squier_davis_1848}. Inscribed as a UNESCO World Heritage Site in September 2023 \\cite{unesco_2023}.",
|
||||||
"type": "earthwork",
|
"type": "earthwork",
|
||||||
"tiles": [
|
"tiles": [
|
||||||
@@ -413,7 +414,8 @@ const KNOWN_SITES = [
|
|||||||
'BS19870743',
|
'BS19870743',
|
||||||
'BS19860743',
|
'BS19860743',
|
||||||
'BS19880743',
|
'BS19880743',
|
||||||
'BS19880742'
|
'BS19880742',
|
||||||
|
'BS19830746'
|
||||||
],
|
],
|
||||||
|
|
||||||
"overlay": [
|
"overlay": [
|
||||||
@@ -442,7 +444,7 @@ const KNOWN_SITES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Van Voorhis Walls",
|
"name": "Van Voorhis Walls",
|
||||||
"coordinates": [[-82.446375, 40.051983], [-82.447, 40.048], [-82.448, 40.045], [-82.45, 40.04]],
|
"coordinates": [[ -82.459139,40.028334]],
|
||||||
"description": "The best-preserved section of the Great Hopewell Road, extending 2.5 miles south from the Newark Octagon to Ramp Creek \\cite{lepper_1995}. This confirmed earthwork consists of parallel walls approximately 60 meters (200 feet) apart, aligned on an azimuth of approximately 212° toward Chillicothe \\cite{schwarz_2016}. First documented by James and Charles Salisbury in 1862, who followed the walls for 6 miles through 'tangled swamps and over hills, still keeping their undeviating course' \\cite{salisbury_salisbury_1862}. LiDAR analysis suggests the road was sunken between the walls \\cite{romain_burks_2008}. Test excavations in 2009 revealed a thin layer of white limestone that may have paved the road \\cite{lepper_2024}. Still visible above ground in woodland areas too swampy to farm.",
|
"description": "The best-preserved section of the Great Hopewell Road, extending 2.5 miles south from the Newark Octagon to Ramp Creek \\cite{lepper_1995}. This confirmed earthwork consists of parallel walls approximately 60 meters (200 feet) apart, aligned on an azimuth of approximately 212° toward Chillicothe \\cite{schwarz_2016}. First documented by James and Charles Salisbury in 1862, who followed the walls for 6 miles through 'tangled swamps and over hills, still keeping their undeviating course' \\cite{salisbury_salisbury_1862}. LiDAR analysis suggests the road was sunken between the walls \\cite{romain_burks_2008}. Test excavations in 2009 revealed a thin layer of white limestone that may have paved the road \\cite{lepper_2024}. Still visible above ground in woodland areas too swampy to farm.",
|
||||||
"type": "road_confirmed",
|
"type": "road_confirmed",
|
||||||
"tiles": ['BS19820746', 'BS19820745', 'BS19820743', 'BS19820742', 'BS19860742', 'BS19880742']
|
"tiles": ['BS19820746', 'BS19820745', 'BS19820743', 'BS19820742', 'BS19860742', 'BS19880742']
|
||||||
@@ -712,7 +714,7 @@ async function loadPngTiles() {
|
|||||||
for (const tileName of TILE_NAMES) {
|
for (const tileName of TILE_NAMES) {
|
||||||
try {
|
try {
|
||||||
// Fetch metadata JSON for bounds
|
// Fetch metadata JSON for bounds
|
||||||
const metaResponse = await fetch(`/png/${tileName}.json`);
|
const metaResponse = await fetch(`/tiles/JSON/${tileName}.json`);
|
||||||
if (!metaResponse.ok) {
|
if (!metaResponse.ok) {
|
||||||
console.warn(`No metadata for ${tileName}, skipping`);
|
console.warn(`No metadata for ${tileName}, skipping`);
|
||||||
continue;
|
continue;
|
||||||
@@ -720,7 +722,7 @@ async function loadPngTiles() {
|
|||||||
const meta = await metaResponse.json();
|
const meta = await metaResponse.json();
|
||||||
|
|
||||||
// Check PNG exists
|
// Check PNG exists
|
||||||
const pngUrl = `/png/${tileName}.png`;
|
const pngUrl = `/tiles/PNG/${tileName}.png`;
|
||||||
const pngResponse = await fetch(pngUrl, { method: 'HEAD' });
|
const pngResponse = await fetch(pngUrl, { method: 'HEAD' });
|
||||||
if (!pngResponse.ok) {
|
if (!pngResponse.ok) {
|
||||||
console.warn(`No PNG for ${tileName}, skipping`);
|
console.warn(`No PNG for ${tileName}, skipping`);
|
||||||
@@ -774,7 +776,7 @@ async function loadMoundData(tileName) {
|
|||||||
console.log(`Loading mound data for ${tileName}...`);
|
console.log(`Loading mound data for ${tileName}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await parseMoundFile(`/mound/${tileName}.mound`);
|
const data = await parseMoundFile(`/tiles/mound/${tileName}.mound`);
|
||||||
tileCache.value[tileName] = data;
|
tileCache.value[tileName] = data;
|
||||||
console.log(`Cached mound data for ${tileName}`);
|
console.log(`Cached mound data for ${tileName}`);
|
||||||
return data;
|
return data;
|
||||||
@@ -1049,28 +1051,67 @@ function clearAllGeometry() {
|
|||||||
// Request a tile for a specific location
|
// Request a tile for a specific location
|
||||||
async function requestTile() {
|
async function requestTile() {
|
||||||
const { lng, lat } = contextMenu.value.lngLat;
|
const { lng, lat } = contextMenu.value.lngLat;
|
||||||
|
const stateKey = `${lat},${lng}`;
|
||||||
|
|
||||||
// TODO: Call backend endpoint
|
// Initialize loading state
|
||||||
// For now, just log what we would send
|
loadingState.value[stateKey] = 'looking_up';
|
||||||
const requestData = {
|
|
||||||
longitude: lng,
|
// Open SSE connection
|
||||||
latitude: lat,
|
const eventSource = new EventSource(`/tiles/request?lat=${lat}&lng=${lng}`);
|
||||||
// Backend will:
|
|
||||||
// 1. Transform to Ohio State Plane South (EPSG:3735) in US Survey Feet
|
eventSource.onmessage = (event) => {
|
||||||
// 2. Call https://maps.ohio.gov/arcgis/rest/services/OGRIP/3DepTiles/MapServer/0/query
|
const data = JSON.parse(event.data);
|
||||||
// 3. Extract TileName, County, Block
|
|
||||||
// 4. Return tile info and download URL
|
switch (data.status) {
|
||||||
|
case 'looking_up':
|
||||||
|
loadingState.value[stateKey] = 'looking_up';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'found':
|
||||||
|
loadingState.value[stateKey] = 'found';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'processing':
|
||||||
|
loadingState.value[stateKey] = 'processing';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ready':
|
||||||
|
loadingState.value[stateKey] = 'complete';
|
||||||
|
// Load the tile data into cache
|
||||||
|
loadMoundData(data.tile_id);
|
||||||
|
eventSource.close();
|
||||||
|
// Clean up loading state after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
delete loadingState.value[stateKey];
|
||||||
|
}, 10000);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
loadingState.value[stateKey] = 'failed';
|
||||||
|
console.error('Tile request failed:', data.message);
|
||||||
|
// TODO: Show toast notification with error message
|
||||||
|
eventSource.close();
|
||||||
|
// Clean up loading state after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
delete loadingState.value[stateKey];
|
||||||
|
}, 10000);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
loadingState.value[stateKey] = 'failed';
|
||||||
|
console.error('SSE connection error:', error);
|
||||||
|
// TODO: Show toast notification with error message
|
||||||
|
eventSource.close();
|
||||||
|
// Clean up loading state after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
delete loadingState.value[stateKey];
|
||||||
|
}, 10000);
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('=== Tile Request ===');
|
|
||||||
console.log('Would send to backend:', requestData);
|
|
||||||
console.log('Backend endpoint: POST /api/tiles/request');
|
|
||||||
|
|
||||||
// Close the context menu
|
// Close the context menu
|
||||||
contextMenu.value.visible = false;
|
contextMenu.value.visible = false;
|
||||||
|
|
||||||
// TODO: Show loading indicator
|
|
||||||
// TODO: Handle response and update UI
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load mound data for interactive shading
|
// Load mound data for interactive shading
|
||||||
|
|||||||
210
ui/src/App1.vue
210
ui/src/App1.vue
@@ -1,210 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div id="app">
|
|
||||||
<canvas ref="canvas" class="canvas"></canvas>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'App1',
|
|
||||||
setup() {
|
|
||||||
const canvas = ref(null);
|
|
||||||
let scene, camera, renderer, controls;
|
|
||||||
|
|
||||||
const parseMoundFile = async (url) => {
|
|
||||||
const response = await fetch(url);
|
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
const magic = String.fromCharCode(
|
|
||||||
view.getUint8(offset++),
|
|
||||||
view.getUint8(offset++),
|
|
||||||
view.getUint8(offset++),
|
|
||||||
view.getUint8(offset++)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (magic !== 'LIDR') {
|
|
||||||
throw new Error('Invalid .mound file');
|
|
||||||
}
|
|
||||||
|
|
||||||
const version = view.getUint32(offset, true); offset += 4;
|
|
||||||
const pointCount = view.getUint32(offset, true); offset += 4;
|
|
||||||
const triangleCount = view.getUint32(offset, true); offset += 4;
|
|
||||||
const minX = view.getFloat32(offset, true); offset += 4;
|
|
||||||
const minY = view.getFloat32(offset, true); offset += 4;
|
|
||||||
const minZ = view.getFloat32(offset, true); offset += 4;
|
|
||||||
const maxX = view.getFloat32(offset, true); offset += 4;
|
|
||||||
const maxY = view.getFloat32(offset, true); offset += 4;
|
|
||||||
const maxZ = view.getFloat32(offset, true); offset += 4;
|
|
||||||
offset += 24; // Skip reserved
|
|
||||||
|
|
||||||
const positions = new Float32Array(buffer, offset, pointCount * 3);
|
|
||||||
offset += pointCount * 3 * 4;
|
|
||||||
|
|
||||||
const indices = new Uint32Array(buffer, offset, triangleCount * 3);
|
|
||||||
|
|
||||||
return {
|
|
||||||
pointCount,
|
|
||||||
triangleCount,
|
|
||||||
bounds: { minX, minY, minZ, maxX, maxY, maxZ },
|
|
||||||
positions,
|
|
||||||
indices
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadLidarTile = async () => {
|
|
||||||
try {
|
|
||||||
console.log('Loading BS19820747...');
|
|
||||||
const data = await parseMoundFile('/tiles/BS19820747.mound');
|
|
||||||
|
|
||||||
console.log('Bounds:', data.bounds);
|
|
||||||
console.log('Points:', data.pointCount);
|
|
||||||
console.log('Triangles:', data.triangleCount);
|
|
||||||
|
|
||||||
const geometry = new THREE.BufferGeometry();
|
|
||||||
|
|
||||||
// Calculate center and scale first
|
|
||||||
const centerX = (data.bounds.minX + data.bounds.maxX) / 2;
|
|
||||||
const centerY = (data.bounds.minY + data.bounds.maxY) / 2;
|
|
||||||
const centerZ = (data.bounds.minZ + data.bounds.maxZ) / 2;
|
|
||||||
|
|
||||||
const spanX = data.bounds.maxX - data.bounds.minX;
|
|
||||||
const spanY = data.bounds.maxY - data.bounds.minY;
|
|
||||||
const spanZ = data.bounds.maxZ - data.bounds.minZ;
|
|
||||||
const maxSpan = Math.max(spanX, spanY);
|
|
||||||
|
|
||||||
console.log('Center:', centerX, centerY, centerZ);
|
|
||||||
console.log('Span X:', spanX, 'Y:', spanY, 'Z:', spanZ);
|
|
||||||
console.log('Max span:', maxSpan);
|
|
||||||
|
|
||||||
// Transform vertices: center and scale
|
|
||||||
const normalizeScale = 10 / maxSpan;
|
|
||||||
const zScale = normalizeScale * (maxSpan * 0.1) / spanZ;
|
|
||||||
|
|
||||||
console.log('Normalize scale:', normalizeScale);
|
|
||||||
console.log('Z scale:', zScale);
|
|
||||||
|
|
||||||
// Create new position array with transformed coordinates
|
|
||||||
const transformedPositions = new Float32Array(data.positions.length);
|
|
||||||
for (let i = 0; i < data.positions.length; i += 3) {
|
|
||||||
transformedPositions[i] = (data.positions[i] - centerX) * normalizeScale; // X
|
|
||||||
transformedPositions[i + 1] = (data.positions[i + 1] - centerY) * normalizeScale; // Y
|
|
||||||
transformedPositions[i + 2] = (data.positions[i + 2] - centerZ) * zScale; // Z
|
|
||||||
}
|
|
||||||
|
|
||||||
geometry.setAttribute('position', new THREE.BufferAttribute(transformedPositions, 3));
|
|
||||||
geometry.setIndex(new THREE.BufferAttribute(data.indices, 1));
|
|
||||||
geometry.computeVertexNormals();
|
|
||||||
|
|
||||||
console.log('First few transformed positions:', transformedPositions.slice(0, 15));
|
|
||||||
|
|
||||||
const material = new THREE.MeshLambertMaterial({
|
|
||||||
color: 0x8B7355,
|
|
||||||
side: THREE.DoubleSide,
|
|
||||||
wireframe: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(geometry, material);
|
|
||||||
scene.add(mesh);
|
|
||||||
|
|
||||||
console.log('Mesh added to scene at origin');
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load tile:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// Scene
|
|
||||||
scene = new THREE.Scene();
|
|
||||||
scene.background = new THREE.Color(0x87CEEB);
|
|
||||||
|
|
||||||
// Camera
|
|
||||||
camera = new THREE.PerspectiveCamera(
|
|
||||||
75,
|
|
||||||
window.innerWidth / window.innerHeight,
|
|
||||||
0.1,
|
|
||||||
1000
|
|
||||||
);
|
|
||||||
camera.position.set(0, -15, 10);
|
|
||||||
camera.lookAt(0, 0, 0);
|
|
||||||
|
|
||||||
// Renderer
|
|
||||||
renderer = new THREE.WebGLRenderer({
|
|
||||||
canvas: canvas.value,
|
|
||||||
antialias: true
|
|
||||||
});
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
||||||
|
|
||||||
// OrbitControls for pan, orbit, zoom
|
|
||||||
controls = new OrbitControls(camera, canvas.value);
|
|
||||||
controls.enableDamping = true;
|
|
||||||
controls.dampingFactor = 0.05;
|
|
||||||
controls.target.set(0, 0, 0);
|
|
||||||
|
|
||||||
// Lights for the terrain
|
|
||||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
|
||||||
scene.add(ambientLight);
|
|
||||||
|
|
||||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
||||||
directionalLight.position.set(5, 5, 10);
|
|
||||||
scene.add(directionalLight);
|
|
||||||
|
|
||||||
// Axes helper
|
|
||||||
const axesHelper = new THREE.AxesHelper(15);
|
|
||||||
scene.add(axesHelper);
|
|
||||||
|
|
||||||
// Load lidar
|
|
||||||
loadLidarTile();
|
|
||||||
|
|
||||||
// Animation loop
|
|
||||||
const animate = () => {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
controls.update();
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
};
|
|
||||||
|
|
||||||
animate();
|
|
||||||
|
|
||||||
// Handle resize
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
camera.aspect = window.innerWidth / window.innerHeight;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Three.js initialized');
|
|
||||||
console.log('Scene:', scene);
|
|
||||||
console.log('Camera:', camera);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { canvas };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
398
ui/src/App2.vue
398
ui/src/App2.vue
@@ -1,398 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div id="app">
|
|
||||||
<canvas ref="canvas" class="canvas"></canvas>
|
|
||||||
|
|
||||||
<div class="control-panel">
|
|
||||||
<div class="control-group">
|
|
||||||
<label>Light Azimuth (E-W)</label>
|
|
||||||
<input
|
|
||||||
v-model.number="controls_state.azimuth"
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="360"
|
|
||||||
step="1"
|
|
||||||
@input="onControlChange"
|
|
||||||
/>
|
|
||||||
<span class="value">{{ Math.round(controls_state.azimuth) }}°</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-group">
|
|
||||||
<label>Light Altitude (N-S)</label>
|
|
||||||
<input
|
|
||||||
v-model.number="controls_state.altitude"
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="90"
|
|
||||||
step="1"
|
|
||||||
@input="onControlChange"
|
|
||||||
/>
|
|
||||||
<span class="value">{{ Math.round(controls_state.altitude) }}°</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-group">
|
|
||||||
<label>Light Intensity</label>
|
|
||||||
<input
|
|
||||||
v-model.number="controls_state.intensity"
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="2"
|
|
||||||
step="0.1"
|
|
||||||
@input="onControlChange"
|
|
||||||
/>
|
|
||||||
<span class="value">{{ controls_state.intensity.toFixed(1) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-group">
|
|
||||||
<label>Height Exaggeration</label>
|
|
||||||
<input
|
|
||||||
v-model.number="controls_state.heightScale"
|
|
||||||
type="range"
|
|
||||||
min="0.1"
|
|
||||||
max="10"
|
|
||||||
step="0.1"
|
|
||||||
@input="onControlChange"
|
|
||||||
/>
|
|
||||||
<span class="value">{{ controls_state.heightScale.toFixed(1) }}x</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'App1',
|
|
||||||
setup() {
|
|
||||||
const canvas = ref(null);
|
|
||||||
let scene, camera, renderer, threeControls, mesh, directionalLight;
|
|
||||||
|
|
||||||
const controls_state = ref({
|
|
||||||
azimuth: 45,
|
|
||||||
altitude: 45,
|
|
||||||
intensity: 0.8,
|
|
||||||
heightScale: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
let geometryCache = null;
|
|
||||||
let centerX, centerY, centerZ, spanZ, normalizeScale;
|
|
||||||
|
|
||||||
const parseMoundFile = async (url) => {
|
|
||||||
const response = await fetch(url);
|
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
const magic = String.fromCharCode(
|
|
||||||
view.getUint8(offset++),
|
|
||||||
view.getUint8(offset++),
|
|
||||||
view.getUint8(offset++),
|
|
||||||
view.getUint8(offset++)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (magic !== 'LIDR') {
|
|
||||||
throw new Error('Invalid .mound file');
|
|
||||||
}
|
|
||||||
|
|
||||||
const version = view.getUint32(offset, true); offset += 4;
|
|
||||||
const pointCount = view.getUint32(offset, true); offset += 4;
|
|
||||||
const triangleCount = view.getUint32(offset, true); offset += 4;
|
|
||||||
const minX = view.getFloat32(offset, true); offset += 4;
|
|
||||||
const minY = view.getFloat32(offset, true); offset += 4;
|
|
||||||
const minZ = view.getFloat32(offset, true); offset += 4;
|
|
||||||
const maxX = view.getFloat32(offset, true); offset += 4;
|
|
||||||
const maxY = view.getFloat32(offset, true); offset += 4;
|
|
||||||
const maxZ = view.getFloat32(offset, true); offset += 4;
|
|
||||||
offset += 24; // Skip reserved
|
|
||||||
|
|
||||||
const positions = new Float32Array(buffer, offset, pointCount * 3);
|
|
||||||
offset += pointCount * 3 * 4;
|
|
||||||
|
|
||||||
const indices = new Uint32Array(buffer, offset, triangleCount * 3);
|
|
||||||
|
|
||||||
return {
|
|
||||||
pointCount,
|
|
||||||
triangleCount,
|
|
||||||
bounds: { minX, minY, minZ, maxX, maxY, maxZ },
|
|
||||||
positions,
|
|
||||||
indices
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadLidarTile = async () => {
|
|
||||||
try {
|
|
||||||
console.log('Loading BS19820747...');
|
|
||||||
const data = await parseMoundFile('/tiles/BS19820747.mound');
|
|
||||||
|
|
||||||
console.log('Bounds:', data.bounds);
|
|
||||||
console.log('Points:', data.pointCount);
|
|
||||||
console.log('Triangles:', data.triangleCount);
|
|
||||||
|
|
||||||
const geometry = new THREE.BufferGeometry();
|
|
||||||
|
|
||||||
// Calculate center and scale first
|
|
||||||
centerX = (data.bounds.minX + data.bounds.maxX) / 2;
|
|
||||||
centerY = (data.bounds.minY + data.bounds.maxY) / 2;
|
|
||||||
centerZ = (data.bounds.minZ + data.bounds.maxZ) / 2;
|
|
||||||
|
|
||||||
const spanX = data.bounds.maxX - data.bounds.minX;
|
|
||||||
const spanY = data.bounds.maxY - data.bounds.minY;
|
|
||||||
spanZ = data.bounds.maxZ - data.bounds.minZ;
|
|
||||||
const maxSpan = Math.max(spanX, spanY);
|
|
||||||
|
|
||||||
console.log('Center:', centerX, centerY, centerZ);
|
|
||||||
console.log('Span X:', spanX, 'Y:', spanY, 'Z:', spanZ);
|
|
||||||
console.log('Max span:', maxSpan);
|
|
||||||
|
|
||||||
// Transform vertices: center and scale
|
|
||||||
normalizeScale = 10 / maxSpan;
|
|
||||||
const zScale = normalizeScale * (maxSpan * 0.1) / spanZ;
|
|
||||||
|
|
||||||
console.log('Normalize scale:', normalizeScale);
|
|
||||||
console.log('Z scale:', zScale);
|
|
||||||
|
|
||||||
// Create new position array with transformed coordinates
|
|
||||||
const transformedPositions = new Float32Array(data.positions.length);
|
|
||||||
for (let i = 0; i < data.positions.length; i += 3) {
|
|
||||||
transformedPositions[i] = (data.positions[i] - centerX) * normalizeScale; // X
|
|
||||||
transformedPositions[i + 1] = (data.positions[i + 1] - centerY) * normalizeScale; // Y
|
|
||||||
transformedPositions[i + 2] = (data.positions[i + 2] - centerZ) * zScale; // Z
|
|
||||||
}
|
|
||||||
|
|
||||||
geometry.setAttribute('position', new THREE.BufferAttribute(transformedPositions, 3));
|
|
||||||
geometry.setIndex(new THREE.BufferAttribute(data.indices, 1));
|
|
||||||
geometry.computeVertexNormals();
|
|
||||||
|
|
||||||
geometryCache = { geometry, baseZ: new Float32Array(data.positions.map((v, i) => i % 3 === 2 ? (v - centerZ) * zScale : 0)), zScale };
|
|
||||||
|
|
||||||
console.log('First few transformed positions:', transformedPositions.slice(0, 15));
|
|
||||||
|
|
||||||
const material = new THREE.MeshLambertMaterial({
|
|
||||||
color: 0x8B7355,
|
|
||||||
side: THREE.DoubleSide,
|
|
||||||
wireframe: false
|
|
||||||
});
|
|
||||||
|
|
||||||
mesh = new THREE.Mesh(geometry, material);
|
|
||||||
scene.add(mesh);
|
|
||||||
|
|
||||||
// Apply initial height scale
|
|
||||||
updateHeightScale();
|
|
||||||
|
|
||||||
console.log('Mesh added to scene at origin');
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load tile:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateHeightScale = () => {
|
|
||||||
if (!geometryCache) return;
|
|
||||||
|
|
||||||
const positions = geometryCache.geometry.attributes.position.array;
|
|
||||||
const exaggeration = controls_state.value.heightScale;
|
|
||||||
|
|
||||||
// Only every 3rd value (Z coordinate)
|
|
||||||
for (let i = 2; i < positions.length; i += 3) {
|
|
||||||
positions[i] = geometryCache.baseZ[i] * exaggeration;
|
|
||||||
}
|
|
||||||
|
|
||||||
geometryCache.geometry.attributes.position.needsUpdate = true;
|
|
||||||
geometryCache.geometry.computeVertexNormals();
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateLightPosition = () => {
|
|
||||||
if (!directionalLight) return;
|
|
||||||
|
|
||||||
const azimuth = (controls_state.value.azimuth * Math.PI) / 180;
|
|
||||||
const altitude = (controls_state.value.altitude * Math.PI) / 180;
|
|
||||||
|
|
||||||
const distance = 20;
|
|
||||||
const x = Math.cos(azimuth) * Math.cos(altitude) * distance;
|
|
||||||
const y = Math.sin(azimuth) * Math.cos(altitude) * distance;
|
|
||||||
const z = Math.sin(altitude) * distance;
|
|
||||||
|
|
||||||
directionalLight.position.set(x, y, z);
|
|
||||||
directionalLight.intensity = controls_state.value.intensity;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onControlChange = () => {
|
|
||||||
updateLightPosition();
|
|
||||||
updateHeightScale();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// Scene
|
|
||||||
scene = new THREE.Scene();
|
|
||||||
scene.background = new THREE.Color(0x87CEEB);
|
|
||||||
|
|
||||||
// Camera
|
|
||||||
camera = new THREE.PerspectiveCamera(
|
|
||||||
75,
|
|
||||||
window.innerWidth / window.innerHeight,
|
|
||||||
0.1,
|
|
||||||
1000
|
|
||||||
);
|
|
||||||
camera.position.set(0, -15, 10);
|
|
||||||
camera.lookAt(0, 0, 0);
|
|
||||||
|
|
||||||
// Renderer
|
|
||||||
renderer = new THREE.WebGLRenderer({
|
|
||||||
canvas: canvas.value,
|
|
||||||
antialias: true
|
|
||||||
});
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
||||||
|
|
||||||
// OrbitControls for pan, orbit, zoom
|
|
||||||
threeControls = new OrbitControls(camera, canvas.value);
|
|
||||||
threeControls.enableDamping = true;
|
|
||||||
threeControls.dampingFactor = 0.05;
|
|
||||||
threeControls.target.set(0, 0, 0);
|
|
||||||
|
|
||||||
// Lights for the terrain
|
|
||||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
|
||||||
scene.add(ambientLight);
|
|
||||||
|
|
||||||
directionalLight = new THREE.DirectionalLight(0xffffff, controls_state.value.intensity);
|
|
||||||
scene.add(directionalLight);
|
|
||||||
|
|
||||||
// Set initial light position
|
|
||||||
updateLightPosition();
|
|
||||||
|
|
||||||
// Axes helper
|
|
||||||
const axesHelper = new THREE.AxesHelper(15);
|
|
||||||
scene.add(axesHelper);
|
|
||||||
|
|
||||||
// Load lidar
|
|
||||||
loadLidarTile();
|
|
||||||
|
|
||||||
// Animation loop
|
|
||||||
const animate = () => {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
threeControls.update();
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
};
|
|
||||||
|
|
||||||
animate();
|
|
||||||
|
|
||||||
// Handle resize
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
camera.aspect = window.innerWidth / window.innerHeight;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Three.js initialized');
|
|
||||||
console.log('Scene:', scene);
|
|
||||||
console.log('Camera:', camera);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { canvas, controls_state, onControlChange };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-panel {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background: rgba(0, 0, 0, 0.75);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
width: 280px;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group input[type="range"] {
|
|
||||||
width: 100%;
|
|
||||||
height: 4px;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 2px;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group input[type="range"]::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
background: #4A9EFF;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group input[type="range"]::-webkit-slider-thumb:hover {
|
|
||||||
background: #5BADFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group input[type="range"]::-moz-range-thumb {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
background: #4A9EFF;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-group input[type="range"]::-moz-range-thumb:hover {
|
|
||||||
background: #5BADFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.7;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -34,7 +34,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/socket': {
|
'/tiles': {
|
||||||
target: 'http://localhost:4000',
|
target: 'http://localhost:4000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false
|
secure: false
|
||||||
@@ -44,7 +44,7 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false
|
secure: false
|
||||||
},
|
},
|
||||||
'/admin': {
|
'/health': {
|
||||||
target: 'http://localhost:4000',
|
target: 'http://localhost:4000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false
|
secure: false
|
||||||
|
|||||||
Reference in New Issue
Block a user