17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-17 21:29:19 +02:00
Files
maszyna/application/planpanel.cpp
maj00r bf82c35734 Georeference a new map against a point in Poland
"New map" now asks what is being started. A fictional scenery keeps its own
frame, where coordinates mean nothing outside it. A real one pins the
scenery's zero to a point picked off a map of Poland, so the plan is drawn
in EPSG:2180 - the frame the layout library already works in, and the one
Geoportal serves imagery in.

The projection is the standard transverse Mercator series for PUWG 1992 on
GRS80. Checked against known values: the central meridian comes out at
easting 500000 exactly, Warszawa at 637382/486757, Krakow at 567614/244588,
Gdansk at 477038/720937.

The map behind the picker is a schematic border outline plus the larger
cities, projected from geographic coordinates on first use. It is coarse on
purpose - it is there to point at a region, not to measure against - and the
dialog says so. No network is involved.
2026-08-27 19:36:55 +02:00

763 lines
20 KiB
C++

/*
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 "application/planpanel.h"
#include "application/editormode.h"
#include "editor/polandmap.h"
#include "rendering/renderer.h"
#include "utilities/Globals.h"
namespace
{
// the plan works in EPSG:2180, the scenery in the engine's world space around its own zero. the
// georeference says which point of the projection that zero stands for; a fictional scenery leaves
// it at the origin, so the two frames differ only in the naming of the axes - easting runs along
// world x, northing against world z
glm::dvec3 plan_to_world(double const X, double const Y)
{
return {X - Global.scenery_origin.x, 0.0, -(Y - Global.scenery_origin.y)};
}
void world_to_plan(glm::dvec3 const &World, double &X, double &Y)
{
X = World.x + Global.scenery_origin.x;
Y = -World.z + Global.scenery_origin.y;
}
// places a world point on screen using the camera of the most recent colour pass. the engine renders
// camera-relative, so the view matrix carries rotation only and the position is subtracted here
bool world_to_screen(glm::dvec3 const &World, ImVec2 &Screen)
{
auto const relative{glm::vec3(World - GfxRenderer->Camera_Position())};
auto const clip{GfxRenderer->Camera_Projection_Matrix() * GfxRenderer->Camera_View_Matrix() * glm::vec4(relative, 1.0f)};
if (clip.w <= 0.0f)
{
return false; // behind the camera
}
auto const ndc{glm::vec3(clip) / clip.w};
auto const display{ImGui::GetIO().DisplaySize};
Screen = {(ndc.x * 0.5f + 0.5f) * display.x, (0.5f - ndc.y * 0.5f) * display.y};
return true;
}
ImU32 element_colour(int const Kind, bool const Active)
{
auto const alpha{Active ? 255 : 110};
switch (Kind)
{
case 1: // arc
return IM_COL32(255, 190, 90, alpha);
case 2: // transition curve
return IM_COL32(140, 245, 150, alpha);
default: // straight
return IM_COL32(125, 200, 255, alpha);
}
}
} // namespace
plan_panel::plan_panel(std::string const &Name, bool const Isopen) : ui_panel(Name, Isopen)
{
size_min = {420, 240};
size_max = {900, 900};
m_document.niwelety.push_back({"niweleta 1", {}, {}});
solve();
}
void plan_panel::update()
{
// the plan belongs on the scenery itself, so opening the tool puts the viewport into the
// distortion-free view from above and closing it hands the camera back
Global.editor_ortho = is_open;
}
void plan_panel::render_contents()
{
handle_scene();
draw_on_scene();
render_toolbar();
render_gaps();
render_storage();
render_newmap_dialog();
render_location_dialog();
}
void plan_panel::render_newmap_dialog()
{
if (false == ImGui::BeginPopupModal("New map", nullptr, ImGuiWindowFlags_AlwaysAutoResize))
{
return;
}
ImGui::TextUnformatted("What is this scenery going to be?");
ImGui::Spacing();
if (ImGui::Button("Fictional", ImVec2(160.0f, 0.0f)))
{
start_map(false, 0.0, 0.0);
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
ImGui::TextDisabled("coordinates mean nothing outside the scenery");
if (ImGui::Button("Real place", ImVec2(160.0f, 0.0f)))
{
// start the picker on the middle of the country, showing the whole of it
m_pickingplace = true;
m_pickx = 500000.0;
m_picky = 350000.0;
m_mapviewx = m_pickx;
m_mapviewy = m_picky;
m_mapscale = 0.0;
ImGui::CloseCurrentPopup();
ImGui::OpenPopup("Scenery location");
}
ImGui::SameLine();
ImGui::TextDisabled("pin the scenery's zero to a point in Poland");
ImGui::Spacing();
if (ImGui::Button("Cancel"))
{
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
void plan_panel::render_location_dialog()
{
if (m_pickingplace)
{
// the popup has to be opened from the same stack level it is drawn on
ImGui::OpenPopup("Scenery location");
m_pickingplace = false;
}
if (false == ImGui::BeginPopupModal("Scenery location", nullptr, ImGuiWindowFlags_AlwaysAutoResize))
{
return;
}
ImVec2 const mapsize{560.0f, 460.0f};
auto const origin{ImGui::GetCursorScreenPos()};
ImGui::InvisibleButton("poland_map", mapsize);
auto const hovered{ImGui::IsItemHovered()};
auto const &outline{editor::poland_outline()};
auto const &places{editor::poland_places()};
// fit the whole country the first time round; afterwards the wheel decides
if (m_mapscale <= 0.0)
{
auto minx{std::numeric_limits<double>::max()};
auto miny{std::numeric_limits<double>::max()};
auto maxx{std::numeric_limits<double>::lowest()};
auto maxy{std::numeric_limits<double>::lowest()};
for (auto const &point : outline)
{
minx = std::min(minx, point.x);
maxx = std::max(maxx, point.x);
miny = std::min(miny, point.y);
maxy = std::max(maxy, point.y);
}
m_mapviewx = (minx + maxx) * 0.5;
m_mapviewy = (miny + maxy) * 0.5;
m_mapscale = std::min(mapsize.x / std::max(1.0, maxx - minx), mapsize.y / std::max(1.0, maxy - miny)) * 0.92;
}
ImVec2 const centre{origin.x + mapsize.x * 0.5f, origin.y + mapsize.y * 0.5f};
auto const to_screen = [&](double const X, double const Y) {
return ImVec2{centre.x + static_cast<float>((X - m_mapviewx) * m_mapscale), centre.y - static_cast<float>((Y - m_mapviewy) * m_mapscale)};
};
auto const to_map = [&](ImVec2 const &Point, double &X, double &Y) {
X = m_mapviewx + (Point.x - centre.x) / m_mapscale;
Y = m_mapviewy - (Point.y - centre.y) / m_mapscale;
};
auto const mouse{ImGui::GetIO().MousePos};
if (hovered && ImGui::GetIO().MouseWheel != 0.0f)
{
double anchorx{0.0};
double anchory{0.0};
to_map(mouse, anchorx, anchory);
m_mapscale = std::clamp(m_mapscale * std::pow(1.2, ImGui::GetIO().MouseWheel), 1e-5, 1.0);
m_mapviewx = anchorx - (mouse.x - centre.x) / m_mapscale;
m_mapviewy = anchory + (mouse.y - centre.y) / m_mapscale;
}
if (hovered && ImGui::IsMouseClicked(0))
{
to_map(mouse, m_pickx, m_picky);
}
auto *drawlist{ImGui::GetWindowDrawList()};
drawlist->PushClipRect(origin, ImVec2(origin.x + mapsize.x, origin.y + mapsize.y), true);
drawlist->AddRectFilled(origin, ImVec2(origin.x + mapsize.x, origin.y + mapsize.y), IM_COL32(22, 30, 38, 255));
std::vector<ImVec2> border;
border.reserve(outline.size());
for (auto const &point : outline)
{
border.push_back(to_screen(point.x, point.y));
}
drawlist->AddPolyline(border.data(), static_cast<int>(border.size()), IM_COL32(120, 190, 140, 255), true, 1.5f);
for (auto const &place : places)
{
auto const point{to_screen(place.position.x, place.position.y)};
drawlist->AddCircleFilled(point, 3.0f, IM_COL32(200, 200, 200, 255));
drawlist->AddText(ImVec2(point.x + 5.0f, point.y - 6.0f), IM_COL32(190, 190, 190, 255), place.name.c_str());
}
auto const picked{to_screen(m_pickx, m_picky)};
drawlist->AddCircleFilled(picked, 5.0f, IM_COL32(255, 210, 90, 255));
drawlist->AddLine(ImVec2(picked.x - 12.0f, picked.y), ImVec2(picked.x + 12.0f, picked.y), IM_COL32(255, 210, 90, 200));
drawlist->AddLine(ImVec2(picked.x, picked.y - 12.0f), ImVec2(picked.x, picked.y + 12.0f), IM_COL32(255, 210, 90, 200));
drawlist->PopClipRect();
ImGui::TextDisabled("schematic outline - click to place the scenery's zero, wheel zooms");
ImGui::SetNextItemWidth(180.0f);
ImGui::InputDouble("easting", &m_pickx, 100.0, 1000.0, "%.0f");
ImGui::SameLine();
ImGui::SetNextItemWidth(180.0f);
ImGui::InputDouble("northing", &m_picky, 100.0, 1000.0, "%.0f");
ImGui::SetNextItemWidth(220.0f);
if (ImGui::BeginCombo("go to", "pick a city"))
{
for (auto const &place : places)
{
if (ImGui::Selectable(place.name.c_str()))
{
m_pickx = place.position.x;
m_picky = place.position.y;
m_mapviewx = m_pickx;
m_mapviewy = m_picky;
}
}
ImGui::EndCombo();
}
ImGui::Spacing();
if (ImGui::Button("Use this place", ImVec2(160.0f, 0.0f)))
{
start_map(true, m_pickx, m_picky);
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel"))
{
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
void plan_panel::start_map(bool const Georeferenced, double const Originx, double const Originy)
{
Global.scenery_georeferenced = Georeferenced;
Global.scenery_origin = {Originx, Originy};
m_document.niwelety.clear();
m_document.niwelety.push_back({"niweleta 1", {}, {}});
m_niweleta = 0;
m_pending = false;
m_draggedvertex = -1;
solve();
// the scenery's zero is where the work starts, whichever frame it stands for
auto &camera{editor_mode::get_camera()};
camera.Pos.x = 0.0;
camera.Pos.z = 0.0;
m_status = Georeferenced ? "map pinned at " + to_string(Originx, 0) + ", " + to_string(Originy, 0) + " (EPSG:2180)" : "fictional map";
}
void plan_panel::handle_scene()
{
// clicks that land on a panel belong to the panel, not to the ground under it
if (ImGui::GetIO().WantCaptureMouse)
{
m_draggedvertex = -1;
return;
}
double cursorx{0.0};
double cursory{0.0};
world_to_plan(Global.pCamera.Pos + GfxRenderer->Mouse_Position(), cursorx, cursory);
auto const mouse{ImGui::GetIO().MousePos};
// grabbing an existing point comes first, so corners can be nudged into place
if (ImGui::IsMouseClicked(0))
{
m_draggedvertex = -1;
for (std::size_t i = 0; i < vertex_count(); ++i)
{
double x{0.0};
double y{0.0};
vertex_position(i, x, y);
ImVec2 point;
if (false == world_to_screen(plan_to_world(x, y), point))
{
continue;
}
if (std::abs(point.x - mouse.x) <= 8.0f && std::abs(point.y - mouse.y) <= 8.0f)
{
m_draggedvertex = static_cast<int>(i);
break;
}
}
if (m_draggedvertex == -1 && m_drawing)
{
append_vertex(cursorx, cursory);
}
}
if (m_draggedvertex != -1)
{
if (ImGui::IsMouseDown(0))
{
move_vertex(static_cast<std::size_t>(m_draggedvertex), cursorx, cursory);
}
else
{
m_draggedvertex = -1;
}
}
}
void plan_panel::draw_on_scene()
{
// the background list paints over the rendered scenery but under every window, so the controls
// stay readable on top of the drawing
auto *drawlist{ImGui::GetBackgroundDrawList()};
std::vector<ImVec2> points;
for (std::size_t n = 0; n < m_solved.size(); ++n)
{
auto const active{n == m_niweleta};
for (auto const &polyline : m_solved[n].polylines)
{
if (polyline.points.size() < 2)
{
continue;
}
points.clear();
points.reserve(polyline.points.size());
for (auto const &point : polyline.points)
{
ImVec2 screen;
if (false == world_to_screen(plan_to_world(point.x, point.y), screen))
{
continue;
}
points.push_back(screen);
}
if (points.size() < 2)
{
continue;
}
drawlist->AddPolyline(points.data(), static_cast<int>(points.size()), element_colour(polyline.kind, active), false, active ? 2.5f : 1.5f);
}
}
// editable points of the niweleta being drawn
for (std::size_t i = 0; i < vertex_count(); ++i)
{
double x{0.0};
double y{0.0};
vertex_position(i, x, y);
ImVec2 screen;
if (false == world_to_screen(plan_to_world(x, y), screen))
{
continue;
}
drawlist->AddCircleFilled(screen, 5.0f, static_cast<int>(i) == m_draggedvertex ? IM_COL32(255, 240, 120, 255) : IM_COL32(240, 240, 240, 255));
}
// the corner still waiting for the one that closes its straight, and the rubber band to the cursor
auto const mouse{ImGui::GetIO().MousePos};
auto const overscenery{false == ImGui::GetIO().WantCaptureMouse};
if (m_pending)
{
ImVec2 screen;
if (world_to_screen(plan_to_world(m_pendingx, m_pendingy), screen))
{
drawlist->AddCircleFilled(screen, 5.0f, IM_COL32(255, 240, 120, 255));
if (overscenery)
{
drawlist->AddLine(screen, mouse, IM_COL32(255, 240, 120, 170), 1.5f);
}
}
}
else if (overscenery && m_drawing && m_draggedvertex == -1 && vertex_count() > 0)
{
double x{0.0};
double y{0.0};
vertex_position(vertex_count() - 1, x, y);
ImVec2 screen;
if (world_to_screen(plan_to_world(x, y), screen))
{
drawlist->AddLine(screen, mouse, IM_COL32(255, 240, 120, 170), 1.5f);
}
}
}
void plan_panel::render_toolbar()
{
auto *niweleta{current_niweleta()};
ImGui::Text("top-down view, %.0f m across", Global.editor_ortho_extent * 2.0f);
ImGui::TextDisabled("click the scenery to lay points, drag a point to move it, wheel zooms");
if (Global.scenery_georeferenced)
{
ImGui::Text("zero at %.0f, %.0f (EPSG:2180)", Global.scenery_origin.x, Global.scenery_origin.y);
}
else
{
ImGui::TextDisabled("fictional map, no georeference");
}
ImGui::Separator();
if (ImGui::Button("New map"))
{
ImGui::OpenPopup("New map");
}
ImGui::SameLine();
if (ImGui::Button("New niweleta"))
{
m_document.niwelety.push_back({"niweleta " + std::to_string(m_document.niwelety.size() + 1), {}, {}});
m_niweleta = m_document.niwelety.size() - 1;
m_pending = false;
solve();
}
ImGui::SameLine();
if (ImGui::Button("Undo point"))
{
drop_last_vertex();
}
ImGui::SameLine();
if (ImGui::Button("Go to plan"))
{
go_to_plan();
}
ImGui::SameLine();
ImGui::Checkbox("Draw", &m_drawing);
if (m_document.niwelety.size() > 1)
{
ImGui::SetNextItemWidth(220.0f);
if (ImGui::BeginCombo("Edited", m_document.niwelety[m_niweleta].name.c_str()))
{
for (std::size_t i = 0; i < m_document.niwelety.size(); ++i)
{
if (ImGui::Selectable(m_document.niwelety[i].name.c_str(), i == m_niweleta))
{
m_niweleta = i;
m_pending = false;
}
}
ImGui::EndCombo();
}
}
ImGui::Text("straights: %d, points: %d", niweleta != nullptr ? static_cast<int>(niweleta->straights.size()) : 0, static_cast<int>(vertex_count()));
}
void plan_panel::render_gaps()
{
auto *niweleta{current_niweleta()};
if (niweleta == nullptr || niweleta->straights.size() < 2)
{
return;
}
ImGui::Separator();
if (false == ImGui::CollapsingHeader("Fits", ImGuiTreeNodeFlags_DefaultOpen))
{
return;
}
ImGui::BeginChild("plan_gaps", ImVec2(0.0f, 130.0f), false);
for (std::size_t gap = 0; gap < niweleta->fits.size(); ++gap)
{
auto &fit{niweleta->fits[gap]};
ImGui::PushID(static_cast<int>(gap));
ImGui::Text("gap %d", static_cast<int>(gap));
ImGui::SameLine(70.0f);
ImGui::SetNextItemWidth(150.0f);
if (ImGui::Combo("##mode", &fit.mode, "none\0arc\0arc + clothoids\0"))
{
if (fit.mode != 0 && fit.radius <= 0.0)
{
fit.radius = 300.0; // something drawable to start from, rather than a rejected fit
}
solve();
}
if (fit.mode != 0)
{
ImGui::SameLine();
ImGui::SetNextItemWidth(120.0f);
if (ImGui::InputDouble("R", &fit.radius, 10.0, 100.0, "%.1f"))
{
solve();
}
}
if (fit.mode == 2)
{
ImGui::SameLine();
ImGui::SetNextItemWidth(120.0f);
if (ImGui::InputDouble("L", &fit.transition, 5.0, 20.0, "%.1f"))
{
solve();
}
}
// a fit the library could not produce is simply absent from its answer; say so rather than
// leaving the parameters looking as though they took effect
if (fit.mode != 0 && false == fit_applied(gap))
{
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), "does not fit");
}
ImGui::PopID();
}
ImGui::EndChild();
}
void plan_panel::render_storage()
{
ImGui::Separator();
ImGui::SetNextItemWidth(240.0f);
ImGui::InputText("##path", m_path, sizeof(m_path));
ImGui::SameLine();
if (ImGui::Button("Save"))
{
m_status = maj0sted::app::save_project(m_document, m_path) ? "saved to " + std::string(m_path) : "could not write " + std::string(m_path);
}
ImGui::SameLine();
if (ImGui::Button("Load"))
{
auto const loaded{maj0sted::app::load_project(m_path)};
if (loaded)
{
m_document = *loaded;
if (m_document.niwelety.empty())
{
m_document.niwelety.push_back({"niweleta 1", {}, {}});
}
m_niweleta = 0;
m_pending = false;
m_draggedvertex = -1;
solve();
go_to_plan();
m_status = "loaded " + std::string(m_path);
}
else
{
m_status = "could not read " + std::string(m_path);
}
}
if (false == m_status.empty())
{
ImGui::TextDisabled("%s", m_status.c_str());
}
}
void plan_panel::solve()
{
align_fits();
m_solved = maj0sted::web::solve_project(m_document.niwelety);
}
maj0sted::web::NiweletaSpec *plan_panel::current_niweleta()
{
return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr;
}
maj0sted::web::NiweletaSpec const *plan_panel::current_niweleta() const
{
return m_niweleta < m_document.niwelety.size() ? &m_document.niwelety[m_niweleta] : nullptr;
}
void plan_panel::align_fits()
{
for (auto &niweleta : m_document.niwelety)
{
auto const gaps{niweleta.straights.empty() ? std::size_t{0} : niweleta.straights.size() - 1};
std::vector<maj0sted::web::GapFit> slots(gaps);
for (std::size_t gap = 0; gap < gaps; ++gap)
{
slots[gap].gap = static_cast<int>(gap);
}
// a document read from disk carries only the gaps that were actually fitted
for (auto const &fit : niweleta.fits)
{
if (fit.gap >= 0 && static_cast<std::size_t>(fit.gap) < gaps)
{
slots[fit.gap] = fit;
}
}
niweleta.fits = std::move(slots);
}
}
bool plan_panel::fit_applied(std::size_t const Gap) const
{
if (m_niweleta >= m_solved.size())
{
return false;
}
for (auto const &fit : m_solved[m_niweleta].applied_fits)
{
if (fit.gap >= 0 && static_cast<std::size_t>(fit.gap) == Gap)
{
return true;
}
}
return false;
}
std::size_t plan_panel::vertex_count() const
{
auto const *niweleta{current_niweleta()};
return (niweleta == nullptr || niweleta->straights.empty()) ? 0 : niweleta->straights.size() + 1;
}
void plan_panel::vertex_position(std::size_t const Index, double &X, double &Y) const
{
auto const *niweleta{current_niweleta()};
if (niweleta == nullptr || niweleta->straights.empty() || Index >= niweleta->straights.size() + 1)
{
return;
}
if (Index == 0)
{
X = niweleta->straights.front().x1;
Y = niweleta->straights.front().y1;
return;
}
X = niweleta->straights[Index - 1].x2;
Y = niweleta->straights[Index - 1].y2;
}
void plan_panel::append_vertex(double const X, double const Y)
{
auto *niweleta{current_niweleta()};
if (niweleta == nullptr)
{
return;
}
if (niweleta->straights.empty())
{
if (false == m_pending)
{
// nothing to attach to yet, so the first corner waits for the one that closes the straight
m_pending = true;
m_pendingx = X;
m_pendingy = Y;
return;
}
m_pending = false;
niweleta->straights.push_back({m_pendingx, m_pendingy, X, Y, false});
}
else
{
auto const &last{niweleta->straights.back()};
niweleta->straights.push_back({last.x2, last.y2, X, Y, false});
}
solve();
}
void plan_panel::move_vertex(std::size_t const Index, double const X, double const Y)
{
auto *niweleta{current_niweleta()};
if (niweleta == nullptr || Index >= niweleta->straights.size() + 1)
{
return;
}
// the point belongs to both straights meeting at it, so both ends follow it
if (Index > 0)
{
niweleta->straights[Index - 1].x2 = X;
niweleta->straights[Index - 1].y2 = Y;
}
if (Index < niweleta->straights.size())
{
niweleta->straights[Index].x1 = X;
niweleta->straights[Index].y1 = Y;
}
solve();
}
void plan_panel::drop_last_vertex()
{
if (m_pending)
{
m_pending = false;
return;
}
auto *niweleta{current_niweleta()};
if (niweleta == nullptr || niweleta->straights.empty())
{
return;
}
niweleta->straights.pop_back();
solve();
}
void plan_panel::go_to_plan()
{
auto minx{std::numeric_limits<double>::max()};
auto miny{std::numeric_limits<double>::max()};
auto maxx{std::numeric_limits<double>::lowest()};
auto maxy{std::numeric_limits<double>::lowest()};
for (auto const &niweleta : m_document.niwelety)
{
for (auto const &straight : niweleta.straights)
{
minx = std::min({minx, straight.x1, straight.x2});
maxx = std::max({maxx, straight.x1, straight.x2});
miny = std::min({miny, straight.y1, straight.y2});
maxy = std::max({maxy, straight.y1, straight.y2});
}
}
if (minx > maxx)
{
return; // nothing drawn yet, so there is nowhere to go
}
auto &camera{editor_mode::get_camera()};
auto const centre{plan_to_world((minx + maxx) * 0.5, (miny + maxy) * 0.5)};
camera.Pos.x = centre.x;
camera.Pos.z = centre.z;
// show the whole drawing with a little room around it
auto const span{std::max({maxx - minx, maxy - miny, 50.0})};
Global.editor_ortho_extent = std::clamp(static_cast<float>(span * 0.6), 5.0f, 20000.0f);
}