diff --git a/CMakeLists.txt b/CMakeLists.txt index 5f4ae645..1485e520 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}) diff --git a/application/editormode.cpp b/application/editormode.cpp index 1bdd4ed7..4da9fe98 100644 --- a/application/editormode.cpp +++ b/application/editormode.cpp @@ -35,6 +35,7 @@ http://mozilla.org/MPL/2.0/. #include #include #include +#include #include #include #include @@ -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((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(std::chrono::year_month_day{std::chrono::floor(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; diff --git a/application/editormode.h b/application/editormode.h index d8dfa979..c251186a 100644 --- a/application/editormode.h +++ b/application/editormode.h @@ -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 #include @@ -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); diff --git a/editor/editorOrthophoto.cpp b/editor/editorOrthophoto.cpp new file mode 100644 index 00000000..74137b62 --- /dev/null +++ b/editor/editorOrthophoto.cpp @@ -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 +#elif defined(EU07_WITH_CURL) +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; + +double steady_seconds() +{ + return std::chrono::duration(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 &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 &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(-1); + parts.dwHostNameLength = static_cast(-1); + parts.dwUrlPathLength = static_cast(-1); + parts.dwExtraInfoLength = static_cast(-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 &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 *>(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(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)); + + 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 &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 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(system_clock::now())}; + int year = Year; + unsigned month = 12; + int const thisyear = static_cast(today.year()); + if (year >= thisyear) + { + year = thisyear; + month = static_cast(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(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(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 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 &Out) +{ + std::ifstream file(Path, std::ios::binary); + if (!file.is_open()) + return false; + Out.assign(std::istreambuf_iterator(file), std::istreambuf_iterator()); + return !Out.empty(); +} + +void write_file(fs::path const &Path, std::vector 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(Data.data()), static_cast(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 &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(Out.size(), 200)); + continue; + } + write_file(Path, Out); + return true; + } + return false; +} + +bool decode(std::vector 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(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(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 out(static_cast(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(Image.rgba[(static_cast(std::min(Y, Image.height - 1)) * Image.width + std::min(X, Image.width - 1)) * 4 + c]); }; + out[(static_cast(y) * width + x) * 4 + c] = static_cast((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 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 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(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(sy) * standard.width + sx) * 4]; + unsigned const alpha = pixel[3]; + for (int c = 0; c < 3; ++c) + pixel[c] = static_cast((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 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 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 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 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(std::floor(east / tile_size)), static_cast(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 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::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 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 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 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 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 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(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(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(static_cast(-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(std::ceil(tile_size / step)), 1, 64); + float const segmentsize = 1.0f / static_cast(segments); + + drawlist->PushTextureID(reinterpret_cast(textureid)); + for (int j = 0; j < segments; ++j) + for (int i = 0; i < segments; ++i) + { + if (static_cast(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(drawlist->_VtxCurrentIdx); + for (int k = 1; k + 1 < count; ++k) + { + drawlist->PrimWriteIdx(base); + drawlist->PrimWriteIdx(static_cast(base + k)); + drawlist->PrimWriteIdx(static_cast(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(); + } +} diff --git a/editor/editorOrthophoto.hpp b/editor/editorOrthophoto.hpp new file mode 100644 index 00000000..40fdf98e --- /dev/null +++ b/editor/editorOrthophoto.hpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; // (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 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 m_tiles; // main thread only + tile_key m_camera_tile{0, 0}; + std::vector 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 m_queue; + std::deque m_done; + tile_key m_focus{0, 0}; // workers pick the queued tile closest to it + + std::atomic m_stop{false}; + std::atomic m_generation{1}; + std::vector m_workers; +}; diff --git a/editor/editorSettings.cpp b/editor/editorSettings.cpp index 86214d6b..3f7956e2 100644 --- a/editor/editorSettings.cpp +++ b/editor/editorSettings.cpp @@ -12,9 +12,13 @@ http://mozilla.org/MPL/2.0/. #include "utilities/Logs.h" #include "utilities/utilities.h" +#include #include #include #include +#include +#include +#include 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 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(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}; +} diff --git a/editor/editorSettings.hpp b/editor/editorSettings.hpp index 4ed09444..122a6f04 100644 --- a/editor/editorSettings.hpp +++ b/editor/editorSettings.hpp @@ -1,5 +1,9 @@ #pragma once +#include +#include +#include + 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> m_orthophoto_origins; }; // global editor settings instance