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.
This commit is contained in:
parent
ddeba0dea8
commit
7c0dcb7843
1
BUILD.md
1
BUILD.md
@ -73,6 +73,7 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
|
||||
F5 / F9 save / load full state (planet.save)
|
||||
F12 screenshot to screenshot.png
|
||||
F11 export the 2D map as a high-resolution (~4K) PNG (also a toolbar button)
|
||||
Shift+F11 export a labeled atlas (~8K, every place/settlement name, no overlap; toolbar button)
|
||||
F2 reload planet.cfg (validated) and regenerate
|
||||
F1 toggle the clickable toolbar menu (top-right of the 3D globe; a button/
|
||||
checkbox/dropdown for every key above, built with raygui)
|
||||
|
||||
30
CLAUDE.md
30
CLAUDE.md
@ -360,6 +360,32 @@ on the Live World clock). **Steps 1–7 of the roadmap are done (plus a derived
|
||||
and `renderFrame()` calls `exportMapImage()` right after `drawToolbar()` returns (scissor mode
|
||||
guaranteed closed by then) — the `F11` key path was never affected (input handling runs before
|
||||
`BeginDrawing()`, outside any scissor state). Purely `src/render` — no save-format or `src/sim` change.
|
||||
- **Labeled atlas export** *(done — see `Viewer::exportAtlasImage()`, `src/render/ViewerRender.cpp`)* —
|
||||
a second, reference-style export for "give me every name, readably" (as opposed to `exportMapImage()`'s
|
||||
"what I'm currently looking at, just bigger"). Key **`Shift+F11`** (also a toolbar "Export Atlas"
|
||||
button under "Export Map") renders at **~8K** (7680 px wide) and always labels **every** named
|
||||
geography feature (continent/ocean/sea/island/mountain range/peak/river/lake, ignoring the on-screen
|
||||
minor-feature/zoom filter) **and every living settlement** (village/town/city, reading `Settlement.name`
|
||||
— `exportMapImage()`/the on-screen map never draw settlement name text, only tier-coloured dot
|
||||
markers) — with **guaranteed non-overlapping text**. Two things make that guarantee possible: (1)
|
||||
label **font size is deliberately capped well below the resolution scale** (a fixed `labelScale=2.2`,
|
||||
not the `~9×` `exportMapImage()` would use at this resolution) — scaling text 1:1 with an ever-bigger
|
||||
canvas would just reproduce the same on-screen crowding at a bigger size, so keeping labels a modest
|
||||
fixed size relative to the huge canvas is what actually buys the decluttering pass room to work; (2) a
|
||||
**greedy priority placement pass**: every candidate label (continents/oceans first, then cities, then
|
||||
seas/mountain ranges, towns, islands, villages, then peaks/rivers/lakes last, ties broken by feature
|
||||
`size`/settlement population) computes its screen-space text box and is **drawn only if that box
|
||||
doesn't collide with one already placed**, else it's silently dropped — so in a dense settlement
|
||||
cluster only as many names as legibly fit are shown (major ones win), never overlapping glyphs. Reuses
|
||||
`drawMapOverlays(..., drawLabels=false)` for base terrain/borders/rivers/settlement markers (forcing
|
||||
`showSettlements` on for the call, since marking cities is the point) — a new `drawLabels` parameter on
|
||||
`drawMapOverlays()` lets it suppress the plate-ID and place-name text `exportMapImage()`/the on-screen
|
||||
map draw, since this function does its own decluttered pass instead. Uses the plain colour-mode
|
||||
`vcolors`, **not** `displayColors()` — in Live World the latter is `shadedColors` (day/night-dimmed),
|
||||
which would leave an arbitrary half of a reference map darkened depending on the moment it was
|
||||
exported; a gazetteer should read the same everywhere. Saves a timestamped
|
||||
`atlas_export_YYYY-MM-DD_HHMMSS.png`; the status line reports how many labels made it in. Purely
|
||||
`src/render` — no save-format or `src/sim` change.
|
||||
|
||||
## Current state
|
||||
|
||||
@ -903,7 +929,9 @@ settlement — with per-field lock against automatic recompute; see below) ·
|
||||
button/checkbox/dropdown there — see below) ·
|
||||
`+`/`-` subdivision level (1..7) · `F5` save (`planet.save`) · `F9` load ·
|
||||
`F12` screenshot (`screenshot.png`) · `F11` export the 2D map as a high-resolution (~4K)
|
||||
`map_export_<timestamp>.png` (also a toolbar button) · `F2` reload `planet.cfg` + regenerate.
|
||||
`map_export_<timestamp>.png` (also a toolbar button) · `Shift+F11` export a labeled reference atlas
|
||||
(~8K, every geography feature + settlement name, no overlapping text — also a toolbar button) ·
|
||||
`F2` reload `planet.cfg` + regenerate.
|
||||
|
||||
Phase 3: after `phase3AfterMy` simulated years a modal prompt asks **Continue
|
||||
Phase 2** / **Start Phase 3**; `H` starts/stops it manually. In Phase 3 drift
|
||||
|
||||
@ -185,8 +185,9 @@ void drawToolbar(Viewer& v) {
|
||||
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.
|
||||
// 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) ------------------
|
||||
|
||||
@ -157,6 +157,7 @@ struct Viewer {
|
||||
// (around the scroll content) isn't still active --
|
||||
// that clip rect would otherwise also clip the export
|
||||
// render-texture pass.
|
||||
bool pendingAtlasExport = false; // Export-Atlas button: same deferral, see above.
|
||||
|
||||
// Transient on-screen status line.
|
||||
std::string statusMsg; double statusUntil = 0.0;
|
||||
@ -207,8 +208,12 @@ struct Viewer {
|
||||
// Shared 2D-map overlay draw (borders/rivers/markers/labels): used by both the on-screen
|
||||
// renderMap2D() (scale=1, markerZoom=on-screen zoom clamp) and exportMapImage() (both = the
|
||||
// export resolution multiplier, so lines/fonts/markers scale up together at high-res).
|
||||
void drawMapOverlays(Rectangle vr, double lonOffset, float scale, float markerZoom, bool showMinorLabels);
|
||||
void drawMapOverlays(Rectangle vr, double lonOffset, float scale, float markerZoom, bool showMinorLabels,
|
||||
bool drawLabels = true);
|
||||
void exportMapImage(); // render the 2D map at high resolution and save as PNG (F11)
|
||||
void exportAtlasImage(); // even-higher-res export with every place/settlement name
|
||||
// laid out with collision avoidance so text never overlaps
|
||||
// (Shift+F11)
|
||||
void appendEvent(uint8_t kind, uint8_t severity, double timeHours, int cell, uint32_t sourceId,
|
||||
const std::string& title, const std::string& detail);
|
||||
void detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
|
||||
|
||||
@ -242,7 +242,10 @@ void Viewer::handleInput() {
|
||||
if (IsKeyPressed(KEY_F5)) saveGame(SAVE_PATH);
|
||||
if (IsKeyPressed(KEY_F9)) loadGame(SAVE_PATH);
|
||||
if (IsKeyPressed(KEY_F12)) { TakeScreenshot("screenshot.png"); setStatus("Screenshot saved to screenshot.png"); }
|
||||
if (IsKeyPressed(KEY_F11)) exportMapImage();
|
||||
if (IsKeyPressed(KEY_F11)) {
|
||||
if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) exportAtlasImage();
|
||||
else exportMapImage();
|
||||
}
|
||||
// Speed control: Live World ramps the live clock (sim hours/s, ~hour -> month);
|
||||
// otherwise it sets the drift rate (My simulated per real second).
|
||||
if (IsKeyPressed(KEY_RIGHT_BRACKET)) {
|
||||
|
||||
@ -386,7 +386,8 @@ void Viewer::renderGlobe3D() {
|
||||
// sizes (1 on-screen, the export resolution ratio for exportMapImage()); `markerZoom` multiplies
|
||||
// marker radii (on-screen: the existing zoom clamp; export: the same as `scale`, since there is no
|
||||
// on-screen "zoom" concept for a full-world export). Shared so the two callers can never diverge.
|
||||
void Viewer::drawMapOverlays(Rectangle vr, double lonOffset, float scale, float markerZoom, bool showMinorLabels) {
|
||||
void Viewer::drawMapOverlays(Rectangle vr, double lonOffset, float scale, float markerZoom, bool showMinorLabels,
|
||||
bool drawLabels) {
|
||||
if (showGrat) { drawGraticule2D(graticule, vr, lonOffset); drawGraticuleLabels2D(vr, lonOffset); }
|
||||
if (showBorders && !borders.empty()) drawSegments2D(borders, Color{255, 235, 90, 255}, 2.0f * scale, vr, lonOffset);
|
||||
if (showBorders && !ridgeBorders.empty()) drawSegments2D(ridgeBorders, Color{220, 70, 60, 255}, 2.0f * scale, vr, lonOffset);
|
||||
@ -453,7 +454,7 @@ void Viewer::drawMapOverlays(Rectangle vr, double lonOffset, float scale, float
|
||||
drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f * scale, vr, lonOffset);
|
||||
drawSegments2D(bigRivers, Color{80, 170, 235, 255}, 3.0f * scale, vr, lonOffset);
|
||||
}
|
||||
if (showDrift && !plateLabels.empty()) {
|
||||
if (drawLabels && showDrift && !plateLabels.empty()) {
|
||||
int fs = (int)std::round(12 * scale);
|
||||
for (const auto& lbl : plateLabels) {
|
||||
Vec3 u = Vec3{lbl.pos.x, lbl.pos.y, lbl.pos.z}.normalized();
|
||||
@ -464,8 +465,9 @@ void Viewer::drawMapOverlays(Rectangle vr, double lonOffset, float scale, float
|
||||
}
|
||||
}
|
||||
// Place-name labels (the atlas). Minor features only when the map is zoomed in (on-screen) or
|
||||
// always for a high-res export, where there's ample room for the full detail.
|
||||
if (showNames && planet.geographyBuilt()) {
|
||||
// always for a high-res export, where there's ample room for the full detail. Skipped entirely
|
||||
// when the caller (exportAtlasImage) does its own decluttered label pass instead.
|
||||
if (drawLabels && showNames && planet.geographyBuilt()) {
|
||||
for (const GeoFeature& f : planet.geography()) {
|
||||
if (f.anchorCell < 0 || f.anchorCell >= (int)planet.cells.size()) continue;
|
||||
int font; Color col; bool minor = labelStyle(f, font, col);
|
||||
@ -487,7 +489,7 @@ void Viewer::renderMap2D() {
|
||||
Rectangle vr = mapViewRect(); // projection rect (zoom/pan); scissor stays mapRect
|
||||
BeginScissorMode((int)mapRect.x, (int)mapRect.y, (int)mapRect.width, (int)mapRect.height);
|
||||
drawMap2D(planet, displayColors(), map2D, vr, mapLon);
|
||||
drawMapOverlays(vr, mapLon, 1.0f, (float)std::min(2.0, mapZoom), mapZoom > 1.5);
|
||||
drawMapOverlays(vr, mapLon, 1.0f, (float)std::min(2.0, mapZoom), mapZoom > 1.5, /*drawLabels=*/true);
|
||||
if (selectedCell >= 0) DrawCircleV(mapScreen(map2D, selectedCell, vr, mapLon), 5, ORANGE);
|
||||
if (hovered >= 0) DrawCircleV(mapScreen(map2D, hovered, vr, mapLon), 4, YELLOW);
|
||||
EndScissorMode();
|
||||
@ -514,7 +516,7 @@ void Viewer::exportMapImage() {
|
||||
BeginTextureMode(rt);
|
||||
ClearBackground(Color{6, 8, 14, 255});
|
||||
drawMap2D(planet, displayColors(), map2D, er, mapLon);
|
||||
drawMapOverlays(er, mapLon, scale, scale, /*showMinorLabels=*/true);
|
||||
drawMapOverlays(er, mapLon, scale, scale, /*showMinorLabels=*/true, /*drawLabels=*/true);
|
||||
EndTextureMode();
|
||||
|
||||
Image img = LoadImageFromTexture(rt.texture);
|
||||
@ -529,6 +531,108 @@ void Viewer::exportMapImage() {
|
||||
setStatus(TextFormat("Map exported to %s (%dx%d)", name, exportW, exportH));
|
||||
}
|
||||
|
||||
// A reference-atlas export: even higher resolution than exportMapImage(), and every named
|
||||
// geography feature (continents/oceans/seas/islands/mountain ranges/peaks/rivers/lakes) plus every
|
||||
// living settlement is labelled -- not just the ones that fit on screen. Labels are placed by a
|
||||
// greedy priority pass (major features/cities first; a label is skipped, never overlapped, if its
|
||||
// box would collide with one already placed) so text never overlaps, however dense the world is.
|
||||
// Base map + context (borders/rivers/settlement markers) reuse drawMapOverlays with its own
|
||||
// plate-ID/place-name text disabled (drawLabels=false) since this function does its own pass;
|
||||
// settlement markers are forced on regardless of the current toggle, since marking cities is the
|
||||
// whole point of an atlas export.
|
||||
void Viewer::exportAtlasImage() {
|
||||
const int exportW = 7680; // ~8K -- extra room for dense labels
|
||||
int exportH = std::max(1, (int)std::lround(exportW * (double)mapRect.height / (double)mapRect.width));
|
||||
float scale = exportW / mapRect.width;
|
||||
// Label font size is deliberately much smaller than `scale` -- keeping text a modest, legible
|
||||
// size relative to the huge canvas is what actually gives the decluttering pass below room to
|
||||
// place far more labels without collisions (scaling font 1:1 with the canvas would just
|
||||
// reproduce the same on-screen crowding at a bigger size).
|
||||
const float labelScale = 2.2f;
|
||||
|
||||
RenderTexture2D rt = LoadRenderTexture(exportW, exportH);
|
||||
Rectangle er{ 0, 0, (float)exportW, (float)exportH };
|
||||
BeginTextureMode(rt);
|
||||
ClearBackground(Color{6, 8, 14, 255});
|
||||
// Unlike exportMapImage() (which mirrors the current view), a reference atlas should read the
|
||||
// same everywhere on the map -- so it uses the plain colour-mode `vcolors`, not displayColors(),
|
||||
// which in Live World would leave half the planet dimmed by whatever the current time-of-day
|
||||
// happens to be.
|
||||
drawMap2D(planet, vcolors, map2D, er, mapLon);
|
||||
bool savedSettlements = showSettlements;
|
||||
showSettlements = true;
|
||||
drawMapOverlays(er, mapLon, scale, scale, /*showMinorLabels=*/true, /*drawLabels=*/false);
|
||||
showSettlements = savedSettlements;
|
||||
|
||||
struct AtlasLabel { Vector2 pos; const std::string* text; int font; Color col; int priority; float sortKey; bool dot; };
|
||||
std::vector<AtlasLabel> labels;
|
||||
if (planet.geographyBuilt()) {
|
||||
for (const GeoFeature& f : planet.geography()) {
|
||||
if (f.anchorCell < 0 || f.anchorCell >= (int)planet.cells.size() || f.name.empty()) continue;
|
||||
int font; Color col; labelStyle(f, font, col);
|
||||
int prio; bool dot;
|
||||
switch (f.kind) {
|
||||
case FeatureKind::Continent: case FeatureKind::Ocean: prio = 0; dot = false; break;
|
||||
case FeatureKind::Sea: case FeatureKind::MountainRange: prio = 2; dot = false; break;
|
||||
case FeatureKind::Island: prio = 4; dot = true; break;
|
||||
default: prio = 6; dot = true; break; // Peak, River, Lake
|
||||
}
|
||||
double lon, lat; dirToLonLat(planet.cells[f.anchorCell].unit, lon, lat);
|
||||
Vector2 lp = projLonLat(lon, lat, mapLon, er);
|
||||
labels.push_back({ lp, &f.name, (int)std::round(font * labelScale), col, prio, (float)f.size, dot });
|
||||
}
|
||||
}
|
||||
if (!planet.settlements.empty()) {
|
||||
const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop;
|
||||
for (const Settlement& s : planet.settlements) {
|
||||
if (s.cell < 0 || s.cell >= (int)planet.cells.size() || s.name.empty() || s.population < abP) continue;
|
||||
SettleTier t = settleTierOf(s.population, townP, cityP);
|
||||
int font = t == SettleTier::City ? 17 : t == SettleTier::Town ? 13 : 10;
|
||||
int prio = t == SettleTier::City ? 1 : t == SettleTier::Town ? 3 : 5;
|
||||
Color col = t == SettleTier::City ? Color{250, 220, 110, 255}
|
||||
: t == SettleTier::Town ? Color{225, 170, 90, 255}
|
||||
: Color{210, 130, 85, 255};
|
||||
double lon, lat; dirToLonLat(planet.cells[s.cell].unit, lon, lat);
|
||||
Vector2 lp = projLonLat(lon, lat, mapLon, er);
|
||||
labels.push_back({ lp, &s.name, (int)std::round(font * labelScale), col, prio, (float)s.population, false });
|
||||
}
|
||||
}
|
||||
// Major features (low priority number) and, within a tier, larger/more populous ones claim
|
||||
// space first; smaller/minor labels only draw if they still fit around what's already placed.
|
||||
std::stable_sort(labels.begin(), labels.end(), [](const AtlasLabel& a, const AtlasLabel& b) {
|
||||
if (a.priority != b.priority) return a.priority < b.priority;
|
||||
return a.sortKey > b.sortKey;
|
||||
});
|
||||
std::vector<Rectangle> placed;
|
||||
const float pad = 3.0f;
|
||||
for (const auto& lb : labels) {
|
||||
if (lb.font <= 0 || lb.text->empty() || !CheckCollisionPointRec(lb.pos, er)) continue;
|
||||
int w = MeasureText(lb.text->c_str(), lb.font);
|
||||
Rectangle box{ lb.pos.x - w * 0.5f - pad, lb.pos.y - lb.font * 0.5f - pad,
|
||||
(float)w + pad * 2, (float)lb.font + pad * 2 };
|
||||
bool overlap = false;
|
||||
for (const auto& r : placed) if (CheckCollisionRecs(box, r)) { overlap = true; break; }
|
||||
if (overlap) continue;
|
||||
placed.push_back(box);
|
||||
if (lb.dot) DrawCircleV(lb.pos, 2.5f, lb.col);
|
||||
int tx = (int)(lb.pos.x - w * 0.5f), ty = (int)(lb.pos.y - lb.font * 0.5f);
|
||||
DrawText(lb.text->c_str(), tx + 1, ty + 1, lb.font, Color{0, 0, 0, 190});
|
||||
DrawText(lb.text->c_str(), tx, ty, lb.font, lb.col);
|
||||
}
|
||||
EndTextureMode();
|
||||
|
||||
Image img = LoadImageFromTexture(rt.texture);
|
||||
ImageFlipVertical(&img);
|
||||
time_t now = time(nullptr);
|
||||
struct tm tmv; localtime_r(&now, &tmv);
|
||||
char name[64];
|
||||
strftime(name, sizeof(name), "atlas_export_%Y-%m-%d_%H%M%S.png", &tmv);
|
||||
ExportImage(img, name);
|
||||
UnloadImage(img);
|
||||
UnloadRenderTexture(rt);
|
||||
setStatus(TextFormat("Atlas exported to %s (%dx%d, %d labels)", name, exportW, exportH, (int)placed.size()));
|
||||
}
|
||||
|
||||
// Live World tabbed info panel in the freed space right of the (left-aligned) 2D map.
|
||||
void Viewer::renderLiveInfo() {
|
||||
liveInfoTabRects.clear(); eventRowRects.clear(); eventRowIndices.clear(); atlasRowCells.clear();
|
||||
@ -1117,8 +1221,9 @@ void Viewer::renderFrame() {
|
||||
renderHUD();
|
||||
drawToolbar(*this); // drawn after the HUD, before the modal so it dims/locks together with it
|
||||
// Run after drawToolbar() fully returns (its own scissor mode is guaranteed closed by then) --
|
||||
// exportMapImage()'s render-texture pass must not inherit the toolbar's scroll-content clip rect.
|
||||
if (pendingMapExport) { pendingMapExport = false; exportMapImage(); }
|
||||
// an export's render-texture pass must not inherit the toolbar's scroll-content clip rect.
|
||||
if (pendingMapExport) { pendingMapExport = false; exportMapImage(); }
|
||||
if (pendingAtlasExport) { pendingAtlasExport = false; exportAtlasImage(); }
|
||||
renderPrompt();
|
||||
|
||||
EndDrawing();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user