planetsim/src/render/Toolbar.cpp
Jonas Reith 7c0dcb7843 Add labeled atlas export (Shift+F11): every place/settlement name, no overlap
A second, reference-style export alongside exportMapImage(): renders at ~8K and
labels every named geography feature and every living settlement (settlement
names weren't drawn as text anywhere before this), using a greedy
priority-based placement pass that only draws a label if its box doesn't
collide with one already placed -- so text never overlaps regardless of how
densely settlements cluster. Keeps label font size well below the resolution
scale, since scaling text 1:1 with a bigger canvas would just reproduce the
same crowding at a bigger size.

Also uses the plain colour-mode vcolors rather than displayColors(), since in
Live World the latter is day/night-dimmed -- a reference atlas should read the
same everywhere, not have an arbitrary half of it darkened by whatever moment
it was exported.
2026-08-30 18:01:18 +02:00

255 lines
13 KiB
C++

#include "Toolbar.hpp"
#include "Viewer.hpp"
#include "raygui.h"
#include <algorithm>
#include <cmath>
// A clickable raygui sidebar overlaying the top-right of the 3D globe (see Toolbar.hpp). Every
// widget here is either DIRECT-BIND (a plain bool with no side effect -- GuiCheckBox writes
// straight into the Viewer field, exactly what the matching key's bare flip does) or
// SHADOW+METHOD (a throw-away local bool seeded from the real state each frame; if raygui flips
// it in response to a click, the paired Viewer:: method -- the single source of truth, same one
// the keyboard shortcut calls -- runs instead of trusting the shadow's own value). The
// before/after compare is used rather than the widget's own return value so this doesn't depend
// on remembering the exact "pressed vs. held" return-code convention for every control type.
namespace {
const float PAD = 8.0f, ROWH = 24.0f, GAP = 4.0f, LABEL_H = 16.0f, SECTION_GAP = 10.0f;
void sectionLabel(float x, float& y, const char* text) {
DrawText(text, (int)x, (int)y, 14, Color{170, 175, 190, 255});
y += LABEL_H;
}
Rectangle takeRow(float x, float w, float& y, float h = ROWH) {
Rectangle r{ x, y, w, h };
y += h + GAP;
return r;
}
void toggleAction(Rectangle r, const char* label, bool current, void (Viewer::*action)(), Viewer& v) {
bool shadow = current;
GuiToggle(r, label, &shadow);
if (shadow != current) (v.*action)();
}
// GuiCheckBox treats `bounds` as the glyph square itself and auto-positions its label OUTSIDE
// that rect using the label's own natural width -- it doesn't compose with a fixed-width
// column layout (a wide `bounds` just draws an oversized square, and the label runs off past
// it). So: pass it a small square glyph rect and draw our own label at a known position
// instead. Returns whether *flag changed (a click), for callers using the shadow+method
// pattern; direct-bind callers can just ignore the return value.
bool guiCheckRow(Rectangle row, const char* label, bool* flag) {
Rectangle box{ row.x, row.y + (row.height - 18.0f) * 0.5f, 18.0f, 18.0f };
bool before = *flag;
GuiCheckBox(box, "", flag);
DrawText(label, (int)(row.x + 26.0f), (int)(row.y + (row.height - 13.0f) * 0.5f), 13, Color{200, 205, 215, 255});
return *flag != before;
}
// View-mode dropdown: the 13 "plain" colour modes (Ecoregion/Habitability/Territory/Culture/
// Wealth are separate side-effecting actions in the Civilization & Ecology section instead --
// same split the original keys 1-0 vs E/I/P/X/Z already had).
const ColorMode kViewModes[] = {
ColorMode::Elevation, ColorMode::Plate, ColorMode::Age, ColorMode::Crust, ColorMode::Biome,
ColorMode::Temperature, ColorMode::TempSummer, ColorMode::TempWinter, ColorMode::Seasonality,
ColorMode::Precip, ColorMode::FloraDensity, ColorMode::FaunaDensity, ColorMode::FungaDensity,
};
const int kViewModeCount = 13;
const char* kViewModeList =
"Elevation;Plates;Crust age;Crust type;Biome;Temperature;Temperature (summer);"
"Temperature (winter);Seasonality (summer-winter);Precipitation;Flora density;"
"Fauna density;Funga density";
// Log-scaled slider mapping so one slider usefully covers driftRate's 0.5..80 range and
// liveRate's 0.25..liveRateMax() range (up to ~175000, a full My/live-clock speed span).
float toSliderT(double val, double lo, double hi) {
double lv = std::log(std::clamp(val, lo, hi)), llo = std::log(lo), lhi = std::log(hi);
return (float)std::clamp((lv - llo) / (lhi - llo), 0.0, 1.0);
}
double fromSliderT(float t, double lo, double hi) {
double llo = std::log(lo), lhi = std::log(hi);
return std::exp(llo + (double)std::clamp(t, 0.0f, 1.0f) * (lhi - llo));
}
}
void setupToolbarStyle() {
GuiSetFont(GetFontDefault());
GuiSetStyle(DEFAULT, TEXT_SIZE, 14);
GuiSetStyle(DEFAULT, BACKGROUND_COLOR, ColorToInt(Color{12, 14, 22, 235}));
GuiSetStyle(DEFAULT, LINE_COLOR, ColorToInt(Color{90, 90, 110, 255}));
GuiSetStyle(DEFAULT, BORDER_COLOR_NORMAL, ColorToInt(Color{90, 90, 110, 255}));
GuiSetStyle(DEFAULT, BASE_COLOR_NORMAL, ColorToInt(Color{28, 30, 42, 255}));
GuiSetStyle(DEFAULT, TEXT_COLOR_NORMAL, ColorToInt(Color{210, 210, 220, 255}));
GuiSetStyle(DEFAULT, BORDER_COLOR_FOCUSED, ColorToInt(Color{235, 225, 140, 255}));
GuiSetStyle(DEFAULT, BASE_COLOR_FOCUSED, ColorToInt(Color{60, 66, 92, 255}));
GuiSetStyle(DEFAULT, TEXT_COLOR_FOCUSED, ColorToInt(RAYWHITE));
GuiSetStyle(DEFAULT, BORDER_COLOR_PRESSED, ColorToInt(Color{255, 200, 120, 255}));
GuiSetStyle(DEFAULT, BASE_COLOR_PRESSED, ColorToInt(Color{80, 88, 120, 255}));
GuiSetStyle(DEFAULT, TEXT_COLOR_PRESSED, ColorToInt(RAYWHITE));
GuiSetStyle(DEFAULT, BORDER_COLOR_DISABLED, ColorToInt(Color{50, 52, 60, 255}));
GuiSetStyle(DEFAULT, BASE_COLOR_DISABLED, ColorToInt(Color{20, 22, 30, 255}));
GuiSetStyle(DEFAULT, TEXT_COLOR_DISABLED, ColorToInt(Color{95, 98, 110, 255}));
}
void drawToolbar(Viewer& v) {
// Always-visible collapse/expand tab, independent of showToolbar.
bool wasOpen = v.showToolbar;
bool openShadow = wasOpen;
GuiToggle(v.toolbarToggleBtn, wasOpen ? "Menu x" : "Menu", &openShadow);
if (openShadow != wasOpen) v.showToolbar = openShadow;
if (!v.showToolbar) return;
bool wasModal = v.phase3Prompt;
if (wasModal) GuiDisable();
Rectangle panel = v.toolbarRect;
DrawRectangleRec(panel, Color{12, 14, 22, 235});
DrawRectangleLinesEx(panel, 1, Color{90, 90, 110, 255});
float x = panel.x + PAD, w = panel.width - 2 * PAD;
float y = panel.y + PAD;
Rectangle dropRect = takeRow(x, w, y, 24.0f); // reserved now, drawn LAST so its open list is on top
y += SECTION_GAP * 0.5f;
Rectangle scrollBounds{ panel.x, y, panel.width, (panel.y + panel.height) - y };
Rectangle content{ 0, 0, panel.width - 14.0f, 900.0f };
Rectangle view{};
bool dropdownOpen = v.toolbarModeEditMode;
if (dropdownOpen) GuiLock();
GuiScrollPanel(scrollBounds, nullptr, content, &v.toolbarScroll, &view);
BeginScissorMode((int)view.x, (int)view.y, (int)view.width, (int)view.height);
{
float cx = scrollBounds.x + PAD + v.toolbarScroll.x;
float cw = content.width - 2 * PAD;
float cy = scrollBounds.y + PAD + v.toolbarScroll.y;
// --- Overlays ---------------------------------------------------------
sectionLabel(cx, cy, "Overlays");
struct Ov { const char* label; bool* flag; };
Ov overlays[] = {
{"Borders (B)", &v.showBorders}, {"Drift (D)", &v.showDrift},
{"Grid (G)", &v.showGrat}, {"Rivers (J)", &v.showRivers},
{"Day/night (N)", &v.dayNightOn}, {"Tides (T)", &v.showTides},
{"Currents (O)", &v.showCurrents}, {"Clouds (K)", &v.showClouds},
{"Volcanoes (V)", &v.showVolcanoes},
};
float halfW = (cw - GAP) * 0.5f;
for (int i = 0; i < 9; i += 2) {
guiCheckRow(Rectangle{ cx, cy, halfW, ROWH }, overlays[i].label, overlays[i].flag);
if (i + 1 < 9) guiCheckRow(Rectangle{ cx + halfW + GAP, cy, halfW, ROWH }, overlays[i + 1].label, overlays[i + 1].flag);
cy += ROWH + GAP;
}
{
bool namesShadow = v.showNames;
if (guiCheckRow(Rectangle{ cx, cy, halfW, ROWH }, "Names (M)", &namesShadow)) v.toggleNames();
if (GuiButton(Rectangle{ cx + halfW + GAP, cy, halfW, ROWH }, "Reshuffle")) v.reshuffleNames();
cy += ROWH + GAP;
}
cy += SECTION_GAP;
// --- World / Time -------------------------------------------------------
sectionLabel(cx, cy, "World / Time");
{
bool pauseShadow = v.paused;
GuiToggle(takeRow(cx, cw, cy), v.paused ? "Resume" : "Pause", &pauseShadow);
if (pauseShadow != v.paused) v.pauseAction();
}
if (GuiButton(takeRow(cx, cw, cy), "Step (S)")) v.stepAction();
{
bool settledNow = v.settled;
if (settledNow) GuiDisable();
if (GuiButton(takeRow(cx, cw, cy), "Fast-forward (F)")) v.fastForward();
if (settledNow) GuiEnable();
}
{
bool isLive = v.liveWorld;
double lo = isLive ? 0.25 : 0.5;
double hi = isLive ? v.liveRateMax() : 80.0;
double cur = isLive ? v.liveRate : v.driftRate;
DrawText(TextFormat(isLive ? "Speed: %.1f h/s (live clock)" : "Speed: %.1f My/s", cur),
(int)cx, (int)cy, 13, Color{170, 175, 190, 255});
cy += LABEL_H;
float t = toSliderT(cur, lo, hi);
Rectangle r = takeRow(cx, cw, cy);
if (GuiSlider(r, "", "", &t, 0.0f, 1.0f)) {
double nv = fromSliderT(t, lo, hi);
if (isLive) v.liveRate = nv; else v.driftRate = nv;
}
}
{
bool hydroShadow = v.phase3;
if (guiCheckRow(takeRow(cx, cw, cy), "Hydrology (H)", &hydroShadow)) v.toggleHydrology();
}
if (GuiButton(takeRow(cx, cw, cy), "Reseed (R)")) v.reseed();
{
Rectangle r = takeRow(cx, cw, cy);
if (GuiButton(Rectangle{ r.x, r.y, halfW, r.height }, "Save (F5)")) v.saveGame(v.SAVE_PATH);
if (GuiButton(Rectangle{ r.x + halfW + GAP, r.y, halfW, r.height }, "Load (F9)")) v.loadGame(v.SAVE_PATH);
}
// Deferred to after drawToolbar() returns -- see Viewer::pendingMapExport/pendingAtlasExport.
if (GuiButton(takeRow(cx, cw, cy), "Export Map (F11)")) v.pendingMapExport = true;
if (GuiButton(takeRow(cx, cw, cy), "Export Atlas (Shift+F11)")) v.pendingAtlasExport = true;
cy += SECTION_GAP;
// --- Civilization & Ecology (all need a settled world) ------------------
sectionLabel(cx, cy, "Civilization & Ecology");
{
bool settledNow = v.settled;
if (!settledNow) GuiDisable();
toggleAction(takeRow(cx, cw, cy), "Ecoregions (E)", v.mode == ColorMode::Ecoregion, &Viewer::toggleEcoregionView, v);
toggleAction(takeRow(cx, cw, cy), "Habitability (I)", v.mode == ColorMode::Habitability, &Viewer::toggleHabitabilityView, v);
if (GuiButton(takeRow(cx, cw, cy), "Generate biota (L)")) v.generateOrRegenerateBiota();
toggleAction(takeRow(cx, cw, cy), v.planet.settlementsPlaced() ? "Settlements (U)" : "Found settlements (U)",
v.showSettlements && v.planet.settlementsPlaced(), &Viewer::placeOrToggleSettlements, v);
toggleAction(takeRow(cx, cw, cy), "Territory (P)", v.mode == ColorMode::Territory, &Viewer::toggleTerritoryView, v);
toggleAction(takeRow(cx, cw, cy), "Culture (X)", v.mode == ColorMode::Culture, &Viewer::toggleCultureView, v);
toggleAction(takeRow(cx, cw, cy), "Trade / wealth (Z)", v.mode == ColorMode::Wealth, &Viewer::toggleTradeView, v);
if (!settledNow) GuiEnable();
}
cy += SECTION_GAP;
// --- Live World -----------------------------------------------------------
sectionLabel(cx, cy, "Live World");
{
bool settledNow = v.settled;
if (!settledNow) GuiDisable();
toggleAction(takeRow(cx, cw, cy), v.liveWorld ? "Exit Live World" : "Enter Live World (W)", v.liveWorld, &Viewer::enterOrLeaveLiveWorld, v);
if (!settledNow) GuiEnable();
bool liveNow = v.liveWorld;
if (!liveNow) GuiDisable();
if (GuiButton(takeRow(cx, cw, cy), "Follow storm (Y)")) v.cycleFollowStorm();
{
Rectangle r = takeRow(cx, cw, cy);
if (GuiButton(Rectangle{ r.x, r.y, halfW, r.height }, "Step + (.)")) { v.liveStepForward(); v.setStatus("Step forward"); }
if (GuiButton(Rectangle{ r.x + halfW + GAP, r.y, halfW, r.height }, "Step - (,)")) v.liveStepBack();
}
if (!liveNow) GuiEnable();
}
cy += SECTION_GAP;
// --- Edit Mode --------------------------------------------------------
sectionLabel(cx, cy, "Edit Mode");
{
bool settledNow = v.settled;
if (!settledNow) GuiDisable();
toggleAction(takeRow(cx, cw, cy), v.editMode ? "Exit Edit Mode" : "Edit Mode (F3)", v.editMode, &Viewer::toggleEditMode, v);
if (!settledNow) GuiEnable();
}
}
EndScissorMode();
if (dropdownOpen) GuiUnlock();
// View-mode dropdown, drawn LAST so its open list renders on top of the scroll body below it
// (see Toolbar.hpp / the gotcha this avoids: raygui doesn't auto-exclude overlapping controls).
if (!v.toolbarModeEditMode) {
for (int i = 0; i < kViewModeCount; ++i) if (kViewModes[i] == v.mode) { v.toolbarModeActive = i; break; }
}
if (GuiDropdownBox(dropRect, kViewModeList, &v.toolbarModeActive, v.toolbarModeEditMode))
v.toolbarModeEditMode = !v.toolbarModeEditMode;
if (!v.toolbarModeEditMode) {
ColorMode picked = kViewModes[std::clamp(v.toolbarModeActive, 0, kViewModeCount - 1)];
if (picked != v.mode) { v.mode = picked; v.recolor(); }
}
if (wasModal) GuiEnable();
}