Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Template for new versions:

## New Tools

- `gui/export-world-map`: New GUI tool to configure world map exports from the embark selection screen.

## New Features

## Fixes
Expand Down
30 changes: 30 additions & 0 deletions docs/gui/export-world-map.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
gui/export-world-map
====================

.. dfhack-tool::
:summary: Export world map data for GIS and other external tools.
:tags: inspection embark map

This tool provides a GUI for the `export-world-map` plugin and a number of
additional exports written in Lua. Moreover, this tool automates the process of
scrolling around the embark map to generate the region tiles used for the
exports.

The additional exports provided are:

* Roads and tunnels (GeoJSON)
* Geological layers and the veins included in them (semicolon-delimited pure data layer)
* Animal and plant populations of regions (semicolon-delimited pure data layer)

The data layers are in "long" format (e.g. ``BIRD_KEA`` is a field in the
``raw_id`` column instead of a column header). Since these layers do not have
any geometry information, they need to be joined with the ``region`` layer of
`export-world-map`. The first column of each file (e.g. ``geo_index`` for
geological layers) match as the corresponding column from the region export.

Usage
-----

::

gui/export-world-map
Comment thread
SilasD marked this conversation as resolved.
221 changes: 221 additions & 0 deletions gui/export-world-map.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
---@diagnostic disable: missing-fields
Comment thread
SilasD marked this conversation as resolved.

local gui = require('gui')
local widgets = require('gui.widgets')

local roads = reqscript('internal/export-world-map/export-roads')
local layers = reqscript('internal/export-world-map/export-layers')
local pops = reqscript('internal/export-world-map/export-pops')

---@type df.viewscreen_choose_start_sitest
local viewscreen = dfhack.gui.getDFViewscreen(true)
Comment thread
SilasD marked this conversation as resolved.

if not df.viewscreen_choose_start_sitest:is_instance(viewscreen) then
qerror("Tool must be used while choosing an embark location.")
return
end

ExportMap = defclass(ExportMap, widgets.Window)
ExportMap.ATTRS {
frame_title='Export World Map',
frame={w=58, h=25},
resizable=false,
}

local function getLoadedTileRatio()
return #df.global.world.world_data.midmap_data.region_details,
Comment thread
SilasD marked this conversation as resolved.
df.global.world.world_data.world_width * df.global.world.world_data.world_height
end

function ExportMap:updateRatio()
cur,max = getLoadedTileRatio()
self.subviews.midmap_ratio:setText(
("%d out of %d region tiles loaded"):format(cur,max)
)
end

function ExportMap:initializeMapScan()
local pixel_x = df.global.gps.screen_pixel_x
local pixel_y = df.global.gps.screen_pixel_y
local tile_x = dfhack.screen.inGraphicsMode() and 16 or df.global.gps.tile_pixel_x
local tile_y = dfhack.screen.inGraphicsMode() and 16 or df.global.gps.tile_pixel_y

-- conservative approximation of screen dimensions in tiles
self.screen_w = (pixel_x // tile_x) - 2
self.screen_h = (pixel_y // tile_y) - 2

-- ensure that we are zoomed in
viewscreen.zoomed_in = true

viewscreen.zoom_cent_x = self.screen_w // 2
viewscreen.zoom_cent_y = self.screen_h // 2

self.done = false
end



-- keep scrolling around, until the entire world has been covered
function ExportMap:onRenderBody(painter)
self:updateRatio()
if self.done then
return
end
if viewscreen.zoom_cent_x + self.screen_w // 2 < df.global.world.world_data.world_width * 16 then
viewscreen.zoom_cent_x = viewscreen.zoom_cent_x + self.screen_w
elseif viewscreen.zoom_cent_y + self.screen_h // 2 < df.global.world.world_data.world_height * 16 then
viewscreen.zoom_cent_x = self.screen_w // 2
viewscreen.zoom_cent_y = viewscreen.zoom_cent_y + self.screen_h
Comment thread
SilasD marked this conversation as resolved.
else
self.done = true
end
end

---@param by_world boolean
---@param by_date boolean
---@param fn fun(boolean,boolean):(string|fun():nil)
local function invokeExport(by_world, by_date, fn)
local command = fn(by_world, by_date)
if type(command) == "string" then
dfhack.run_command(command)
else
command()
end
end

function ExportMap:startExports()
local by_date = self.subviews.by_date:getOptionValue()
local by_world = by_date or self.subviews.by_world:getOptionValue()

for _, export in ipairs(exports) do
if export.enabled then
invokeExport(by_world, by_date, export.command)
end
end
end

---generate wrapper for invoking the C++ plugin
---@param export string
---@return fun(boolean,boolean):string
local function pluginCommand(export)
return function(by_world, by_date)
-- grouping by date implies grouping by world
return ('export-world-map %s %s'):format(by_date and '--group-by-date' or (by_world and '--group-by-world' or ''), export)
end
end

exports = {
{ id = 1, key = 'regions', desc = 'Region Map Export (regions.csv)' , enabled = true, command = pluginCommand("regions") },
{ id = 2, key = 'rivers', desc = 'River Export (rivers.csv)' , enabled = true, command = pluginCommand("rivers") },
{ id = 3, key = 'sites', desc = 'Site Export (sites.csv)' , enabled = true, command = pluginCommand("sites") },
{ id = 4, key = 'elevation', desc = 'Elevation Grid Export (elevation.dat, elevation.vrt)' , enabled = true, command = pluginCommand("elevation") },
{ id = 5, key = 'roads', desc = 'Road Export (roads.geojson)' , enabled = true, command = roads.export },
{ id = 6, key = 'layers', desc = 'Export Geological Layers (layers.csv)' , enabled = true, command = layers.export },
{ id = 7, key = 'pops', desc = 'Export Plant and Animal Populations (*_pops.csv)' , enabled = true, command = pops.export }
}

local SELECTED_ICON = dfhack.pen.parse{ch=string.char(251), fg=COLOR_LIGHTGREEN}
local DISABLED_ICON = dfhack.pen.parse{ch='x', fg=COLOR_RED}

function ExportMap:getChoices()
print("getChoices")
local choices = {}
for _, export in ipairs(exports) do
table.insert(choices, {
icon = function()
return export.enabled and SELECTED_ICON or DISABLED_ICON
end,
text = export.desc,
id = export.id
})
end
return choices
end

function ExportMap:toggleExport(_, choice)
exports[choice.id].enabled = not exports[choice.id].enabled
self:updateLayout()
end

function ExportMap:init()
self.done = true
self:addviews{
widgets.Label{
frame = { t = 1 },
view_id = 'midmap_ratio',
text = "counting..."
},
widgets.TextButton{
frame = { w = 22, h = 1 , t = 3 },
label = "Load Region Details!",
on_click = self:callback('initializeMapScan')
},
widgets.Divider{
frame = { t = 5, h = 1 },
frame_style_l = false,
frame_style_r = false
},
widgets.Label{
frame={ t = 7 , h = 1 },
text = "Select exports to place in dfhack-config/map-export:",
},
widgets.List{
frame={t=9, h = #exports},
view_id = "export_list",
on_submit=self:callback("toggleExport"),
icon_width = 2,
choices = self:getChoices(),
},
widgets.CycleHotkeyLabel{
view_id = 'by_world',
key = 'CUSTOM_W',
frame = { w = 40, h = 1 , t = #exports + 10, l = 0 },
options = { { label = 'Yes' , value = true, pen = COLOR_LIGHTGREEN}, { label = 'No' , value = false} },
initial_option = false,
label = "Create folder for world name",
on_change = function(new, _)
if not new then
self.subviews.by_date:setOption(false)
end
end
},
widgets.CycleHotkeyLabel{
view_id = 'by_date',
key = 'CUSTOM_D',
frame = { w = 40, h = 1 , t = #exports + 11, l = 0 },
options = { { label = 'Yes' , value = true, pen = COLOR_LIGHTGREEN}, { label = 'No' , value = false} },
initial_option = false,
label = "Create subfolder for world date",
on_change = function(new, _)
if new then
self.subviews.by_world:setOption(true)
end
end
},
widgets.TextButton{
frame = { w = 18, h = 1 , t = #exports + 13 },
label = "Run Map Exports!",
on_click = self:callback('startExports'),
enabled = function()
local cur,max = getLoadedTileRatio()
return cur == max
end
}
}
self:updateRatio()
end

ExportMapScreen = defclass(ExportMapScreen, gui.ZScreen)
ExportMapScreen.ATTRS {
focus_path='PopulateMidmap',
}

function ExportMapScreen:init()
self:addviews{ExportMap{}}
end

function ExportMapScreen:onDismiss()
view = nil
end

view = view and view:raise() or ExportMapScreen{}:show()
38 changes: 38 additions & 0 deletions internal/export-world-map/export-layers.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
--@ module = true
local util = reqscript('internal/export-world-map/util')

function writeOutput(out)
local column_headers = "geo_index;type;material;top_height;bottom_height\n"
out:write(column_headers)

for _, geo_biome in ipairs(df.global.world.world_data.geo_biomes) do
for _, layer in ipairs(geo_biome.layers) do
out:write(('%s;%s;%s;%s;%s\n'):format(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why semicolons when the output is named as a CSV file? Comma-separated values

(later) I see you're using this for most/all output files. I'm not going to comment on each occurance.

as an aside, I've never understood why people use the idiom
("a format string"):format(val, val)
instead of
string.format("a format string", val, val)
treating it as a sprintf-alike

if the format string is already defined, it makes sense, sure. but that's so seldom the case with string.format.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why semicolons when the output is named as a CSV file? Comma-separated values

(later) I see you're using this for most/all output files. I'm not going to comment on each occurance.

This is a case of Europeans doing European things: https://discord.com/channels/793331351645323264/1340358673003712654/1540610890192134195

However, I agree that this should be documented for the benefit of others.

@ab9rf ab9rf Sep 13, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW, the rule for when you use a semicolon is "if the decimal separator is a comma, then the CSV separator is a semicolon, otherwise it's a comma". Microsoft's locale processing does this for you (they have a ListSeparator element in their locale library which is inferred using this rule) but in POSIX (and thus in the C++ standard, which is heavily POSIX-leaning) you have to do it yourself.

Most CSV libraries will accept either. Arguably, the best approach is to grab the current locale, identify its decimal separator, and from that infer whether a comma or a semicolon is most appropriate.

std::locale current_locale = std::locale{""};    
auto const& numeric_facet = std::use_facet<std::numpunct<char>>(current_locale);
char decimal_sep = numeric_facet.decimal_point();
char list_sep = decimal_sep == ',' ? ';' : ',';

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really would prefer if output of the world export tools would not depend on the user's locale. As far as I know, no other behavior of DFHack depends on it. If only you knew how much grief Microsoft's locale-dependent interpretation of CSV has caused on this side of the Atlantic...

Moreover, while semicolons do not naturally occur in our data, commas very much do

plant_pops.csv:736;Grass;BAMBOO, GOLDEN;10000001;10000001
plant_pops.csv:737;Grass;BAMBOO, ARROW;10000001;10000001
plant_pops.csv:746;Grass;BAMBOO, HEDGE;10000001;10000001
sites.csv:1434;-1;-10000;-1;lair;Emanomba Etrujulosm;Birdsrivers the Earthen Crab;NONE;NONE;NONE;NONE;NONE;POLYGON((84336 -52896,84384 -52896,84384 -52944,84336 -52944,84336 -52896))

The commas inside the WKT geometries obviously affects all layer files, including those generated by the C++ plugin.

Currently, all exports get away without doing any escaping. If you want commas, you also will get escaping.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<meme> Um, ackchyually, </meme>

We've got a couple of locale-specific numbers. The trade overlays format prices per locale, and the notifications floating overlay formats time-since-last-save per locale.

Which is just quibbling really.

Commas crop up in the exported data; I think that's a good enough reason to go with semicolons. All good.

geo_biome.index,
"LAYER",
df.inorganic_raw.find(layer.mat_index).id,
layer.top_height,
layer.bottom_height
))
for i = 0, #layer.vein_mat - 1 do
Comment thread
chdoc marked this conversation as resolved.
local material = df.inorganic_raw[layer.vein_mat[i]]
out:write(('%s;%s;%s;%s;%s\n'):format(
geo_biome.index,
df.inclusion_type[layer.vein_type[i]],
df.inorganic_raw.find(layer.vein_mat[i]).id,
layer.top_height,
layer.bottom_height
))
end
end
end
end

--- export geological layers
function export(by_world, by_date)
return function()
local out = io.open(util.getOutputFolder(by_world, by_date).."layers.csv", 'w')
writeOutput(out)
out:close()
end
end
63 changes: 63 additions & 0 deletions internal/export-world-map/export-pops.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
--@ module = true
local util = reqscript('internal/export-world-map/util')

local plants_filename = "plant_pops.csv"
local creature_filename = "creature_pops.csv"
local vermin_filename = "vermin_pops.csv"
local column_headers = "region_id;population_type;raw_id;pop_count_min;pop_count_max\n"

function writeOutput(base_folder)
local plants = io.open(base_folder..plants_filename, 'w')
local creatures = io.open(base_folder..creature_filename, 'w')
local vermin = io.open(base_folder..vermin_filename, 'w')

plants:write(column_headers)
creatures:write(column_headers)
vermin:write(column_headers)

for _, region in ipairs(df.global.world.world_data.regions) do
for _, pop in ipairs(region.population) do
local raw_id
local out
if
pop.type == df.world_population_type.Tree or
pop.type == df.world_population_type.Grass or
pop.type == df.world_population_type.Bush
then
raw_id = df.plant_raw.find(pop.plant).id
out = plants
else
raw_id = df.creature_raw.find(pop.plant).creature_id
if
pop.type == df.world_population_type.Vermin or
pop.type == df.world_population_type.VerminInnumerable
Comment thread
SilasD marked this conversation as resolved.
then
out = vermin
else
out = creatures
end
end

if out then
out:write(('%s;%s;%s;%s;%s\n'):format(
region.index,
df.world_population_type[pop.type],
raw_id,
pop.count_min,
pop.count_max
))
end
end
end

plants:close()
vermin:close()
creatures:close()
end

--- export animal and plant populations
function export(by_world, by_date)
return function()
writeOutput(util.getOutputFolder(by_world, by_date))
end
end
Loading
Loading