17
0
mirror of https://github.com/MaSzyna-EU07/maszyna.git synced 2026-10-17 09:49:19 +02:00
Files
maszyna/application/planpanel.cpp
maj00r db49039b9d Put the editor viewport into a top-down orthographic plan view
Opening the Plan tool now switches the whole colour pass to an orthographic
projection and pins the camera looking straight down, so the plan is drawn
against the loaded scenery rather than in a window of its own. Closing the
tool hands the camera's orientation back; leaving the editor clears the view
outright. The wheel changes how much ground fits on screen instead of moving
the camera, which with no perspective would change nothing but the clipping.

Both configured backends follow: the experimental renderer swaps the pass
projection (and fits its shadow cascades to it), the modern GL renderer uses
its existing ortho helpers. Everything reading the pass projection - the
mouse ray, the frustum test - picks the change up on its own.

The experimental renderer also publishes its colour pass camera now, which
it previously left at the interface defaults, so overlays can place world
points on screen.
2026-08-27 19:36:55 +02:00

597 lines
15 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 "utilities/Globals.h"
namespace
{
// screen placement of the canvas together with the plan point it is centred on; rebuilt every frame
// from the panel's view state, so the transform never goes stale against a resized window
struct canvas_view
{
ImVec2 origin{0.f, 0.f};
ImVec2 size{0.f, 0.f};
double viewx{0.0};
double viewy{0.0};
double scale{1.0};
ImVec2 centre() const { return {origin.x + size.x * 0.5f, origin.y + size.y * 0.5f}; }
// northing grows towards the top of the screen, so the vertical axis is flipped
ImVec2 to_screen(double const X, double const Y) const
{
auto const middle{centre()};
return {middle.x + static_cast<float>((X - viewx) * scale), middle.y - static_cast<float>((Y - viewy) * scale)};
}
void to_plan(ImVec2 const &Point, double &X, double &Y) const
{
auto const middle{centre()};
X = viewx + (Point.x - middle.x) / scale;
Y = viewy - (Point.y - middle.y) / scale;
}
};
// grid spacing is picked so the cells stay roughly the same size on screen whatever the zoom, and
// always lands on a round number of metres a surveyor would recognise
double grid_step(double const Scale)
{
auto const target{80.0 / std::max(Scale, 1e-6)}; // metres per cell at the wanted screen size
auto const magnitude{std::pow(10.0, std::floor(std::log10(std::max(target, 1e-6))))};
auto const steps = {1.0, 2.0, 5.0, 10.0};
for (auto const step : steps)
{
if (magnitude * step >= target)
{
return magnitude * step;
}
}
return magnitude * 10.0;
}
ImU32 element_colour(int const Kind, bool const Active)
{
auto const alpha{Active ? 255 : 90};
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 = {520, 460};
size_max = {2000, 1600};
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()
{
ImGui::Text("view: top-down, %.0f m across", Global.editor_ortho_extent * 2.0f);
ImGui::SameLine();
ImGui::TextDisabled("(wheel over the scenery zooms it)");
render_toolbar();
render_canvas();
render_gaps();
render_storage();
}
void plan_panel::render_toolbar()
{
auto *niweleta{current_niweleta()};
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("Frame all"))
{
frame_all();
}
ImGui::SameLine();
ImGui::Checkbox("Draw", &m_drawing);
if (m_document.niwelety.size() > 1)
{
auto const preview{m_document.niwelety[m_niweleta].name};
ImGui::SetNextItemWidth(220.0f);
if (ImGui::BeginCombo("Edited", preview.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()));
ImGui::Separator();
}
void plan_panel::render_canvas()
{
auto const available{ImGui::GetContentRegionAvail()};
// the tables below the canvas need room of their own, but the canvas takes whatever is left
ImVec2 const canvassize{std::max(available.x, 240.0f), std::max(available.y - 210.0f, 200.0f)};
canvas_view view;
view.origin = ImGui::GetCursorScreenPos();
view.size = canvassize;
view.viewx = m_viewx;
view.viewy = m_viewy;
view.scale = m_scale;
ImGui::InvisibleButton("plan_canvas", canvassize);
auto const hovered{ImGui::IsItemHovered()};
auto const mouse{ImGui::GetIO().MousePos};
double mousex{0.0};
double mousey{0.0};
view.to_plan(mouse, mousex, mousey);
// zoom around the cursor, so the ground under it stays put
if (hovered && ImGui::GetIO().MouseWheel != 0.0f)
{
m_scale = std::clamp(m_scale * std::pow(1.15, ImGui::GetIO().MouseWheel), 0.002, 40.0);
view.scale = m_scale;
auto const middle{view.centre()};
m_viewx = mousex - (mouse.x - middle.x) / m_scale;
m_viewy = mousey + (mouse.y - middle.y) / m_scale;
view.viewx = m_viewx;
view.viewy = m_viewy;
}
// panning latches on, so the view keeps following the cursor once it leaves the canvas
if (hovered && ImGui::IsMouseDragging(1))
{
m_panning = true;
}
if (m_panning)
{
if (false == ImGui::IsMouseDragging(1))
{
m_panning = false;
}
else
{
auto const delta{ImGui::GetIO().MouseDelta};
m_viewx -= delta.x / m_scale;
m_viewy += delta.y / m_scale;
view.viewx = m_viewx;
view.viewy = m_viewy;
}
}
// grab an existing point before laying a new one, so corners can be nudged into place
if (hovered && 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);
auto const point{view.to_screen(x, y)};
if (std::abs(point.x - mouse.x) <= 6.0f && std::abs(point.y - mouse.y) <= 6.0f)
{
m_draggedvertex = static_cast<int>(i);
break;
}
}
if (m_draggedvertex == -1 && m_drawing)
{
append_vertex(mousex, mousey);
}
}
if (m_draggedvertex != -1)
{
if (ImGui::IsMouseDown(0))
{
move_vertex(static_cast<std::size_t>(m_draggedvertex), mousex, mousey);
}
else
{
m_draggedvertex = -1;
}
}
auto *drawlist{ImGui::GetWindowDrawList()};
auto const canvasend{ImVec2(view.origin.x + canvassize.x, view.origin.y + canvassize.y)};
drawlist->PushClipRect(view.origin, canvasend, true);
drawlist->AddRectFilled(view.origin, canvasend, IM_COL32(24, 26, 30, 255));
// grid, with the project origin picked out
auto const step{grid_step(m_scale)};
double left{0.0};
double top{0.0};
double right{0.0};
double bottom{0.0};
view.to_plan(view.origin, left, top);
view.to_plan(canvasend, right, bottom);
for (auto x = std::floor(left / step) * step; x <= right; x += step)
{
auto const colour{std::abs(x) < step * 0.5 ? IM_COL32(90, 90, 110, 255) : IM_COL32(48, 50, 58, 255)};
drawlist->AddLine(view.to_screen(x, top), view.to_screen(x, bottom), colour);
}
for (auto y = std::floor(bottom / step) * step; y <= top; y += step)
{
auto const colour{std::abs(y) < step * 0.5 ? IM_COL32(90, 90, 110, 255) : IM_COL32(48, 50, 58, 255)};
drawlist->AddLine(view.to_screen(left, y), view.to_screen(right, y), colour);
}
// solved geometry: two rails per centreline, coloured by the kind of element they came from
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)
{
points.push_back(view.to_screen(point.x, point.y));
}
drawlist->AddPolyline(points.data(), static_cast<int>(points.size()), element_colour(polyline.kind, active), false, active ? 2.0f : 1.0f);
}
}
// editable points of the niweleta being drawn, plus the corner still waiting for its partner
for (std::size_t i = 0; i < vertex_count(); ++i)
{
double x{0.0};
double y{0.0};
vertex_position(i, x, y);
auto const point{view.to_screen(x, y)};
drawlist->AddCircleFilled(point, 4.0f, static_cast<int>(i) == m_draggedvertex ? IM_COL32(255, 240, 120, 255) : IM_COL32(235, 235, 235, 255));
}
if (m_pending)
{
auto const point{view.to_screen(m_pendingx, m_pendingy)};
drawlist->AddCircleFilled(point, 4.0f, IM_COL32(255, 240, 120, 255));
if (hovered)
{
drawlist->AddLine(point, mouse, IM_COL32(255, 240, 120, 160));
}
}
else if (hovered && m_drawing && m_draggedvertex == -1 && vertex_count() > 0)
{
double x{0.0};
double y{0.0};
vertex_position(vertex_count() - 1, x, y);
drawlist->AddLine(view.to_screen(x, y), mouse, IM_COL32(255, 240, 120, 160));
}
drawlist->PopClipRect();
ImGui::Text("cursor: %.1f, %.1f m grid: %g m scale: %.3g px/m", mousex, mousey, step, m_scale);
ImGui::TextDisabled("left click lays a point, drag a point to move it, right drag pans, wheel zooms");
}
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, 120.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(280.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();
frame_all();
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::frame_all()
{
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)
{
// nothing drawn yet: sit on the project origin at a scale that shows a few hundred metres
m_viewx = 0.0;
m_viewy = 0.0;
m_scale = 2.0;
return;
}
m_viewx = (minx + maxx) * 0.5;
m_viewy = (miny + maxy) * 0.5;
auto const span{std::max({maxx - minx, maxy - miny, 50.0})};
m_scale = std::clamp(400.0 / span, 0.002, 40.0);
}