mirror of
https://github.com/MaSzyna-EU07/maszyna.git
synced 2026-10-17 12:09:19 +02:00
feat(editor): geoportal orthophoto layer streamed around the camera
This commit is contained in:
@@ -170,6 +170,7 @@ set(SOURCES
|
||||
"editor/editorModelSets.cpp"
|
||||
"editor/editorTerrain.cpp"
|
||||
"editor/editorTerrainStreamer.cpp"
|
||||
"editor/editorOrthophoto.cpp"
|
||||
"application/editormode.cpp"
|
||||
"input/editormouseinput.cpp"
|
||||
"application/editoruilayer.cpp"
|
||||
@@ -564,6 +565,19 @@ if (WIN32)
|
||||
target_link_libraries(${PROJECT_NAME} ws2_32)
|
||||
endif()
|
||||
|
||||
# http client of the editor's geoportal orthophoto layer: WinHTTP on Windows, libcurl elsewhere when available
|
||||
if (WIN32)
|
||||
target_link_libraries(${PROJECT_NAME} winhttp)
|
||||
else()
|
||||
find_package(CURL QUIET)
|
||||
if (CURL_FOUND)
|
||||
target_link_libraries(${PROJECT_NAME} CURL::libcurl)
|
||||
target_compile_definitions(${PROJECT_NAME} PRIVATE EU07_WITH_CURL)
|
||||
else()
|
||||
message(STATUS "libcurl not found: the editor's orthophoto layer will only show cached tiles")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if (WITH_ZMQ)
|
||||
find_package(cppzmq REQUIRED)
|
||||
target_include_directories(${PROJECT_NAME} PRIVATE ${cppzmq_INCLUDE_DIR})
|
||||
|
||||
@@ -35,6 +35,7 @@ http://mozilla.org/MPL/2.0/.
|
||||
#include <glm/gtc/type_ptr.hpp>
|
||||
#include <glm/gtc/matrix_transform.hpp>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <limits>
|
||||
@@ -267,6 +268,19 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
// scenery file name without path and extension
|
||||
std::string scenery_stem()
|
||||
{
|
||||
std::string scenery = Global.SceneryFile;
|
||||
auto const slash = scenery.find_last_of("/\\");
|
||||
if (slash != std::string::npos)
|
||||
scenery = scenery.substr(slash + 1);
|
||||
auto const dot = scenery.find_last_of('.');
|
||||
if (dot != std::string::npos)
|
||||
scenery = scenery.substr(0, dot);
|
||||
return scenery.empty() ? "default" : scenery;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
bool editor_mode::editormode_input::init()
|
||||
@@ -879,6 +893,10 @@ bool editor_mode::update()
|
||||
}
|
||||
}
|
||||
|
||||
// --- geoportal orthophoto: streamed around the camera, drawn beneath the other overlays ---
|
||||
m_orthophoto.update(Camera.Pos);
|
||||
draw_orthophoto();
|
||||
|
||||
// --- ImGuizmo: in-viewport transform gizmo for the selected node ---
|
||||
render_gizmo();
|
||||
|
||||
@@ -926,6 +944,11 @@ void editor_mode::render_settings()
|
||||
render_terrain_ui();
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
if (ImGui::BeginTabItem("Orthophoto"))
|
||||
{
|
||||
render_orthophoto_ui();
|
||||
ImGui::EndTabItem();
|
||||
}
|
||||
ImGui::EndTabBar();
|
||||
}
|
||||
|
||||
@@ -1094,6 +1117,153 @@ void editor_mode::render_terrain_ui()
|
||||
}
|
||||
}
|
||||
|
||||
void editor_mode::load_orthophoto_settings()
|
||||
{
|
||||
std::string const scenery = scenery_stem();
|
||||
if (scenery == m_orthophoto_scenery)
|
||||
return;
|
||||
m_orthophoto_scenery = scenery;
|
||||
|
||||
auto const &stored = EditorSettings.orthophoto();
|
||||
editor_orthophoto::config config = m_orthophoto.settings();
|
||||
config.radius = stored.radius;
|
||||
config.height = stored.height;
|
||||
config.opacity = stored.opacity;
|
||||
config.year = stored.year;
|
||||
config.hires = stored.hires;
|
||||
config.north = 0.0;
|
||||
config.east = 0.0;
|
||||
EditorSettings.orthophoto_origin(scenery, config.north, config.east);
|
||||
m_orthophoto.settings(config);
|
||||
m_orthophoto_origin_edit = {config.north, config.east};
|
||||
}
|
||||
|
||||
void editor_mode::save_orthophoto_settings()
|
||||
{
|
||||
editor_orthophoto::config const &config = m_orthophoto.settings();
|
||||
auto &stored = EditorSettings.orthophoto();
|
||||
stored.radius = config.radius;
|
||||
stored.height = config.height;
|
||||
stored.opacity = config.opacity;
|
||||
stored.year = config.year;
|
||||
stored.hires = config.hires;
|
||||
if (config.north != 0.0 || config.east != 0.0)
|
||||
EditorSettings.orthophoto_origin(m_orthophoto_scenery, config.north, config.east);
|
||||
EditorSettings.save();
|
||||
}
|
||||
|
||||
void editor_mode::render_orthophoto_ui()
|
||||
{
|
||||
load_orthophoto_settings();
|
||||
|
||||
editor_orthophoto::config config = m_orthophoto.settings();
|
||||
bool changed = false; // apply to the layer
|
||||
bool persist = false; // and store in the editor settings
|
||||
|
||||
bool enabled = m_orthophoto.enabled();
|
||||
if (ImGui::Checkbox("Show orthophoto (geoportal.gov.pl)", &enabled))
|
||||
m_orthophoto.enabled(enabled);
|
||||
|
||||
ImGui::Separator();
|
||||
ImGui::TextUnformatted("Scenery origin (0,0,0) in PUWG 1992 / EPSG:2180");
|
||||
// geodetic convention, as displayed by geoportal.gov.pl: X grows north, Y grows east.
|
||||
// applied once editing ends, so half-typed numbers don't trigger downloads
|
||||
ImGui::SetNextItemWidth(160.0f);
|
||||
ImGui::InputDouble("X (northing, m)", &m_orthophoto_origin_edit.x, 0.0, 0.0, "%.2f");
|
||||
bool const northedited = ImGui::IsItemDeactivatedAfterEdit();
|
||||
ImGui::SetNextItemWidth(160.0f);
|
||||
ImGui::InputDouble("Y (easting, m)", &m_orthophoto_origin_edit.y, 0.0, 0.0, "%.2f");
|
||||
bool const eastedited = ImGui::IsItemDeactivatedAfterEdit();
|
||||
if (northedited || eastedited)
|
||||
{
|
||||
config.north = m_orthophoto_origin_edit.x;
|
||||
config.east = m_orthophoto_origin_edit.y;
|
||||
changed = persist = true;
|
||||
}
|
||||
if (config.north == 0.0 && config.east == 0.0)
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "Enter the origin of this scenery first");
|
||||
else
|
||||
ImGui::TextDisabled("Camera at X %.1f Y %.1f", config.north + Camera.Pos.z, config.east - Camera.Pos.x);
|
||||
|
||||
ImGui::Separator();
|
||||
ImGui::SetNextItemWidth(160.0f);
|
||||
changed |= ImGui::SliderInt("Distance (tiles)", &config.radius, 0, editor_orthophoto::max_radius);
|
||||
persist |= ImGui::IsItemDeactivatedAfterEdit();
|
||||
ImGui::SameLine();
|
||||
int const span = static_cast<int>((2 * config.radius + 1) * editor_orthophoto::tile_size);
|
||||
ImGui::TextDisabled("%d x %d m", span, span);
|
||||
|
||||
ImGui::SetNextItemWidth(160.0f);
|
||||
changed |= ImGui::DragFloat("Height (m)", &config.height, 0.1f, -1000.0f, 3000.0f, "%.2f");
|
||||
persist |= ImGui::IsItemDeactivatedAfterEdit();
|
||||
|
||||
ImGui::SetNextItemWidth(160.0f);
|
||||
changed |= ImGui::SliderFloat("Opacity", &config.opacity, 0.0f, 1.0f, "%.2f");
|
||||
persist |= ImGui::IsItemDeactivatedAfterEdit();
|
||||
|
||||
// newest imagery, or the newest imagery taken up to the end of the selected year
|
||||
int const thisyear = static_cast<int>(std::chrono::year_month_day{std::chrono::floor<std::chrono::days>(std::chrono::system_clock::now())}.year());
|
||||
std::string const yearlabel = config.year ? std::to_string(config.year) : std::string("Newest");
|
||||
ImGui::SetNextItemWidth(160.0f);
|
||||
if (ImGui::BeginCombo("Photo year", yearlabel.c_str()))
|
||||
{
|
||||
if (ImGui::Selectable("Newest", config.year == 0))
|
||||
{
|
||||
config.year = 0;
|
||||
changed = persist = true;
|
||||
}
|
||||
for (int year = thisyear; year >= editor_orthophoto::oldest_year; --year)
|
||||
{
|
||||
if (ImGui::Selectable(std::to_string(year).c_str(), config.year == year))
|
||||
{
|
||||
config.year = year;
|
||||
changed = persist = true;
|
||||
}
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
|
||||
if (ImGui::Checkbox("4K tiles where available", &config.hires))
|
||||
changed = persist = true;
|
||||
if (ImGui::IsItemHovered())
|
||||
ImGui::SetTooltip("4096 px tiles (~6 cm/px) from the high-resolution orthophoto, for the tiles around the camera.\n"
|
||||
"Areas it doesn't cover use the standard imagery. Each 4K tile takes ~85 MB of video memory.");
|
||||
|
||||
if (changed)
|
||||
m_orthophoto.settings(config);
|
||||
if (persist)
|
||||
save_orthophoto_settings();
|
||||
|
||||
ImGui::Separator();
|
||||
auto const stats = m_orthophoto.stats();
|
||||
ImGui::Text("Tiles: %d shown, %d loading, %d failed", stats.resident, stats.loading, stats.failed);
|
||||
if (stats.failed > 0)
|
||||
{
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Retry"))
|
||||
m_orthophoto.retry_failed();
|
||||
}
|
||||
if (!editor_orthophoto::can_download())
|
||||
ImGui::TextDisabled("This build has no HTTP client, only cached tiles are shown");
|
||||
ImGui::TextDisabled("Cache: %s", editor_orthophoto::cache_directory().c_str());
|
||||
ImGui::TextDisabled("Imagery: GUGiK, geoportal.gov.pl");
|
||||
}
|
||||
|
||||
void editor_mode::draw_orthophoto()
|
||||
{
|
||||
if (!m_orthophoto.enabled())
|
||||
return;
|
||||
// the camera was just published to the renderer (update_camera), so build the view from it rather than
|
||||
// from the renderer's previous frame; camera-relative rotation, same projection as the other overlays
|
||||
glm::dmat4 viewmatrix{1.0};
|
||||
Camera.SetMatrix(viewmatrix);
|
||||
glm::mat4 const view = glm::mat4(glm::mat3(viewmatrix));
|
||||
ImGuiIO const &io = ImGui::GetIO();
|
||||
float const fovy = glm::radians(Global.FieldOfView / Global.ZoomFactor);
|
||||
float const aspect = io.DisplaySize.y > 0.0f ? io.DisplaySize.x / io.DisplaySize.y : 1.0f;
|
||||
m_orthophoto.draw(glm::perspective(fovy, aspect, 0.1f, 10000.0f) * view, Camera.Pos, io.DisplaySize.x, io.DisplaySize.y);
|
||||
}
|
||||
|
||||
editor_terrain *editor_mode::terrain_at(double X, double Z)
|
||||
{
|
||||
for (auto &terrain : m_terrains)
|
||||
@@ -1869,6 +2039,8 @@ void editor_mode::enter()
|
||||
Global.ControlPicking = true;
|
||||
EditorModeFlag = true;
|
||||
|
||||
load_orthophoto_settings();
|
||||
|
||||
Application.set_cursor(GLFW_CURSOR_NORMAL);
|
||||
}
|
||||
|
||||
@@ -1882,6 +2054,7 @@ void editor_mode::exit()
|
||||
g_redo.clear();
|
||||
m_history.clear();
|
||||
m_fill_last.clear();
|
||||
m_orthophoto.cancel_pending();
|
||||
|
||||
// drop selection so a stale/dangling node pointer isn't used on the next editor session
|
||||
m_node = nullptr;
|
||||
|
||||
@@ -18,6 +18,7 @@ http://mozilla.org/MPL/2.0/.
|
||||
#include "scene/scenenode.h"
|
||||
#include "editor/editorTerrain.hpp"
|
||||
#include "editor/editorTerrainStreamer.hpp"
|
||||
#include "editor/editorOrthophoto.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -191,6 +192,16 @@ class editor_mode : public application_mode
|
||||
int m_stream_radius{2};
|
||||
bool m_stream_persist{true}; // save edited chunks to disk and load them back
|
||||
|
||||
// geoportal orthophoto layer drawn under the other viewport overlays
|
||||
void render_orthophoto_ui();
|
||||
void draw_orthophoto();
|
||||
// picks up the stored settings and the origin of the current scenery
|
||||
void load_orthophoto_settings();
|
||||
void save_orthophoto_settings();
|
||||
editor_orthophoto m_orthophoto;
|
||||
std::string m_orthophoto_scenery; // scenery whose origin is loaded
|
||||
glm::dvec2 m_orthophoto_origin_edit{0.0}; // origin being typed in (northing, easting); applied when editing ends
|
||||
|
||||
// hierarchy management
|
||||
void add_to_hierarchy(scene::basic_node *node);
|
||||
void remove_from_hierarchy(scene::basic_node *node);
|
||||
|
||||
963
editor/editorOrthophoto.cpp
Normal file
963
editor/editorOrthophoto.cpp
Normal file
@@ -0,0 +1,963 @@
|
||||
/*
|
||||
This Source Code Form is subject to the
|
||||
terms of the Mozilla Public License, v.
|
||||
2.0. If a copy of the MPL was not
|
||||
distributed with this file, You can
|
||||
obtain one at
|
||||
http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
#include "stdafx.h"
|
||||
#include "editor/editorOrthophoto.hpp"
|
||||
|
||||
#include "rendering/renderer.h"
|
||||
#include "utilities/Logs.h"
|
||||
#include "utilities/utilities.h"
|
||||
#include "imgui/imgui.h"
|
||||
#include "stb/stb_image.h"
|
||||
|
||||
#if defined(_WIN32)
|
||||
#include <winhttp.h>
|
||||
#elif defined(EU07_WITH_CURL)
|
||||
#include <curl/curl.h>
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <climits>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <sstream>
|
||||
|
||||
namespace
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
constexpr int standard_pixels{1024}; // ~25 cm/px for a 256 m tile, matches the standard orthophoto
|
||||
constexpr int hires_pixels{4096}; // ~6 cm/px, the WMS MaxWidth/MaxHeight of the geoportal services
|
||||
constexpr int probe_pixels{64}; // coverage test of the high-resolution product
|
||||
constexpr int worker_count{3}; // concurrent requests; the service is slow but shouldn't be hammered
|
||||
constexpr std::size_t upload_budget{16u << 20}; // bytes of texture data uploaded per frame (always at least one tile)
|
||||
constexpr std::size_t vertex_budget{60000}; // ImGui uses 16-bit indices and not every backend supports vertex offsets
|
||||
constexpr float near_w{0.5f};
|
||||
|
||||
using stop_check = std::function<bool()>;
|
||||
|
||||
double steady_seconds()
|
||||
{
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// minimal blocking https GET; one instance per worker thread
|
||||
|
||||
class http_client
|
||||
{
|
||||
public:
|
||||
http_client();
|
||||
~http_client();
|
||||
http_client(http_client const &) = delete;
|
||||
http_client &operator=(http_client const &) = delete;
|
||||
|
||||
bool get(std::string const &Url, std::vector<std::uint8_t> &Out, std::string &Error, stop_check const &Stop);
|
||||
|
||||
private:
|
||||
#if defined(_WIN32)
|
||||
HINTERNET m_session{nullptr};
|
||||
#elif defined(EU07_WITH_CURL)
|
||||
CURL *m_curl{nullptr};
|
||||
#endif
|
||||
};
|
||||
|
||||
#if defined(_WIN32)
|
||||
|
||||
http_client::http_client()
|
||||
{
|
||||
wchar_t const *agent = L"MaSzyna-editor";
|
||||
#ifdef WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY
|
||||
m_session = ::WinHttpOpen(agent, WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
|
||||
#endif
|
||||
if (m_session == nullptr) // automatic proxy needs windows 8.1+
|
||||
m_session = ::WinHttpOpen(agent, WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
|
||||
if (m_session != nullptr)
|
||||
::WinHttpSetTimeouts(m_session, 10000, 10000, 15000, 20000);
|
||||
}
|
||||
|
||||
http_client::~http_client()
|
||||
{
|
||||
if (m_session != nullptr)
|
||||
::WinHttpCloseHandle(m_session);
|
||||
}
|
||||
|
||||
bool http_client::get(std::string const &Url, std::vector<std::uint8_t> &Out, std::string &Error, stop_check const &Stop)
|
||||
{
|
||||
Out.clear();
|
||||
if (m_session == nullptr)
|
||||
{
|
||||
Error = "WinHttpOpen failed (" + std::to_string(::GetLastError()) + ")";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::wstring const url(Url.begin(), Url.end()); // the requests are plain ascii
|
||||
URL_COMPONENTS parts{};
|
||||
parts.dwStructSize = sizeof(parts);
|
||||
parts.dwSchemeLength = static_cast<DWORD>(-1);
|
||||
parts.dwHostNameLength = static_cast<DWORD>(-1);
|
||||
parts.dwUrlPathLength = static_cast<DWORD>(-1);
|
||||
parts.dwExtraInfoLength = static_cast<DWORD>(-1);
|
||||
if (!::WinHttpCrackUrl(url.c_str(), 0, 0, &parts))
|
||||
{
|
||||
Error = "invalid url";
|
||||
return false;
|
||||
}
|
||||
std::wstring const host(parts.lpszHostName, parts.dwHostNameLength);
|
||||
std::wstring const path = std::wstring(parts.lpszUrlPath, parts.dwUrlPathLength) + std::wstring(parts.lpszExtraInfo, parts.dwExtraInfoLength);
|
||||
|
||||
HINTERNET connection = ::WinHttpConnect(m_session, host.c_str(), parts.nPort, 0);
|
||||
HINTERNET request = connection ? ::WinHttpOpenRequest(connection, L"GET", path.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES,
|
||||
parts.nScheme == INTERNET_SCHEME_HTTPS ? WINHTTP_FLAG_SECURE : 0)
|
||||
: nullptr;
|
||||
auto const close = [&]() {
|
||||
if (request != nullptr)
|
||||
::WinHttpCloseHandle(request);
|
||||
if (connection != nullptr)
|
||||
::WinHttpCloseHandle(connection);
|
||||
};
|
||||
|
||||
if (request == nullptr || !::WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0) || !::WinHttpReceiveResponse(request, nullptr))
|
||||
{
|
||||
Error = "WinHTTP error " + std::to_string(::GetLastError());
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
DWORD status{0};
|
||||
DWORD statussize{sizeof(status)};
|
||||
::WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &status, &statussize, WINHTTP_NO_HEADER_INDEX);
|
||||
|
||||
for (;;)
|
||||
{
|
||||
if (Stop())
|
||||
{
|
||||
Error = "cancelled";
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
DWORD available{0};
|
||||
if (!::WinHttpQueryDataAvailable(request, &available))
|
||||
{
|
||||
Error = "WinHTTP error " + std::to_string(::GetLastError());
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
if (available == 0)
|
||||
break;
|
||||
std::size_t const offset = Out.size();
|
||||
Out.resize(offset + available);
|
||||
DWORD received{0};
|
||||
if (!::WinHttpReadData(request, Out.data() + offset, available, &received))
|
||||
{
|
||||
Error = "WinHTTP error " + std::to_string(::GetLastError());
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
Out.resize(offset + received);
|
||||
}
|
||||
close();
|
||||
|
||||
if (status != 200)
|
||||
{
|
||||
Error = "HTTP " + std::to_string(status);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#elif defined(EU07_WITH_CURL)
|
||||
|
||||
http_client::http_client()
|
||||
{
|
||||
static std::once_flag initialized;
|
||||
std::call_once(initialized, []() { ::curl_global_init(CURL_GLOBAL_DEFAULT); });
|
||||
m_curl = ::curl_easy_init();
|
||||
}
|
||||
|
||||
http_client::~http_client()
|
||||
{
|
||||
if (m_curl != nullptr)
|
||||
::curl_easy_cleanup(m_curl);
|
||||
}
|
||||
|
||||
bool http_client::get(std::string const &Url, std::vector<std::uint8_t> &Out, std::string &Error, stop_check const &Stop)
|
||||
{
|
||||
Out.clear();
|
||||
if (m_curl == nullptr)
|
||||
{
|
||||
Error = "curl_easy_init failed";
|
||||
return false;
|
||||
}
|
||||
|
||||
auto const write = +[](char *Data, std::size_t Size, std::size_t Count, void *User) -> std::size_t {
|
||||
auto *out = static_cast<std::vector<std::uint8_t> *>(User);
|
||||
out->insert(out->end(), Data, Data + Size * Count);
|
||||
return Size * Count;
|
||||
};
|
||||
auto const progress = +[](void *User, curl_off_t, curl_off_t, curl_off_t, curl_off_t) -> int { return (*static_cast<stop_check const *>(User))() ? 1 : 0; };
|
||||
|
||||
::curl_easy_reset(m_curl);
|
||||
::curl_easy_setopt(m_curl, CURLOPT_URL, Url.c_str());
|
||||
::curl_easy_setopt(m_curl, CURLOPT_USERAGENT, "MaSzyna-editor");
|
||||
::curl_easy_setopt(m_curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
::curl_easy_setopt(m_curl, CURLOPT_NOSIGNAL, 1L);
|
||||
::curl_easy_setopt(m_curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
::curl_easy_setopt(m_curl, CURLOPT_LOW_SPEED_LIMIT, 1L);
|
||||
::curl_easy_setopt(m_curl, CURLOPT_LOW_SPEED_TIME, 20L);
|
||||
::curl_easy_setopt(m_curl, CURLOPT_WRITEFUNCTION, write);
|
||||
::curl_easy_setopt(m_curl, CURLOPT_WRITEDATA, &Out);
|
||||
::curl_easy_setopt(m_curl, CURLOPT_NOPROGRESS, 0L);
|
||||
::curl_easy_setopt(m_curl, CURLOPT_XFERINFOFUNCTION, progress);
|
||||
::curl_easy_setopt(m_curl, CURLOPT_XFERINFODATA, const_cast<stop_check *>(&Stop));
|
||||
|
||||
CURLcode const code = ::curl_easy_perform(m_curl);
|
||||
if (code != CURLE_OK)
|
||||
{
|
||||
Error = ::curl_easy_strerror(code);
|
||||
return false;
|
||||
}
|
||||
long status{0};
|
||||
::curl_easy_getinfo(m_curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
if (status != 200)
|
||||
{
|
||||
Error = "HTTP " + std::to_string(status);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
http_client::http_client() = default;
|
||||
http_client::~http_client() = default;
|
||||
|
||||
bool http_client::get(std::string const &, std::vector<std::uint8_t> &Out, std::string &Error, stop_check const &)
|
||||
{
|
||||
Out.clear();
|
||||
Error = "no http client in this build";
|
||||
return false;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// geoportal requests and the disk cache
|
||||
|
||||
struct tile_request
|
||||
{
|
||||
int ix{0}; // easting index
|
||||
int iy{0}; // northing index
|
||||
int level{standard_pixels};
|
||||
int year{0};
|
||||
bool hires{false};
|
||||
};
|
||||
|
||||
struct image
|
||||
{
|
||||
int width{0};
|
||||
int height{0};
|
||||
std::vector<std::uint8_t> rgba;
|
||||
};
|
||||
|
||||
// the archival services return the newest imagery taken up to TIME, but a TIME in the future yields a blank
|
||||
// image, so the end of the requested year is clamped to the current month
|
||||
std::string time_parameter(int Year)
|
||||
{
|
||||
using namespace std::chrono;
|
||||
year_month_day const today{floor<days>(system_clock::now())};
|
||||
int year = Year;
|
||||
unsigned month = 12;
|
||||
int const thisyear = static_cast<int>(today.year());
|
||||
if (year >= thisyear)
|
||||
{
|
||||
year = thisyear;
|
||||
month = static_cast<unsigned>(today.month());
|
||||
}
|
||||
char buffer[32];
|
||||
std::snprintf(buffer, sizeof(buffer), "%04d-%02u-01", year, month);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
std::string wms_url(tile_request const &Request, bool Hires, int Pixels, bool Png)
|
||||
{
|
||||
std::string const service = Hires ? (Request.year ? "HighResolutionTime" : "HighResolution") : (Request.year ? "StandardResolutionTime" : "StandardResolution");
|
||||
std::string const layer = (Hires && Request.year) ? "Image" : "Raster";
|
||||
auto const size = static_cast<long long>(editor_orthophoto::tile_size);
|
||||
long long const minn = Request.iy * size;
|
||||
long long const mine = Request.ix * size;
|
||||
|
||||
std::string url = "https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/" + service +
|
||||
"?SERVICE=WMS&VERSION=1.3.0&REQUEST=GetMap&CRS=EPSG:2180&STYLES=&LAYERS=" + layer;
|
||||
url += Png ? "&FORMAT=image/png&TRANSPARENT=TRUE" : "&FORMAT=image/jpeg";
|
||||
url += "&WIDTH=" + std::to_string(Pixels) + "&HEIGHT=" + std::to_string(Pixels);
|
||||
// WMS 1.3.0 uses the axis order of the CRS, which for EPSG:2180 is northing first
|
||||
url += "&BBOX=" + std::to_string(minn) + "," + std::to_string(mine) + "," + std::to_string(minn + size) + "," + std::to_string(mine + size);
|
||||
if (Request.year)
|
||||
url += "&TIME=" + time_parameter(Request.year);
|
||||
return url;
|
||||
}
|
||||
|
||||
fs::path cache_root()
|
||||
{
|
||||
fs::path root = user_config_path(".cache");
|
||||
if (root.empty())
|
||||
root = ".cache";
|
||||
return root / "geoportal";
|
||||
}
|
||||
|
||||
fs::path cache_file(tile_request const &Request, bool Hires, std::string const &Suffix)
|
||||
{
|
||||
std::string const product = std::string(Hires ? "hr_" : "sr_") + (Request.year ? std::to_string(Request.year) : std::string("latest"));
|
||||
std::string const name = std::to_string(static_cast<int>(editor_orthophoto::tile_size)) + "_" + std::to_string(Request.iy) + "_" + std::to_string(Request.ix) + "_" + Suffix;
|
||||
return cache_root() / product / name;
|
||||
}
|
||||
|
||||
bool is_image(std::vector<std::uint8_t> const &Data)
|
||||
{
|
||||
if (Data.size() >= 3 && Data[0] == 0xFF && Data[1] == 0xD8 && Data[2] == 0xFF)
|
||||
return true; // jpeg
|
||||
return Data.size() >= 4 && Data[0] == 0x89 && Data[1] == 'P' && Data[2] == 'N' && Data[3] == 'G';
|
||||
}
|
||||
|
||||
bool read_file(fs::path const &Path, std::vector<std::uint8_t> &Out)
|
||||
{
|
||||
std::ifstream file(Path, std::ios::binary);
|
||||
if (!file.is_open())
|
||||
return false;
|
||||
Out.assign(std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>());
|
||||
return !Out.empty();
|
||||
}
|
||||
|
||||
void write_file(fs::path const &Path, std::vector<std::uint8_t> const &Data)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::create_directories(Path.parent_path(), ec);
|
||||
// write under a per-thread name and rename, so an interrupted write never leaves a truncated tile behind
|
||||
std::ostringstream suffix;
|
||||
suffix << ".part" << std::this_thread::get_id();
|
||||
fs::path temporary = Path;
|
||||
temporary += suffix.str();
|
||||
{
|
||||
std::ofstream file(temporary, std::ios::binary | std::ios::trunc);
|
||||
if (!file.is_open())
|
||||
return;
|
||||
file.write(reinterpret_cast<char const *>(Data.data()), static_cast<std::streamsize>(Data.size()));
|
||||
if (!file)
|
||||
{
|
||||
file.close();
|
||||
fs::remove(temporary, ec);
|
||||
return;
|
||||
}
|
||||
}
|
||||
fs::rename(temporary, Path, ec);
|
||||
if (ec)
|
||||
fs::remove(temporary, ec);
|
||||
}
|
||||
|
||||
// cached file if present, otherwise downloads (with a few retries, the service drops connections and answers 404 now and then)
|
||||
bool fetch(http_client &Client, std::string const &Url, fs::path const &Path, std::vector<std::uint8_t> &Out, std::string &Error, stop_check const &Stop)
|
||||
{
|
||||
if (read_file(Path, Out) && is_image(Out))
|
||||
return true;
|
||||
|
||||
for (int attempt = 0; attempt < 4; ++attempt)
|
||||
{
|
||||
if (Stop())
|
||||
{
|
||||
Error = "cancelled";
|
||||
return false;
|
||||
}
|
||||
if (attempt > 0)
|
||||
{
|
||||
// back off, but stay responsive to shutdown
|
||||
for (int wait = 0; wait < attempt * 5 && !Stop(); ++wait)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
if (!Client.get(Url, Out, Error, Stop))
|
||||
continue;
|
||||
if (!is_image(Out))
|
||||
{
|
||||
// service exception report (xml) or similar
|
||||
Error = "unexpected response: " + std::string(Out.begin(), Out.begin() + std::min<std::size_t>(Out.size(), 200));
|
||||
continue;
|
||||
}
|
||||
write_file(Path, Out);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool decode(std::vector<std::uint8_t> const &Data, image &Out, std::string &Error)
|
||||
{
|
||||
int width{0}, height{0}, components{0};
|
||||
stbi_uc *pixels = stbi_load_from_memory(Data.data(), static_cast<int>(Data.size()), &width, &height, &components, 4);
|
||||
if (pixels == nullptr)
|
||||
{
|
||||
Error = std::string("image decoding failed: ") + stbi_failure_reason();
|
||||
return false;
|
||||
}
|
||||
Out.width = width;
|
||||
Out.height = height;
|
||||
Out.rgba.assign(pixels, pixels + static_cast<std::size_t>(width) * height * 4);
|
||||
stbi_image_free(pixels);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2x2 box filter
|
||||
void halve(image &Image)
|
||||
{
|
||||
int const width = std::max(1, Image.width / 2);
|
||||
int const height = std::max(1, Image.height / 2);
|
||||
std::vector<std::uint8_t> out(static_cast<std::size_t>(width) * height * 4);
|
||||
for (int y = 0; y < height; ++y)
|
||||
for (int x = 0; x < width; ++x)
|
||||
for (int c = 0; c < 4; ++c)
|
||||
{
|
||||
auto const at = [&](int X, int Y) { return static_cast<unsigned>(Image.rgba[(static_cast<std::size_t>(std::min(Y, Image.height - 1)) * Image.width + std::min(X, Image.width - 1)) * 4 + c]); };
|
||||
out[(static_cast<std::size_t>(y) * width + x) * 4 + c] = static_cast<std::uint8_t>((at(2 * x, 2 * y) + at(2 * x + 1, 2 * y) + at(2 * x, 2 * y + 1) + at(2 * x + 1, 2 * y + 1) + 2) / 4);
|
||||
}
|
||||
Image.width = width;
|
||||
Image.height = height;
|
||||
Image.rgba = std::move(out);
|
||||
}
|
||||
|
||||
// areas without imagery come back plain white (jpeg) or fully transparent (png)
|
||||
bool is_blank(image const &Image)
|
||||
{
|
||||
for (std::size_t i = 0; i + 3 < Image.rgba.size(); i += 4)
|
||||
if (Image.rgba[i + 3] != 0 && (Image.rgba[i] < 250 || Image.rgba[i + 1] < 250 || Image.rgba[i + 2] < 250))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool load_standard(http_client &Client, tile_request const &Request, image &Out, std::string &Error, stop_check const &Stop)
|
||||
{
|
||||
std::vector<std::uint8_t> data;
|
||||
return fetch(Client, wms_url(Request, false, standard_pixels, false), cache_file(Request, false, std::to_string(standard_pixels) + ".jpg"), data, Error, Stop) && decode(data, Out, Error);
|
||||
}
|
||||
|
||||
// returns false on failure (to be retried later); Empty is set when the place has no imagery
|
||||
bool load_tile(http_client &Client, tile_request const &Request, image &Out, bool &Empty, std::string &Error, stop_check const &Stop)
|
||||
{
|
||||
Empty = false;
|
||||
if (Request.hires && Request.level >= hires_pixels)
|
||||
{
|
||||
// the high-resolution product covers only parts of the country; a tiny transparent png tells whether this tile is covered
|
||||
std::vector<std::uint8_t> data;
|
||||
image probe;
|
||||
if (!fetch(Client, wms_url(Request, true, probe_pixels, true), cache_file(Request, true, "probe.png"), data, Error, Stop) || !decode(data, probe, Error))
|
||||
return false;
|
||||
std::size_t covered{0};
|
||||
for (std::size_t i = 3; i < probe.rgba.size(); i += 4)
|
||||
covered += (probe.rgba[i] != 0);
|
||||
std::size_t const total = probe.rgba.size() / 4;
|
||||
|
||||
if (covered == total)
|
||||
{
|
||||
return fetch(Client, wms_url(Request, true, hires_pixels, false), cache_file(Request, true, std::to_string(hires_pixels) + ".jpg"), data, Error, Stop) && decode(data, Out, Error);
|
||||
}
|
||||
if (covered > 0)
|
||||
{
|
||||
// edge of the covered area: transparent png, holes filled from the standard imagery
|
||||
if (!fetch(Client, wms_url(Request, true, hires_pixels, true), cache_file(Request, true, std::to_string(hires_pixels) + ".png"), data, Error, Stop) || !decode(data, Out, Error))
|
||||
return false;
|
||||
image standard;
|
||||
std::string ignored;
|
||||
if (load_standard(Client, Request, standard, ignored, Stop) && standard.width > 0 && standard.height > 0)
|
||||
{
|
||||
for (int y = 0; y < Out.height; ++y)
|
||||
for (int x = 0; x < Out.width; ++x)
|
||||
{
|
||||
std::uint8_t *pixel = &Out.rgba[(static_cast<std::size_t>(y) * Out.width + x) * 4];
|
||||
if (pixel[3] == 255)
|
||||
continue;
|
||||
int const sx = std::min(standard.width - 1, x * standard.width / Out.width);
|
||||
int const sy = std::min(standard.height - 1, y * standard.height / Out.height);
|
||||
std::uint8_t const *source = &standard.rgba[(static_cast<std::size_t>(sy) * standard.width + sx) * 4];
|
||||
unsigned const alpha = pixel[3];
|
||||
for (int c = 0; c < 3; ++c)
|
||||
pixel[c] = static_cast<std::uint8_t>((pixel[c] * alpha + source[c] * (255u - alpha) + 127u) / 255u);
|
||||
pixel[3] = 255;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// not covered at all: fall back to the standard imagery
|
||||
}
|
||||
|
||||
if (!load_standard(Client, Request, Out, Error, Stop))
|
||||
return false;
|
||||
if (is_blank(Out))
|
||||
{
|
||||
Empty = true;
|
||||
Out = image{};
|
||||
return true;
|
||||
}
|
||||
while (Out.width > Request.level && Out.width > 1)
|
||||
halve(Out);
|
||||
return true;
|
||||
}
|
||||
|
||||
// the services cover Poland only (EPSG:2180 extent from their capabilities, plus a margin); no point asking about other places
|
||||
bool in_service_area(int Ix, int Iy)
|
||||
{
|
||||
double const east = (Ix + 0.5) * editor_orthophoto::tile_size;
|
||||
double const north = (Iy + 0.5) * editor_orthophoto::tile_size;
|
||||
return north > 100000.0 && north < 900000.0 && east > 100000.0 && east < 900000.0;
|
||||
}
|
||||
|
||||
// homogeneous clip-space vertex with texture coordinates
|
||||
struct clip_vertex
|
||||
{
|
||||
glm::vec4 position;
|
||||
glm::vec2 uv;
|
||||
};
|
||||
|
||||
// Sutherland-Hodgman against a single clip plane; Distance(v) >= 0 is inside
|
||||
template <typename Distance_> int clip_polygon(clip_vertex const *In, int Count, clip_vertex *Out, Distance_ const &Distance)
|
||||
{
|
||||
int count{0};
|
||||
for (int i = 0; i < Count; ++i)
|
||||
{
|
||||
clip_vertex const &a = In[i];
|
||||
clip_vertex const &b = In[(i + 1) % Count];
|
||||
float const da = Distance(a.position);
|
||||
float const db = Distance(b.position);
|
||||
if (da >= 0.0f)
|
||||
Out[count++] = a;
|
||||
if ((da >= 0.0f) != (db >= 0.0f))
|
||||
{
|
||||
float const t = da / (da - db);
|
||||
Out[count++] = {glm::mix(a.position, b.position, t), glm::mix(a.uv, b.uv, t)};
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
editor_orthophoto::~editor_orthophoto()
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_stop = true;
|
||||
m_queue.clear();
|
||||
}
|
||||
m_wakeup.notify_all();
|
||||
for (auto &worker : m_workers)
|
||||
if (worker.joinable())
|
||||
worker.join();
|
||||
}
|
||||
|
||||
bool editor_orthophoto::can_download()
|
||||
{
|
||||
#if defined(_WIN32) || defined(EU07_WITH_CURL)
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string editor_orthophoto::cache_directory()
|
||||
{
|
||||
auto const path = cache_root().u8string();
|
||||
return std::string(path.begin(), path.end());
|
||||
}
|
||||
|
||||
void editor_orthophoto::enabled(bool State)
|
||||
{
|
||||
if (State == m_enabled)
|
||||
return;
|
||||
m_enabled = State;
|
||||
if (m_enabled)
|
||||
{
|
||||
start_workers();
|
||||
return;
|
||||
}
|
||||
cancel_pending();
|
||||
for (auto &entry : m_tiles)
|
||||
release_tile(entry.second);
|
||||
m_tiles.clear();
|
||||
++m_generation; // results still in flight are now stale
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_done.clear();
|
||||
}
|
||||
|
||||
void editor_orthophoto::settings(config const &Config)
|
||||
{
|
||||
bool const sourcechanged = (Config.year != m_config.year || Config.hires != m_config.hires);
|
||||
m_config = Config;
|
||||
m_config.radius = std::clamp(m_config.radius, 0, max_radius);
|
||||
m_config.opacity = std::clamp(m_config.opacity, 0.0f, 1.0f);
|
||||
if (!sourcechanged)
|
||||
return;
|
||||
cancel_pending();
|
||||
for (auto &entry : m_tiles)
|
||||
release_tile(entry.second);
|
||||
m_tiles.clear();
|
||||
++m_generation;
|
||||
}
|
||||
|
||||
void editor_orthophoto::cancel_pending()
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_queue.clear();
|
||||
}
|
||||
// jobs already picked up by a worker still deliver their result, which is accepted if the tile is still wanted
|
||||
for (auto &entry : m_tiles)
|
||||
entry.second.requested = 0;
|
||||
}
|
||||
|
||||
void editor_orthophoto::retry_failed()
|
||||
{
|
||||
for (auto &entry : m_tiles)
|
||||
{
|
||||
entry.second.failures = 0;
|
||||
entry.second.retry_at = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
editor_orthophoto::statistics editor_orthophoto::stats() const
|
||||
{
|
||||
statistics out;
|
||||
for (auto const &entry : m_tiles)
|
||||
{
|
||||
tile const &t = entry.second;
|
||||
if (t.level > 0 && !t.empty && t.texture != null_handle)
|
||||
++out.resident;
|
||||
if (t.requested != 0)
|
||||
++out.loading;
|
||||
else if (t.failures > 0 && t.level == 0)
|
||||
++out.failed;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int editor_orthophoto::level_for(int Distance, bool Hires)
|
||||
{
|
||||
// coarser imagery further away keeps the memory use of large radii in check
|
||||
if (Distance <= 1)
|
||||
return Hires ? hires_pixels : standard_pixels;
|
||||
if (Distance <= 3)
|
||||
return standard_pixels;
|
||||
if (Distance <= 6)
|
||||
return standard_pixels / 2;
|
||||
return standard_pixels / 4;
|
||||
}
|
||||
|
||||
editor_orthophoto::tile_key editor_orthophoto::camera_tile(glm::dvec3 const &Camera) const
|
||||
{
|
||||
double const east = m_config.east - Camera.x;
|
||||
double const north = m_config.north + Camera.z;
|
||||
return {static_cast<int>(std::floor(east / tile_size)), static_cast<int>(std::floor(north / tile_size))};
|
||||
}
|
||||
|
||||
void editor_orthophoto::start_workers()
|
||||
{
|
||||
if (!m_workers.empty())
|
||||
return;
|
||||
for (int i = 0; i < worker_count; ++i)
|
||||
m_workers.emplace_back(&editor_orthophoto::worker, this);
|
||||
}
|
||||
|
||||
texture_handle editor_orthophoto::acquire_texture()
|
||||
{
|
||||
if (!m_free_textures.empty())
|
||||
{
|
||||
texture_handle const handle = m_free_textures.back();
|
||||
m_free_textures.pop_back();
|
||||
return handle;
|
||||
}
|
||||
// script-style generated texture: no file behind it, the content is supplied through update_from_memory()
|
||||
return GfxRenderer->Fetch_Texture("internal_src:editor_orthophoto_" + std::to_string(m_texture_count++), true);
|
||||
}
|
||||
|
||||
void editor_orthophoto::release_tile(tile &Tile)
|
||||
{
|
||||
if (Tile.texture == null_handle)
|
||||
return;
|
||||
// shrink to a single texel to give the gpu memory back; the slot is reused by the next tile
|
||||
std::uint8_t const texel[4] = {0, 0, 0, 0};
|
||||
GfxRenderer->Texture(Tile.texture).update_from_memory(1, 1, texel);
|
||||
m_free_textures.push_back(Tile.texture);
|
||||
Tile.texture = null_handle;
|
||||
}
|
||||
|
||||
void editor_orthophoto::schedule(tile_key const &Key, tile &Tile, int Level)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_queue.erase(std::remove_if(m_queue.begin(), m_queue.end(), [&](job const &Job) { return Job.key == Key; }), m_queue.end());
|
||||
m_queue.push_back(job{Key, Level, source{m_config.year, m_config.hires}, m_generation.load()});
|
||||
}
|
||||
Tile.requested = Level;
|
||||
m_wakeup.notify_one();
|
||||
}
|
||||
|
||||
std::size_t editor_orthophoto::apply(result &Result, double Now)
|
||||
{
|
||||
if (Result.generation != m_generation.load())
|
||||
return 0;
|
||||
auto const lookup = m_tiles.find(Result.key);
|
||||
if (lookup == m_tiles.end())
|
||||
return 0; // left the area in the meantime; the imagery stays in the disk cache
|
||||
tile &t = lookup->second;
|
||||
bool const current = (t.requested == Result.level);
|
||||
if (current)
|
||||
t.requested = 0;
|
||||
|
||||
if (!Result.ok)
|
||||
{
|
||||
if (current)
|
||||
{
|
||||
++t.failures;
|
||||
t.retry_at = (t.failures >= 5) ? std::numeric_limits<double>::max() : Now + std::min(120.0, 5.0 * std::pow(2.0, t.failures - 1));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
t.failures = 0;
|
||||
t.level = Result.level;
|
||||
t.empty = Result.empty;
|
||||
if (Result.empty)
|
||||
{
|
||||
release_tile(t);
|
||||
return 0;
|
||||
}
|
||||
if (t.texture == null_handle)
|
||||
t.texture = acquire_texture();
|
||||
if (t.texture == null_handle)
|
||||
{
|
||||
t.level = 0;
|
||||
return 0;
|
||||
}
|
||||
GfxRenderer->Texture(t.texture).update_from_memory(Result.width, Result.height, Result.pixels.data());
|
||||
return Result.pixels.size();
|
||||
}
|
||||
|
||||
void editor_orthophoto::update(glm::dvec3 const &Camera)
|
||||
{
|
||||
double const now = steady_seconds();
|
||||
|
||||
// finished work first, within a per-frame upload budget
|
||||
std::size_t uploaded{0};
|
||||
while (uploaded < upload_budget)
|
||||
{
|
||||
result done;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (m_done.empty())
|
||||
break;
|
||||
done = std::move(m_done.front());
|
||||
m_done.pop_front();
|
||||
}
|
||||
if (m_enabled)
|
||||
uploaded += apply(done, now);
|
||||
}
|
||||
|
||||
if (!m_enabled)
|
||||
return;
|
||||
|
||||
tile_key const centre = camera_tile(Camera);
|
||||
if (centre != m_camera_tile)
|
||||
{
|
||||
m_camera_tile = centre;
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_focus = centre;
|
||||
}
|
||||
int const radius = m_config.radius;
|
||||
auto const distance = [&](tile_key const &Key) { return std::max(std::abs(Key.first - centre.first), std::abs(Key.second - centre.second)); };
|
||||
|
||||
// tiles which left the area
|
||||
bool evicted{false};
|
||||
for (auto it = m_tiles.begin(); it != m_tiles.end();)
|
||||
{
|
||||
if (distance(it->first) > radius)
|
||||
{
|
||||
release_tile(it->second);
|
||||
it = m_tiles.erase(it);
|
||||
evicted = true;
|
||||
}
|
||||
else
|
||||
++it;
|
||||
}
|
||||
if (evicted)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_queue.erase(std::remove_if(m_queue.begin(), m_queue.end(), [&](job const &Job) { return distance(Job.key) > radius; }), m_queue.end());
|
||||
}
|
||||
|
||||
// tiles which need (different) imagery
|
||||
for (int dy = -radius; dy <= radius; ++dy)
|
||||
for (int dx = -radius; dx <= radius; ++dx)
|
||||
{
|
||||
tile_key const key{centre.first + dx, centre.second + dy};
|
||||
tile &t = m_tiles[key];
|
||||
if (!in_service_area(key.first, key.second))
|
||||
t.empty = true;
|
||||
int const level = level_for(std::max(std::abs(dx), std::abs(dy)), m_config.hires);
|
||||
if (t.empty || t.level == level || t.requested == level || now < t.retry_at)
|
||||
continue;
|
||||
schedule(key, t, level);
|
||||
}
|
||||
}
|
||||
|
||||
void editor_orthophoto::worker()
|
||||
{
|
||||
stbi_set_flip_vertically_on_load_thread(0); // the texture loader flips globally; tiles keep the north edge in the first row
|
||||
http_client client;
|
||||
stop_check const stopping = [this]() { return m_stop.load(); };
|
||||
|
||||
for (;;)
|
||||
{
|
||||
job current;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_mutex);
|
||||
m_wakeup.wait(lock, [this]() { return m_stop.load() || !m_queue.empty(); });
|
||||
if (m_stop)
|
||||
return;
|
||||
// nearest tile first
|
||||
auto best = m_queue.begin();
|
||||
long long bestdistance = LLONG_MAX;
|
||||
for (auto it = m_queue.begin(); it != m_queue.end(); ++it)
|
||||
{
|
||||
long long const dx = it->key.first - m_focus.first;
|
||||
long long const dy = it->key.second - m_focus.second;
|
||||
long long const d = dx * dx + dy * dy;
|
||||
if (d < bestdistance)
|
||||
{
|
||||
bestdistance = d;
|
||||
best = it;
|
||||
}
|
||||
}
|
||||
current = *best;
|
||||
m_queue.erase(best);
|
||||
}
|
||||
|
||||
result out;
|
||||
out.key = current.key;
|
||||
out.level = current.level;
|
||||
out.generation = current.generation;
|
||||
|
||||
image picture;
|
||||
std::string error;
|
||||
tile_request const request{current.key.first, current.key.second, current.level, current.src.year, current.src.hires};
|
||||
out.ok = load_tile(client, request, picture, out.empty, error, stopping);
|
||||
if (m_stop)
|
||||
return;
|
||||
if (!out.ok)
|
||||
ErrorLog("Editor orthophoto: tile " + std::to_string(current.key.second) + "_" + std::to_string(current.key.first) + " failed: " + error);
|
||||
out.width = picture.width;
|
||||
out.height = picture.height;
|
||||
out.pixels = std::move(picture.rgba);
|
||||
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_done.push_back(std::move(out));
|
||||
}
|
||||
}
|
||||
|
||||
void editor_orthophoto::draw(glm::mat4 const &ViewProjection, glm::dvec3 const &CameraPos, float ScreenWidth, float ScreenHeight) const
|
||||
{
|
||||
if (!m_enabled || m_config.opacity <= 0.0f || ScreenWidth <= 0.0f || ScreenHeight <= 0.0f)
|
||||
return;
|
||||
|
||||
ImDrawList *drawlist = ImGui::GetBackgroundDrawList();
|
||||
ImU32 const color = IM_COL32(255, 255, 255, static_cast<int>(m_config.opacity * 255.0f + 0.5f));
|
||||
auto const tosceen = [&](glm::vec4 const &Clip) { return ImVec2((Clip.x / Clip.w * 0.5f + 0.5f) * ScreenWidth, (0.5f - Clip.y / Clip.w * 0.5f) * ScreenHeight); };
|
||||
// slightly wider than the view so clipped edges never show on screen
|
||||
float constexpr guard{1.02f};
|
||||
auto const outside = [&](glm::vec4 const *Corners) {
|
||||
auto const all = [&](auto const &Test) { return Test(Corners[0]) && Test(Corners[1]) && Test(Corners[2]) && Test(Corners[3]); };
|
||||
return all([](glm::vec4 const &v) { return v.w < near_w; }) || all([&](glm::vec4 const &v) { return v.x > v.w * guard; }) ||
|
||||
all([&](glm::vec4 const &v) { return v.x < -v.w * guard; }) || all([&](glm::vec4 const &v) { return v.y > v.w * guard; }) ||
|
||||
all([&](glm::vec4 const &v) { return v.y < -v.w * guard; });
|
||||
};
|
||||
|
||||
double const height = static_cast<double>(m_config.height);
|
||||
for (auto const &entry : m_tiles)
|
||||
{
|
||||
tile const &t = entry.second;
|
||||
if (t.level == 0 || t.empty || t.texture == null_handle)
|
||||
continue;
|
||||
std::size_t const textureid = GfxRenderer->Texture(t.texture).get_id();
|
||||
if (textureid == 0 || textureid == static_cast<std::size_t>(static_cast<std::uint32_t>(-1)))
|
||||
continue;
|
||||
|
||||
// world extent of the tile; u runs west->east (-x), v runs north->south (-z), matching the image rows
|
||||
double const westx = m_config.east - entry.first.first * tile_size;
|
||||
double const northz = (entry.first.second + 1) * tile_size - m_config.north;
|
||||
auto const relative = [&](double U, double V) { return glm::vec3(glm::dvec3(westx - U * tile_size, height, northz - V * tile_size) - CameraPos); };
|
||||
// the tile is a parallelogram and the projection is linear before the divide, so clip positions interpolate exactly
|
||||
glm::vec4 const c00 = ViewProjection * glm::vec4(relative(0.0, 0.0), 1.0f);
|
||||
glm::vec4 const du = ViewProjection * glm::vec4(relative(1.0, 0.0), 1.0f) - c00;
|
||||
glm::vec4 const dv = ViewProjection * glm::vec4(relative(0.0, 1.0), 1.0f) - c00;
|
||||
glm::vec4 const corners[4] = {c00, c00 + du, c00 + du + dv, c00 + dv};
|
||||
if (outside(corners))
|
||||
continue;
|
||||
|
||||
// ImGui interpolates texture coordinates affinely, so subdivide finer where the camera is close
|
||||
double const nearestx = std::clamp(CameraPos.x, westx - tile_size, westx);
|
||||
double const nearestz = std::clamp(CameraPos.z, northz - tile_size, northz);
|
||||
double const distance = glm::length(glm::dvec3(nearestx, height, nearestz) - CameraPos);
|
||||
double const step = std::clamp(distance * 0.2, 4.0, tile_size);
|
||||
int const segments = std::clamp(static_cast<int>(std::ceil(tile_size / step)), 1, 64);
|
||||
float const segmentsize = 1.0f / static_cast<float>(segments);
|
||||
|
||||
drawlist->PushTextureID(reinterpret_cast<ImTextureID>(textureid));
|
||||
for (int j = 0; j < segments; ++j)
|
||||
for (int i = 0; i < segments; ++i)
|
||||
{
|
||||
if (static_cast<std::size_t>(drawlist->VtxBuffer.Size) + 16 > vertex_budget)
|
||||
{
|
||||
drawlist->PopTextureID();
|
||||
return;
|
||||
}
|
||||
float const u0 = i * segmentsize, u1 = (i + 1) * segmentsize;
|
||||
float const v0 = j * segmentsize, v1 = (j + 1) * segmentsize;
|
||||
clip_vertex quad[4] = {
|
||||
{c00 + du * u0 + dv * v0, {u0, v0}},
|
||||
{c00 + du * u1 + dv * v0, {u1, v0}},
|
||||
{c00 + du * u1 + dv * v1, {u1, v1}},
|
||||
{c00 + du * u0 + dv * v1, {u0, v1}},
|
||||
};
|
||||
glm::vec4 const positions[4] = {quad[0].position, quad[1].position, quad[2].position, quad[3].position};
|
||||
if (outside(positions))
|
||||
continue;
|
||||
|
||||
// clip against the near plane and the (slightly widened) sides of the view
|
||||
clip_vertex buffer0[12], buffer1[12];
|
||||
int count = clip_polygon(quad, 4, buffer0, [](glm::vec4 const &v) { return v.w - near_w; });
|
||||
count = clip_polygon(buffer0, count, buffer1, [&](glm::vec4 const &v) { return v.w * guard - v.x; });
|
||||
count = clip_polygon(buffer1, count, buffer0, [&](glm::vec4 const &v) { return v.w * guard + v.x; });
|
||||
count = clip_polygon(buffer0, count, buffer1, [&](glm::vec4 const &v) { return v.w * guard - v.y; });
|
||||
count = clip_polygon(buffer1, count, buffer0, [&](glm::vec4 const &v) { return v.w * guard + v.y; });
|
||||
if (count < 3)
|
||||
continue;
|
||||
|
||||
drawlist->PrimReserve((count - 2) * 3, count);
|
||||
auto const base = static_cast<ImDrawIdx>(drawlist->_VtxCurrentIdx);
|
||||
for (int k = 1; k + 1 < count; ++k)
|
||||
{
|
||||
drawlist->PrimWriteIdx(base);
|
||||
drawlist->PrimWriteIdx(static_cast<ImDrawIdx>(base + k));
|
||||
drawlist->PrimWriteIdx(static_cast<ImDrawIdx>(base + k + 1));
|
||||
}
|
||||
for (int k = 0; k < count; ++k)
|
||||
drawlist->PrimWriteVtx(tosceen(buffer0[k].position), ImVec2(buffer0[k].uv.x, buffer0[k].uv.y), color);
|
||||
}
|
||||
drawlist->PopTextureID();
|
||||
}
|
||||
}
|
||||
154
editor/editorOrthophoto.hpp
Normal file
154
editor/editorOrthophoto.hpp
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
This Source Code Form is subject to the
|
||||
terms of the Mozilla Public License, v.
|
||||
2.0. If a copy of the MPL was not
|
||||
distributed with this file, You can
|
||||
obtain one at
|
||||
http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include <glm/glm.hpp>
|
||||
|
||||
#include "interfaces/ITexture.h" // texture_handle
|
||||
|
||||
// Editor aid: aerial orthophoto tiles from geoportal.gov.pl (GUGiK WMS), drawn as a translucent
|
||||
// horizontal layer around the camera.
|
||||
//
|
||||
// Tiles are aligned to an absolute 256 m grid in PUWG 1992 (EPSG:2180), so the disk cache is shared
|
||||
// between sceneries. The scenery is placed on that grid through the PUWG coordinates of its (0,0,0)
|
||||
// point; MaSzyna's +Z points north and +X points west, hence easting = E0 - x, northing = N0 + z.
|
||||
//
|
||||
// Downloading, cache I/O and image decoding run on worker threads; the main thread only uploads
|
||||
// finished images (a bounded amount per frame) and emits the overlay geometry. The overlay goes into
|
||||
// the ImGui background draw list, like the other editor viewport overlays, so it works with every
|
||||
// renderer backend, but it isn't depth-tested against the scene.
|
||||
class editor_orthophoto
|
||||
{
|
||||
public:
|
||||
struct config
|
||||
{
|
||||
double north{0.0}; // PUWG 1992 X (northing) of the scenery origin
|
||||
double east{0.0}; // PUWG 1992 Y (easting) of the scenery origin
|
||||
int radius{2}; // tiles loaded around the camera tile (Chebyshev distance)
|
||||
float height{0.0f}; // world Y of the layer
|
||||
float opacity{0.6f}; // 0..1
|
||||
int year{0}; // 0 = newest available imagery, otherwise newest imagery taken up to the end of that year
|
||||
bool hires{false}; // 4096 px tiles from the high-resolution product next to the camera, where it exists
|
||||
};
|
||||
|
||||
struct statistics
|
||||
{
|
||||
int resident{0}; // tiles with imagery on screen
|
||||
int loading{0}; // tiles waiting for or being processed by a worker
|
||||
int failed{0}; // tiles whose last attempt failed
|
||||
};
|
||||
|
||||
static constexpr double tile_size{256.0}; // metres per tile side
|
||||
static constexpr int max_radius{10};
|
||||
static constexpr int oldest_year{1995}; // first year offered by the archival service
|
||||
|
||||
editor_orthophoto() = default;
|
||||
~editor_orthophoto();
|
||||
editor_orthophoto(editor_orthophoto const &) = delete;
|
||||
editor_orthophoto &operator=(editor_orthophoto const &) = delete;
|
||||
|
||||
bool enabled() const { return m_enabled; }
|
||||
// disabling drops the queue and returns the gpu memory of the resident tiles
|
||||
void enabled(bool State);
|
||||
config const &settings() const { return m_config; }
|
||||
// changes of year / resolution invalidate resident tiles, the rest applies immediately
|
||||
void settings(config const &Config);
|
||||
|
||||
// main thread, once per frame: schedules tiles around the camera and uploads finished ones
|
||||
void update(glm::dvec3 const &Camera);
|
||||
// main thread, inside an ImGui frame: emits the overlay into the background draw list.
|
||||
// ViewProjection must map camera-relative world positions (world - CameraPos) to clip space.
|
||||
void draw(glm::mat4 const &ViewProjection, glm::dvec3 const &CameraPos, float ScreenWidth, float ScreenHeight) const;
|
||||
// forgets pending (not yet started) downloads, e.g. when the editor is left
|
||||
void cancel_pending();
|
||||
// clears failure state so failed tiles are attempted again
|
||||
void retry_failed();
|
||||
|
||||
statistics stats() const;
|
||||
// false when the build has no http client; cached tiles are still shown
|
||||
static bool can_download();
|
||||
static std::string cache_directory();
|
||||
|
||||
private:
|
||||
using tile_key = std::pair<int, int>; // (easting index, northing index) on the PUWG tile grid
|
||||
|
||||
struct source
|
||||
{
|
||||
int year{0};
|
||||
bool hires{false};
|
||||
};
|
||||
|
||||
struct job
|
||||
{
|
||||
tile_key key;
|
||||
int level{0}; // requested texture size in pixels
|
||||
source src;
|
||||
std::uint32_t generation{0};
|
||||
};
|
||||
|
||||
struct result
|
||||
{
|
||||
tile_key key;
|
||||
int level{0};
|
||||
std::uint32_t generation{0};
|
||||
bool ok{false};
|
||||
bool empty{false}; // no imagery for this place/year: nothing to draw
|
||||
int width{0};
|
||||
int height{0};
|
||||
std::vector<std::uint8_t> pixels; // RGBA8, first row = north edge
|
||||
};
|
||||
|
||||
struct tile
|
||||
{
|
||||
texture_handle texture{null_handle};
|
||||
int level{0}; // size of the imagery currently held (0 = nothing yet)
|
||||
int requested{0}; // level of the job scheduled for this tile (0 = none)
|
||||
bool empty{false};
|
||||
int failures{0};
|
||||
double retry_at{0.0}; // steady clock seconds
|
||||
};
|
||||
|
||||
static int level_for(int Distance, bool Hires);
|
||||
tile_key camera_tile(glm::dvec3 const &Camera) const;
|
||||
void schedule(tile_key const &Key, tile &Tile, int Level);
|
||||
std::size_t apply(result &Result, double Now); // returns bytes uploaded
|
||||
void release_tile(tile &Tile);
|
||||
texture_handle acquire_texture();
|
||||
void start_workers();
|
||||
void worker();
|
||||
|
||||
config m_config;
|
||||
bool m_enabled{false};
|
||||
std::map<tile_key, tile> m_tiles; // main thread only
|
||||
tile_key m_camera_tile{0, 0};
|
||||
std::vector<texture_handle> m_free_textures; // pool of texture slots, reused between tiles
|
||||
int m_texture_count{0};
|
||||
|
||||
// shared with the workers, guarded by m_mutex
|
||||
mutable std::mutex m_mutex;
|
||||
std::condition_variable m_wakeup;
|
||||
std::deque<job> m_queue;
|
||||
std::deque<result> m_done;
|
||||
tile_key m_focus{0, 0}; // workers pick the queued tile closest to it
|
||||
|
||||
std::atomic<bool> m_stop{false};
|
||||
std::atomic<std::uint32_t> m_generation{1};
|
||||
std::vector<std::thread> m_workers;
|
||||
};
|
||||
@@ -12,9 +12,13 @@ http://mozilla.org/MPL/2.0/.
|
||||
#include "utilities/Logs.h"
|
||||
#include "utilities/utilities.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <locale>
|
||||
#include <sstream>
|
||||
|
||||
editorSettings EditorSettings;
|
||||
|
||||
@@ -32,6 +36,28 @@ const char *scheme_to_string(editorSettings::movement_scheme scheme)
|
||||
{
|
||||
return scheme == editorSettings::movement_scheme::legacy ? "legacy" : "wsad";
|
||||
}
|
||||
|
||||
// numbers are stored locale-independent
|
||||
template <typename Type_> void parse(std::string const &Text, Type_ &Out)
|
||||
{
|
||||
std::istringstream stream(Text);
|
||||
stream.imbue(std::locale::classic());
|
||||
Type_ value;
|
||||
if (stream >> value)
|
||||
Out = value;
|
||||
}
|
||||
|
||||
const std::string origin_prefix{"ortho_origin@"};
|
||||
|
||||
// settings are whitespace separated key/value pairs, so the scenery name used in the key can't contain spaces
|
||||
std::string origin_key(std::string const &Scenery)
|
||||
{
|
||||
std::string key = origin_prefix + Scenery;
|
||||
for (char &c : key)
|
||||
if (std::isspace(static_cast<unsigned char>(c)))
|
||||
c = '_';
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
bool editorSettings::load()
|
||||
@@ -50,6 +76,27 @@ bool editorSettings::load()
|
||||
{
|
||||
if (key == "movement_scheme")
|
||||
m_movement = (value == "legacy") ? movement_scheme::legacy : movement_scheme::wsad;
|
||||
else if (key == "ortho_radius")
|
||||
parse(value, m_orthophoto.radius);
|
||||
else if (key == "ortho_height")
|
||||
parse(value, m_orthophoto.height);
|
||||
else if (key == "ortho_opacity")
|
||||
parse(value, m_orthophoto.opacity);
|
||||
else if (key == "ortho_year")
|
||||
parse(value, m_orthophoto.year);
|
||||
else if (key == "ortho_4k")
|
||||
m_orthophoto.hires = (value == "1");
|
||||
else if (key.rfind(origin_prefix, 0) == 0)
|
||||
{
|
||||
// northing;easting
|
||||
auto const separator = value.find(';');
|
||||
if (separator == std::string::npos)
|
||||
continue;
|
||||
double north{0.0}, east{0.0};
|
||||
parse(value.substr(0, separator), north);
|
||||
parse(value.substr(separator + 1), east);
|
||||
m_orthophoto_origins[key] = {north, east};
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -67,6 +114,30 @@ bool editorSettings::save()
|
||||
return false;
|
||||
}
|
||||
|
||||
stream.imbue(std::locale::classic());
|
||||
stream << "movement_scheme " << scheme_to_string(m_movement) << "\n";
|
||||
stream << "ortho_radius " << m_orthophoto.radius << "\n";
|
||||
stream << "ortho_height " << m_orthophoto.height << "\n";
|
||||
stream << "ortho_opacity " << m_orthophoto.opacity << "\n";
|
||||
stream << "ortho_year " << m_orthophoto.year << "\n";
|
||||
stream << "ortho_4k " << (m_orthophoto.hires ? 1 : 0) << "\n";
|
||||
stream << std::fixed << std::setprecision(3);
|
||||
for (auto const &origin : m_orthophoto_origins)
|
||||
stream << origin.first << " " << origin.second.first << ";" << origin.second.second << "\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
bool editorSettings::orthophoto_origin(std::string const &Scenery, double &North, double &East) const
|
||||
{
|
||||
auto const lookup = m_orthophoto_origins.find(origin_key(Scenery));
|
||||
if (lookup == m_orthophoto_origins.end())
|
||||
return false;
|
||||
North = lookup->second.first;
|
||||
East = lookup->second.second;
|
||||
return true;
|
||||
}
|
||||
|
||||
void editorSettings::orthophoto_origin(std::string const &Scenery, double North, double East)
|
||||
{
|
||||
m_orthophoto_origins[origin_key(Scenery)] = {North, East};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
class editorSettings
|
||||
{
|
||||
public:
|
||||
@@ -18,8 +22,24 @@ public:
|
||||
movement_scheme movement() const { return m_movement; }
|
||||
void movement(movement_scheme scheme) { m_movement = scheme; }
|
||||
|
||||
// geoportal orthophoto layer (editor_orthophoto); the origin is kept per scenery
|
||||
struct orthophoto_settings
|
||||
{
|
||||
int radius{2};
|
||||
float height{0.0f};
|
||||
float opacity{0.6f};
|
||||
int year{0};
|
||||
bool hires{false};
|
||||
};
|
||||
orthophoto_settings &orthophoto() { return m_orthophoto; }
|
||||
// PUWG 1992 northing/easting of the scenery's (0,0,0) point; false when none was stored
|
||||
bool orthophoto_origin(std::string const &Scenery, double &North, double &East) const;
|
||||
void orthophoto_origin(std::string const &Scenery, double North, double East);
|
||||
|
||||
private:
|
||||
movement_scheme m_movement{movement_scheme::wsad};
|
||||
orthophoto_settings m_orthophoto;
|
||||
std::map<std::string, std::pair<double, double>> m_orthophoto_origins;
|
||||
};
|
||||
|
||||
// global editor settings instance
|
||||
|
||||
Reference in New Issue
Block a user