add --splitby param to blueprint

--splitby=none is the new default, allowing all blueprint phases to be
written to a single file. old behavior of one phase per file is
supported via --splitby=phase.
develop
myk002 2021-09-09 00:46:33 -07:00 committed by Myk
parent 3d1e3f0832
commit 0747f872b0
5 changed files with 250 additions and 100 deletions

@ -54,9 +54,8 @@ Usage::
Examples:
``blueprint gui``
Runs `gui/blueprint`, the interactive blueprint frontend, where all
configuration for a ``blueprint`` command can be set visually and
interactively.
Runs `gui/blueprint`, the interactive frontend, where all configuration for
a ``blueprint`` command can be set visually and interactively.
``blueprint 30 40 bedrooms``
Generates blueprints for an area 30 tiles wide by 40 tiles tall, starting
@ -103,6 +102,21 @@ Options:
then an active game map cursor is not necessary.
:``-h``, ``--help``:
Show command help text.
:``-t``, ``--splitby <strategy>``:
Split blueprints into multiple files. See the ``Splitting output into
multiple files`` section below for details. If not specified, defaults to
"none", which will create a standard quickfort
`multi-blueprint <quickfort-packaging>` file.
Splitting output into multiple files:
The ``--splitby`` flag can take any of the following values:
:``none``:
Writes all blueprints into a single file. This is the standard format for
quickfort fortress blueprint bundles and is the default.
:``phase``:
Creates a separate file for each phase.
.. _remotefortressreader:

@ -32,11 +32,11 @@
#include "df/building_workshopst.h"
#include "df/world.h"
using std::string;
using std::endl;
using std::vector;
using std::ofstream;
using std::pair;
using std::string;
using std::vector;
using namespace DFHack;
DFHACK_PLUGIN("blueprint");
@ -50,6 +50,10 @@ struct blueprint_options {
// coordinates are set to -30000)
df::coord start;
// file splitting strategy. this could be an enum if we set up the
// boilerplate for it.
string split_strategy;
// dimensions of translation area. width and height are guaranteed to be
// greater than 0. depth can be positive or negative, but not zero.
int32_t width = 0;
@ -71,17 +75,18 @@ struct blueprint_options {
static struct_identity _identity;
};
static const struct_field_info blueprint_options_fields[] = {
{ struct_field_info::PRIMITIVE, "help", offsetof(blueprint_options, help), &df::identity_traits<bool>::identity, 0, 0 },
{ struct_field_info::SUBSTRUCT, "start", offsetof(blueprint_options, start), &df::coord::_identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "width", offsetof(blueprint_options, width), &df::identity_traits<int32_t>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "height", offsetof(blueprint_options, height), &df::identity_traits<int32_t>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "depth", offsetof(blueprint_options, depth), &df::identity_traits<int32_t>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "name", offsetof(blueprint_options, name), df::identity_traits<string>::get(), 0, 0 },
{ struct_field_info::PRIMITIVE, "auto_phase", offsetof(blueprint_options, auto_phase), &df::identity_traits<bool>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "dig", offsetof(blueprint_options, dig), &df::identity_traits<bool>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "build", offsetof(blueprint_options, build), &df::identity_traits<bool>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "place", offsetof(blueprint_options, place), &df::identity_traits<bool>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "query", offsetof(blueprint_options, query), &df::identity_traits<bool>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "help", offsetof(blueprint_options, help), &df::identity_traits<bool>::identity, 0, 0 },
{ struct_field_info::SUBSTRUCT, "start", offsetof(blueprint_options, start), &df::coord::_identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "split_strategy", offsetof(blueprint_options, split_strategy), df::identity_traits<string>::get(), 0, 0 },
{ struct_field_info::PRIMITIVE, "width", offsetof(blueprint_options, width), &df::identity_traits<int32_t>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "height", offsetof(blueprint_options, height), &df::identity_traits<int32_t>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "depth", offsetof(blueprint_options, depth), &df::identity_traits<int32_t>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "name", offsetof(blueprint_options, name), df::identity_traits<string>::get(), 0, 0 },
{ struct_field_info::PRIMITIVE, "auto_phase", offsetof(blueprint_options, auto_phase), &df::identity_traits<bool>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "dig", offsetof(blueprint_options, dig), &df::identity_traits<bool>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "build", offsetof(blueprint_options, build), &df::identity_traits<bool>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "place", offsetof(blueprint_options, place), &df::identity_traits<bool>::identity, 0, 0 },
{ struct_field_info::PRIMITIVE, "query", offsetof(blueprint_options, query), &df::identity_traits<bool>::identity, 0, 0 },
{ struct_field_info::END }
};
struct_identity blueprint_options::_identity(sizeof(blueprint_options), &df::allocator_fn<blueprint_options>, NULL, "blueprint_options", NULL, blueprint_options_fields);
@ -90,7 +95,7 @@ command_result blueprint(color_ostream &, vector<string> &);
DFhackCExport command_result plugin_init(color_ostream &, vector<PluginCommand> &commands)
{
commands.push_back(PluginCommand("blueprint", "Record the structure of a live game map in a quickfort blueprint", blueprint, false));
commands.push_back(PluginCommand("blueprint", "Record the structure of a live game map in a quickfort blueprint file", blueprint, false));
return CR_OK;
}
@ -587,34 +592,68 @@ static string get_tile_query(df::building* b)
return " ";
}
// can remove once we move to C++20
static bool ends_with(const string &str, const string &sv)
static bool get_filename(string &fname,
color_ostream &out,
blueprint_options opts, // copy because we can't const
const string &phase)
{
if (sv.size() > str.size())
auto L = Lua::Core::State;
Lua::StackUnwinder top(L);
if (!lua_checkstack(L, 3) ||
!Lua::PushModulePublic(
out, L, "plugins.blueprint", "get_filename"))
{
out.printerr("Failed to load blueprint Lua code\n");
return false;
}
Lua::Push(L, &opts);
Lua::Push(L, phase);
if (!Lua::SafeCall(out, L, 2, 1))
{
out.printerr("Failed Lua call to get_filename\n");
return false;
}
const char *s = lua_tostring(L, -1);
if (!s)
{
out.printerr("Failed to retrieve filename from get_filename\n");
return false;
return str.substr(str.size() - sv.size()) == sv;
}
fname = s;
return true;
}
// returns filename
static string init_stream(ofstream &out, string basename, string target)
static bool write_blueprint(color_ostream &out,
std::map<string, ofstream*> &output_files,
const blueprint_options &opts,
const string &phase,
const std::ostringstream &stream)
{
std::ostringstream out_path;
string separator = ends_with(basename, "/") ? "" : "-";
out_path << basename << separator << target << ".csv";
string path = out_path.str();
out.open(path, ofstream::trunc);
out << "#" << target << endl;
return path;
string fname;
if (!get_filename(fname, out, opts, phase))
return false;
if (!output_files.count(fname))
output_files[fname] = new ofstream(fname, ofstream::trunc);
ofstream &ofile = *output_files[fname];
ofile << "#" << phase << endl;
ofile << stream.str();
return true;
}
static bool do_transform(const DFCoord &start, const DFCoord &end,
const blueprint_options &options,
vector<string> &files,
std::ostringstream &err)
static bool do_transform(color_ostream &out,
const DFCoord &start, const DFCoord &end,
const blueprint_options &opts,
vector<string> &filenames)
{
ofstream dig, build, place, query;
std::ostringstream dig, build, place, query;
string basename = "blueprints/" + options.name;
string basename = "blueprints/" + opts.name;
size_t last_slash = basename.find_last_of("/");
string parent_path = basename.substr(0, last_slash);
@ -622,27 +661,11 @@ static bool do_transform(const DFCoord &start, const DFCoord &end,
std::error_code ec;
if (!Filesystem::mkdir_recursive(parent_path))
{
err << "could not create output directory: '" << parent_path << "'";
out.printerr("could not create output directory: '%s'\n",
parent_path.c_str());
return false;
}
if (options.auto_phase || options.dig)
{
files.push_back(init_stream(dig, basename, "dig"));
}
if (options.auto_phase || options.build)
{
files.push_back(init_stream(build, basename, "build"));
}
if (options.auto_phase || options.place)
{
files.push_back(init_stream(place, basename, "place"));
}
if (options.auto_phase || options.query)
{
files.push_back(init_stream(query, basename, "query"));
}
const int32_t z_inc = start.z < end.z ? 1 : -1;
const string z_key = start.z < end.z ? "#<" : "#>";
for (int32_t z = start.z; z != end.z; z += z_inc)
@ -652,44 +675,66 @@ static bool do_transform(const DFCoord &start, const DFCoord &end,
for (int32_t x = start.x; x < end.x; x++)
{
df::building* b = Buildings::findAtTile(DFCoord(x, y, z));
if (options.auto_phase || options.query)
if (opts.auto_phase || opts.query)
query << get_tile_query(b) << ',';
if (options.auto_phase || options.place)
if (opts.auto_phase || opts.place)
place << get_tile_place(x, y, b) << ',';
if (options.auto_phase || options.build)
if (opts.auto_phase || opts.build)
build << get_tile_build(x, y, b) << ',';
if (options.auto_phase || options.dig)
if (opts.auto_phase || opts.dig)
dig << get_tile_dig(x, y, z) << ',';
}
if (options.auto_phase || options.query)
if (opts.auto_phase || opts.query)
query << "#" << endl;
if (options.auto_phase || options.place)
if (opts.auto_phase || opts.place)
place << "#" << endl;
if (options.auto_phase || options.build)
if (opts.auto_phase || opts.build)
build << "#" << endl;
if (options.auto_phase || options.dig)
if (opts.auto_phase || opts.dig)
dig << "#" << endl;
}
if (z != end.z - z_inc)
{
if (options.auto_phase || options.query)
if (opts.auto_phase || opts.query)
query << z_key << endl;
if (options.auto_phase || options.place)
if (opts.auto_phase || opts.place)
place << z_key << endl;
if (options.auto_phase || options.build)
if (opts.auto_phase || opts.build)
build << z_key << endl;
if (options.auto_phase || options.dig)
if (opts.auto_phase || opts.dig)
dig << z_key << endl;
}
}
if (options.auto_phase || options.query)
query.close();
if (options.auto_phase || options.place)
place.close();
if (options.auto_phase || options.build)
build.close();
if (options.auto_phase || options.dig)
dig.close();
std::map<string, ofstream*> output_files;
string fname;
if (opts.auto_phase || opts.dig)
{
if (!write_blueprint(out, output_files, opts, "dig", dig))
return false;
}
if (opts.auto_phase || opts.build)
{
if (!write_blueprint(out, output_files, opts, "build", build))
return false;
}
if (opts.auto_phase || opts.place)
{
if (!write_blueprint(out, output_files, opts, "place", place))
return false;
}
if (opts.auto_phase || opts.query)
{
if (!write_blueprint(out, output_files, opts, "query", query))
return false;
}
for (auto &it : output_files)
{
filenames.push_back(it.first);
it.second->close();
delete(it.second);
}
return true;
}
@ -805,13 +850,7 @@ static bool do_blueprint(color_ostream &out,
if (end.z < -1)
end.z = -1;
std::ostringstream err;
if (!do_transform(start, end, options, files, err))
{
out.printerr("%s\n", err.str().c_str());
return false;
}
return true;
return do_transform(out, start, end, options, files);
}
// entrypoint when called from Lua. returns the names of the generated files

@ -40,9 +40,14 @@ local valid_phase_list = {
'place',
'query',
}
valid_phases = utils.invert(valid_phase_list)
local valid_split_strategies_list = {
'none',
'phase',
}
valid_split_strategies = utils.invert(valid_split_strategies_list)
local function parse_cursor(opts, arg)
local cursor = argparse.coords(arg)
-- be careful not to replace struct members when called from C++, but also
@ -51,6 +56,15 @@ local function parse_cursor(opts, arg)
utils.assign(opts.start, cursor)
end
local function parse_split_strategy(opts, strategy)
if not valid_split_strategies[strategy] then
qerror(('unknown split strategy: "%s"; expected one of: %s')
:format(strategy,
table.concat(valid_split_strategies_list, ', ')))
end
opts.split_strategy = strategy
end
local function parse_positionals(opts, args, start_argidx)
local argidx = start_argidx or 1
@ -71,8 +85,8 @@ local function parse_positionals(opts, args, start_argidx)
local phase = args[argidx]
while phase do
if not valid_phases[phase] then
qerror(('unknown phase: "%s"; expected one of: %s'):
format(phase, table.concat(valid_phase_list, ', ')))
qerror(('unknown phase: "%s"; expected one of: %s')
:format(phase, table.concat(valid_phase_list, ', ')))
end
auto_phase = false
opts[phase] = true
@ -88,11 +102,20 @@ local function process_args(opts, args)
return
end
return argparse.processArgsGetopt(args, {
local positionals = argparse.processArgsGetopt(args, {
{'c', 'cursor', hasArg=true,
handler=function(optarg) parse_cursor(opts, optarg) end},
{'h', 'help', handler=function() opts.help = true end},
{'t', 'splitby', hasArg=true,
handler=function(optarg) parse_split_strategy(opts, optarg) end},
})
if opts.help then
return
end
opts.split_strategy = opts.split_strategy or valid_split_strategies_list[1]
return positionals
end
-- used by the gui/blueprint script
@ -133,6 +156,24 @@ function parse_commandline(opts, ...)
parse_positionals(opts, positionals, depth and 4 or 3)
end
-- returns the name of the output file for the given context
function get_filename(opts, phase)
local fullname = 'blueprints/' .. opts.name
local _,_,basename = fullname:find('/([^/]+)/?$')
if not basename then
-- should not happen since opts.name should already be validated
error(('could not parse basename out of "%s"'):format(fullname))
end
if fullname:endswith('/') then
fullname = fullname .. basename
end
if opts.split_strategy == 'phase' then
return ('%s-%s.csv'):format(fullname, phase)
end
-- no splitting
return ('%s.csv'):format(fullname)
end
-- compatibility with old exported API.
local function do_phase(start_pos, end_pos, name, phase)
local width = math.abs(start_pos.x - end_pos.x) + 1

@ -4,7 +4,8 @@ local b = require('plugins.blueprint')
function test.parse_gui_commandline()
local opts = {}
b.parse_gui_commandline(opts, {})
expect.table_eq({auto_phase=true, name='blueprint'}, opts)
expect.table_eq({auto_phase=true, split_strategy='none', name='blueprint'},
opts)
opts = {}
b.parse_gui_commandline(opts, {'help'})
@ -23,12 +24,29 @@ function test.parse_gui_commandline()
function()
b.parse_gui_commandline(opts, {'--cursor=1,2,3'})
end)
expect.table_eq({auto_phase=true, name='blueprint', start={x=1,y=2,z=3}},
expect.table_eq({auto_phase=true, split_strategy='none', name='blueprint',
start={x=1,y=2,z=3}},
opts)
opts = {}
b.parse_gui_commandline(opts, {'-tnone'})
expect.table_eq({auto_phase=true, split_strategy='none', name='blueprint'},
opts)
opts = {}
b.parse_gui_commandline(opts, {'--splitby', 'phase'})
expect.table_eq({auto_phase=true, split_strategy='phase', name='blueprint'},
opts)
opts = {}
expect.error_match('unknown split strategy',
function() b.parse_gui_commandline(opts, {'-tfoo'}) end)
opts = {}
b.parse_gui_commandline(opts, {'imaname'})
expect.table_eq({auto_phase=true, name='imaname'}, opts)
expect.table_eq({auto_phase=true, split_strategy='none',
name='imaname'},
opts)
opts = {}
expect.error_match('invalid basename',
@ -36,7 +54,8 @@ function test.parse_gui_commandline()
opts = {}
b.parse_gui_commandline(opts, {'imaname', 'dig', 'query'})
expect.table_eq({auto_phase=false, name='imaname', dig=true, query=true},
expect.table_eq({auto_phase=false, split_strategy='none', name='imaname',
dig=true, query=true},
opts)
opts = {}
@ -48,37 +67,44 @@ end
function test.parse_commandline()
local opts = {}
b.parse_commandline(opts, '1', '2')
expect.table_eq({auto_phase=true,name='blueprint',width=1,height=2,depth=1},
expect.table_eq({auto_phase=true, split_strategy='none', name='blueprint',
width=1, height=2, depth=1},
opts)
opts = {}
b.parse_commandline(opts, '1', '2', '3')
expect.table_eq({auto_phase=true,name='blueprint',width=1,height=2,depth=3},
expect.table_eq({auto_phase=true, split_strategy='none', name='blueprint',
width=1, height=2, depth=3},
opts)
opts = {}
b.parse_commandline(opts, '1', '2', '-3')
expect.table_eq({auto_phase=true,name='blueprint',width=1,height=2,depth=-3},
expect.table_eq({auto_phase=true, split_strategy='none', name='blueprint',
width=1, height=2, depth=-3},
opts)
opts = {}
b.parse_commandline(opts, '1', '2', 'imaname')
expect.table_eq({auto_phase=true,name='imaname',width=1,height=2,depth=1},
expect.table_eq({auto_phase=true, split_strategy='none', name='imaname',
width=1, height=2, depth=1},
opts)
opts = {}
b.parse_commandline(opts, '1', '2', '10imaname')
expect.table_eq({auto_phase=true,name='10imaname',width=1,height=2,depth=1},
expect.table_eq({auto_phase=true, split_strategy='none', name='10imaname',
width=1, height=2, depth=1},
opts, 'invalid depth is considered a basename')
opts = {}
b.parse_commandline(opts, '1', '2', '-10imaname')
expect.table_eq({auto_phase=true,name='-10imaname',width=1,height=2,depth=1},
expect.table_eq({auto_phase=true, split_strategy='none', name='-10imaname',
width=1, height=2, depth=1},
opts, 'invalid negative depth is considered a basename')
opts = {}
b.parse_commandline(opts, '1', '2', '3', 'imaname')
expect.table_eq({auto_phase=true,name='imaname',width=1,height=2,depth=3},
expect.table_eq({auto_phase=true, split_strategy='none', name='imaname',
width=1, height=2, depth=3},
opts)
opts = {}
@ -153,3 +179,21 @@ function test.do_phase_ensure_cursor_is_at_upper_left()
mock_run.call_args[1])
end)
end
function test.get_filename()
local opts = {name='a', split_strategy='none'}
expect.eq('blueprints/a.csv', b.get_filename(opts, 'dig'))
opts = {name='a/', split_strategy='none'}
expect.eq('blueprints/a/a.csv', b.get_filename(opts, 'dig'))
opts = {name='a', split_strategy='phase'}
expect.eq('blueprints/a-dig.csv', b.get_filename(opts, 'dig'))
opts = {name='a/', split_strategy='phase'}
expect.eq('blueprints/a/a-dig.csv', b.get_filename(opts, 'dig'))
expect.error_match('could not parse basename', function()
b.get_filename({name='', split_strategy='none'})
end)
end

@ -18,9 +18,7 @@
-- test blueprints that #build flooring and then #build a workshop on top, again
-- since the flooring is never actually built.
config = {
mode = 'fortress',
}
config.mode = 'fortress'
local blueprint = require('plugins.blueprint')
local quickfort_list = reqscript('internal/quickfort/list')
@ -32,6 +30,19 @@ local output_dir = 'library/test/ecosystem/out/'
local mode_names = {'dig', 'build', 'place', 'query'}
-- clear the output dir before each test run (but not after -- to allow
-- inspection of the results)
local function test_wrapper(test_fn)
local outdir = blueprints_dir .. output_dir
for _, v in ipairs(dfhack.filesystem.listdir_recursive(outdir)) do
if not v.isdir then
os.remove(v.path)
end
end
test_fn()
end
config.wrapper = test_wrapper
local function bad_spec(expected, varname, basename, bad_value)
qerror(('expected %s for %s in "%s" test spec; got "%s"'):
format(expected, varname, basename, bad_value))
@ -182,7 +193,8 @@ local function run_blueprint(basename, set, pos)
local blueprint_args = {tostring(set.spec.width),
tostring(set.spec.height),
tostring(-set.spec.depth),
output_dir..basename, get_cursor_arg(pos)}
output_dir..basename, get_cursor_arg(pos),
'-tphase'}
for _,mode_name in pairs(mode_names) do
if set.modes[mode_name] then table.insert(blueprint_args, mode_name) end
end