diff --git a/changelog.txt b/changelog.txt index df212a489a..1353b42f8c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -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 diff --git a/docs/gui/export-world-map.rst b/docs/gui/export-world-map.rst new file mode 100644 index 0000000000..70640b91fd --- /dev/null +++ b/docs/gui/export-world-map.rst @@ -0,0 +1,18 @@ +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. + +Usage +----- + +:: + + gui/export-world-map diff --git a/gui/export-world-map.lua b/gui/export-world-map.lua new file mode 100644 index 0000000000..e437146e94 --- /dev/null +++ b/gui/export-world-map.lua @@ -0,0 +1,224 @@ +---@diagnostic disable: missing-fields + +local gui = require('gui') +local widgets = require('gui.widgets') + +local roads = reqscript('internal/export-world-map/export-roads') + +---@type df.viewscreen_choose_start_sitest +local viewscreen = dfhack.gui.getDFViewscreen(true) + +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, + 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 + + -- conservative approximation of screen dimensions in tiles + self.screen_w = (pixel_x // 16) - 2 + self.screen_h = (pixel_y // 16) - 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 + 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() + self.subviews.indicator_label:setText("working ...") + + 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 + + self.subviews.indicator_label:setText("") +end + +---launch command from 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 } +} + +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 = 19, h = 1 , t = 3 }, + label = "Generate Midmaps!", + 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 = 6}, + 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 = 15, 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 = 16, 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 = 18 }, + label = "Run Map Exports!", + on_click = self:callback('startExports'), + enabled = function() + local cur,max = getLoadedTileRatio() + return cur == max + end + }, + widgets.Label{ + view_id = 'indicator_label', + frame={ t = 20 , h = 1 }, + text = "" + } + } + 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() diff --git a/internal/export-world-map/export-roads.lua b/internal/export-world-map/export-roads.lua new file mode 100644 index 0000000000..ac738eb159 --- /dev/null +++ b/internal/export-world-map/export-roads.lua @@ -0,0 +1,74 @@ +--@ module = true +local json = require('json') +local util = reqscript('internal/export-world-map/util') + +local function getConstructionAndGeometryType(construction) + if df.world_construction_roadst:is_instance(construction) then + local subtype = "" + if df.item_type[construction.square_obj[0]["item_type"]] == "BLOCKS" then + subtype = "paved" + else + subtype = "dirt" + end + return "road", "LineString", subtype + elseif df.world_construction_bridgest:is_instance(construction) then + return "bridge", "Point", "" + elseif df.world_construction_tunnelst:is_instance(construction) then + return "tunnel", "LineString", "" + else + qerror("unknown construction type") + end +end + +local function gatherFeatures() + local features = {} + for _, construction in ipairs(df.global.world.world_data.constructions.list) do + local type, geometry_type, subtype = getConstructionAndGeometryType(construction) + + local coordinates + if geometry_type == "Point" then + -- only world tile - not sure where to get region tile coordinates from + -- perhaps it gets them from neighbouring roads? + coordinates = { + construction.square_pos["x"][0]*768, + -(construction.square_pos["y"][0]*768) + } + else + coordinates = {} + for _,square_obj in ipairs(construction.square_obj) do + for i=0,#square_obj.embark_x-1 do + table.insert(coordinates, { + square_obj.region_pos["x"]*768+square_obj.embark_x[i]*48+24, + -(square_obj.region_pos["y"]*768+square_obj.embark_y[i]*48+24) + }) + end + end + end + + table.insert(features, { + type = "Feature", + properties = { + id = construction.id, + name_df = dfhack.df2utf(dfhack.translation.translateName(construction["name"],false)), + name_en = dfhack.df2utf(dfhack.translation.translateName(construction["name"],true)), + construction_type = type, + construction_subtype = subtype, + }, + geometry = { + type = geometry_type, + coordinates = coordinates + } + }) + end + return features +end + +-- export roads as GeoJson +function export(by_world, by_date) + return function() + json.encode_file( + { type = "FeatureCollection", features = gatherFeatures() }, + util.getOutputFolder(by_world, by_date).."roads.geojson" + ) + end +end diff --git a/internal/export-world-map/util.lua b/internal/export-world-map/util.lua new file mode 100644 index 0000000000..30ea2c87ed --- /dev/null +++ b/internal/export-world-map/util.lua @@ -0,0 +1,20 @@ +--@ module = true + +if not dfhack_flags.module then + qerror('this script cannot be called directly') +end + +local plugin = require('plugins.export-world-map') + +---get folder to place output files +---@param by_world boolean +---@param by_date boolean +---@return string +function getOutputFolder(by_world, by_date) + local by_world = by_world or by_date + return ('%s/map-export/%s%s'):format( + dfhack.getConfigPath(), + by_world and plugin.getWorldFolderName()..'/' or '', + by_date and plugin.getDateFolderName()..'/' or '' + ) +end