Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add screenshot feature #1391

Merged
merged 25 commits into from
May 14, 2024
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
1 change: 1 addition & 0 deletions application/F3DOptionsParser.cxx
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ void ConfigurationOptions::GetOptions(F3DAppOptions& appOptions, f3d::options& o
this->DeclareOption(grp0, "watch", "", "Watch current file and automatically reload it whenever it is modified on disk", appOptions.Watch, HasDefault::YES, MayHaveConfig::YES );
this->DeclareOption(grp0, "load-plugins", "", "List of plugins to load separated with a comma", appOptions.Plugins, LocalHasDefaultNo, MayHaveConfig::YES, "<paths or names>");
this->DeclareOption(grp0, "scan-plugins", "", "Scan standard directories for plugins and display available plugins (result can be incomplete)");
this->DeclareOption(grp0, "screenshot-filename", "", "Screenshot filename", appOptions.ScreenshotFilename, HasDefault::YES, MayHaveConfig::YES, "<filename>");

auto grp1 = cxxOptions.add_options("General");
this->DeclareOption(grp1, "verbose", "", "Set verbose level, providing more information about the loaded data in the console output", appOptions.VerboseLevel, HasDefault::YES, MayHaveConfig::YES, "{debug, info, warning, error, quiet}", HasImplicitValue::YES, "debug");
Expand Down
1 change: 1 addition & 0 deletions application/F3DOptionsParser.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ struct F3DAppOptions
bool GeometryOnly = false;
bool GroupGeometries = false;
std::string Output = "";
std::string ScreenshotFilename = "{app}/{model}_{n}.png";
std::string Reference = "";
std::string InteractionTestRecordFile = "";
std::string InteractionTestPlayFile = "";
Expand Down
252 changes: 250 additions & 2 deletions application/F3DStarter.cxx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
#include <filesystem>
#include <iostream>
#include <mutex>
#include <regex>
#include <set>

namespace fs = std::filesystem;
Expand Down Expand Up @@ -165,6 +166,198 @@
}
}

void addOutputImageMetadata(f3d::image& image)
{
std::stringstream cameraMetadata;
{
const auto state = Engine->getWindow().getCamera().getState();
const auto vec3toJson = [](const std::array<double, 3>& v)
{
std::stringstream ss;
ss << "[" << v[0] << ", " << v[1] << ", " << v[2] << "]";
return ss.str();
};
cameraMetadata << "{\n";
cameraMetadata << " \"pos\": " << vec3toJson(state.pos) << ",\n";
cameraMetadata << " \"foc\": " << vec3toJson(state.foc) << ",\n";
cameraMetadata << " \"up\": " << vec3toJson(state.up) << ",\n";
cameraMetadata << " \"angle\": " << state.angle << "\n";
cameraMetadata << "}\n";
}

image.setMetadata("camera", cameraMetadata.str());
}

/**
* Substitute the following variables in a filename template:
* - `{app}`: application name (ie. `F3D`)
* - `{version}`: application version (eg. `2.4.0`)
* - `{version_full}`: full application version (eg. `2.4.0-abcdefgh`)
* - `{model}`: current model filename without extension (eg. `foo` for `/home/user/foo.glb`)
* - `{model.ext}`: current model filename with extension (eg. `foo.glb` for `/home/user/foo.glb`)
* - `{model_ext}`: current model filename extension (eg. `glb` for `/home/user/foo.glb`)
* - `{date}`: current date in YYYYMMDD format
* - `{date:format}`: current date as per C++'s `std::put_time` format
* - `{n}`: auto-incremented number to make filename unique (up to 1000000)
* - `{n:2}`, `{n:3}`, ...: zero-padded auto-incremented number to make filename unique
* (up to 1000000)
*/
std::filesystem::path applyFilenameTemplate(const std::string& templateString)
{
constexpr size_t maxNumberingAttempts = 1000000;
const std::regex numberingRe("\\{(n:?([0-9]*))\\}");
const std::regex dateRe("date:?([A-Za-z%]*)");

/* return value for template variable name (eg. `app` -> `F3D`) */
const auto variableLookup = [&](const std::string& var)
{
if (var == "app")
{
return F3D::AppName;
}
else if (var == "version")
{
return F3D::AppVersion;
}
else if (var == "version_full")
{
return F3D::AppVersionFull;
}
else if (var == "model")
{
return FilesList[CurrentFileIndex].stem().string();
}
else if (var == "model.ext")
{
return FilesList[CurrentFileIndex].filename().string();
}
else if (var == "model_ext")
{
return FilesList[CurrentFileIndex].extension().string().substr(1);
}
else if (std::regex_match(var, dateRe))
{
auto fmt = std::regex_replace(var, dateRe, "$1");
if (fmt.empty())
{
fmt = "%Y%m%d";
}
std::time_t t = std::time(nullptr);
std::stringstream joined;
joined << std::put_time(std::localtime(&t), fmt.c_str());
return joined.str();
}
throw std::out_of_range(var);
};

/* process template as tokens, keeping track of whether they've been
* substituted or left untouched */
const auto substituteVariables = [&]()
{
const std::string varName = "[\\w_.%:-]+";
const std::string escapedVar = "(\\{(\\{" + varName + "\\})\\})";
const std::string substVar = "(\\{(" + varName + ")\\})";
const std::regex escapedVarRe(escapedVar);
const std::regex substVarRe(substVar);

std::vector<std::pair<std::string, bool> > fragments;
const auto callback = [&](const std::string& m)
{
if (std::regex_match(m, escapedVarRe))
{
fragments.emplace_back(std::regex_replace(m, escapedVarRe, "$2"), true);
}
else if (std::regex_match(m, substVarRe))
{
try
{
fragments.emplace_back(variableLookup(std::regex_replace(m, substVarRe, "$2")), true);
}
catch (std::out_of_range&)
{
fragments.emplace_back(m, false);
}
}
else
{
fragments.emplace_back(m, false);
}
};

const std::regex re(escapedVar + "|" + substVar);
std::sregex_token_iterator begin(templateString.begin(), templateString.end(), re, { -1, 0 });
std::for_each(begin, std::sregex_token_iterator(), callback);

return fragments;
};

const auto fragments = substituteVariables();

/* check the non-substituted fragments for numbering variables */
const auto hasNumbering = [&]()
{
for (const auto& [fragment, processed] : fragments)
{
if (!processed && std::regex_search(fragment, numberingRe))
{
return true;
}
}
return false;
};

/* just join and return if there's no numbering to be done */
if (!hasNumbering())
{
std::stringstream joined;
for (const auto& fragment : fragments)
{
joined << fragment.first;
}
return { joined.str() };
}

/* apply numbering in the non-substituted fragments and join */
const auto applyNumbering = [&](const size_t i)
{
std::stringstream joined;
for (const auto& [fragment, processed] : fragments)
{
if (!processed && std::regex_match(fragment, numberingRe))
{
std::stringstream formattedNumber;
try
{
const std::string fmt = std::regex_replace(fragment, numberingRe, "$2");
formattedNumber << std::setfill('0') << std::setw(std::stoi(fmt)) << i;
}
catch (std::invalid_argument&)
{
formattedNumber << std::setw(0) << i;
}
joined << std::regex_replace(fragment, numberingRe, formattedNumber.str());
}
else
{
joined << fragment;
}
}
return joined.str();
};

/* apply incrementing numbering until file doesn't exist already */
for (size_t i = 1; i <= maxNumberingAttempts; ++i)
{
const std::string candidate = applyNumbering(i);
if (!std::filesystem::exists(candidate))
{
return { candidate };
}
}
throw std::runtime_error("could not find available unique filename after " +
std::to_string(maxNumberingAttempts) + " attempts");

Check warning on line 358 in application/F3DStarter.cxx

View check run for this annotation

Codecov / codecov/patch

application/F3DStarter.cxx#L357-L358

Added lines #L357 - L358 were not covered by tests
}

F3DOptionsParser Parser;
F3DAppOptions AppOptions;
f3d::options DynamicOptions;
Expand Down Expand Up @@ -291,6 +484,13 @@
}
return true;
}

if (keySym == "F12")
{
this->SaveScreenshot(this->Internals->AppOptions.ScreenshotFilename);
return true;
}

return false;
});

Expand Down Expand Up @@ -473,6 +673,8 @@
}

f3d::image img = window.renderToImage(this->Internals->AppOptions.NoBackground);
this->Internals->addOutputImageMetadata(img);

if (renderToStdout)
{
const auto buffer = img.saveBuffer();
Expand All @@ -481,8 +683,10 @@
}
else
{
img.save(this->Internals->AppOptions.Output);
f3d::log::debug("Output image saved to ", this->Internals->AppOptions.Output);
std::filesystem::path path =
this->Internals->applyFilenameTemplate(this->Internals->AppOptions.Output);
img.save(path.string());
f3d::log::debug("Output image saved to ", path);
}

if (this->Internals->FilesList.size() > 1)
Expand Down Expand Up @@ -700,6 +904,50 @@
f3d::log::debug("Render done");
}

//----------------------------------------------------------------------------
void F3DStarter::SaveScreenshot(const std::string& filenameTemplate)
{

const auto getScreenshotDir = []()
{
for (const char* const& candidate : { "XDG_PICTURES_DIR", "HOME", "USERPROFILE" })
{
char* val = std::getenv(candidate);
if (val != nullptr)
{
std::filesystem::path path(val);
if (std::filesystem::is_directory(path))
{
return path;
}
}
}

return std::filesystem::current_path();

Check warning on line 926 in application/F3DStarter.cxx

View check run for this annotation

Codecov / codecov/patch

application/F3DStarter.cxx#L926

Added line #L926 was not covered by tests
};

std::filesystem::path pathTemplate = std::filesystem::path(filenameTemplate).make_preferred();
std::filesystem::path fullPathTemplate =
pathTemplate.is_absolute() ? pathTemplate : getScreenshotDir() / pathTemplate;
std::filesystem::path path = this->Internals->applyFilenameTemplate(fullPathTemplate.string());

std::filesystem::create_directories(std::filesystem::path(path).parent_path());
f3d::log::info("saving screenshot to " + path.string());

f3d::image img =
this->Internals->Engine->getWindow().renderToImage(this->Internals->AppOptions.NoBackground);
this->Internals->addOutputImageMetadata(img);
img.save(path.string(), f3d::image::SaveFormat::PNG);

f3d::options& options = this->Internals->Engine->getOptions();
const std::string light_intensity_key = "render.light.intensity";
const double intensity = options.getAsDouble(light_intensity_key);
options.set(light_intensity_key, intensity * 5);
this->Render();
options.set(light_intensity_key, intensity);
this->Render();
}

//----------------------------------------------------------------------------
int F3DStarter::AddFile(const fs::path& path, bool quiet)
{
Expand Down
6 changes: 6 additions & 0 deletions application/F3DStarter.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ class F3DStarter
*/
void Render();

/**
* Trigger a render and save a screenshot to disk according to a filename template.
* See `F3DStarter::F3DInternals::applyFilenameTemplate` for template substitution details.
*/
void SaveScreenshot(const std::string& filenameTemplate);

F3DStarter();
~F3DStarter();

Expand Down
26 changes: 26 additions & 0 deletions application/testing/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,32 @@ if(NOT F3D_MACOS_BUNDLE)
f3d_test(NAME TestColorMapFile DATA dragon.vtu ARGS --colormap-file=magma.png --scalars --comp=1 DEFAULT_LIGHTS)
endif()

# Screenshot Interaction
function(f3d_ss_test)
cmake_parse_arguments(F3D_SS_TEST "" "NAME;TEMPLATE;EXPECTED;DEPENDS" "ARGS" ${ARGN})
f3d_test(NAME TestScreenshot${F3D_SS_TEST_NAME} DATA suzanne.ply ARGS --screenshot-filename=${F3D_SS_TEST_TEMPLATE} --dry-run --interaction-test-play=${F3D_SOURCE_DIR}/testing/recordings/TestScreenshot.log NO_BASELINE DEPENDS TestSetupScreenshots)
f3d_test(NAME TestScreenshot${F3D_SS_TEST_NAME}File DATA suzanne.ply ARGS --ref=${F3D_SS_TEST_EXPECTED} DEPENDS TestScreenshot${F3D_SS_TEST_NAME} TestScreenshot${F3D_SS_TEST_DEPENDS} NO_BASELINE)
endfunction()

cmake_path(SET _screenshot_path ${CMAKE_BINARY_DIR}/Testing/Temporary/ss)
cmake_path(SET _screenshot_user_path ${_screenshot_path}/user)
cmake_path(NATIVE_PATH _screenshot_path _screenshot_dir)
cmake_path(NATIVE_PATH _screenshot_user_path _screenshot_user_dir)
add_test(NAME f3d::TestClearScreenshots COMMAND ${CMAKE_COMMAND} -E remove_directory ${_screenshot_dir})
add_test(NAME f3d::TestSetupScreenshots COMMAND ${CMAKE_COMMAND} -E make_directory ${_screenshot_user_dir} DEPENDS TestClearScreenshots)

f3d_ss_test(NAME Version TEMPLATE ${_screenshot_dir}/{app}_{version}_{version_full}.png EXPECTED ${_screenshot_dir}/${PROJECT_NAME}_${F3D_VERSION}_${F3D_VERSION_FULL}.png)
f3d_ss_test(NAME Model TEMPLATE ${_screenshot_dir}/{model}_{model.ext}_{model_ext}.png EXPECTED ${_screenshot_dir}/suzanne_suzanne.ply_ply.png)
f3d_ss_test(NAME ModelN1 TEMPLATE ${_screenshot_dir}/{model}_{n}_{n:2}.png EXPECTED ${_screenshot_dir}/suzanne_1_01.png)
f3d_ss_test(NAME ModelN2 TEMPLATE ${_screenshot_dir}/{model}_{n}_{n:2}.png EXPECTED ${_screenshot_dir}/suzanne_2_02.png DEPENDS ModelN1)
string(TIMESTAMP DATE_Y "%Y")
string(TIMESTAMP DATE_Ymd "%Y%m%d")
f3d_ss_test(NAME Date TEMPLATE ${_screenshot_dir}/{model}_{date}_{date:%Y}.png EXPECTED ${_screenshot_dir}/suzanne_${DATE_Ymd}_${DATE_Y}.png)
f3d_ss_test(NAME Esc TEMPLATE ${_screenshot_dir}/{model}_{{model}}_{}.png EXPECTED ${_screenshot_dir}/suzanne_{model}_{}.png)

f3d_ss_test(NAME UserModelN TEMPLATE {model}_{n}.png EXPECTED ${_screenshot_user_dir}/suzanne_1.png)
set_tests_properties(f3d::TestScreenshotUserModelN PROPERTIES ENVIRONMENT "XDG_PICTURES_DIR=${_screenshot_user_dir};HOME=${_screenshot_user_dir};USERPROFILE=${_screenshot_user_dir}")

if(NOT APPLE OR VTK_VERSION VERSION_GREATER_EQUAL 9.3.0)
f3d_test(NAME TestTextureColor DATA WaterBottle.glb ARGS --geometry-only --texture-base-color=${F3D_SOURCE_DIR}/testing/data/albedo_mod.png --translucency-support DEFAULT_LIGHTS)
endif()
Expand Down
8 changes: 8 additions & 0 deletions doc/user/INTERACTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Other hotkeys are available:
* <kbd>&rarr;</kbd>: load the next file if any and reset the camera.
* <kbd>&uarr;</kbd>: reload the current file.
* <kbd>&darr;</kbd>: add current file parent directory to the list of files, reload the current file and reset the camera.
* <kbd>F12</kbd>: take a screenshot, ie. render the current view to an image file.

When loading another file or reloading, options that have been changed interactively are kept but can be overridden
if a dedicated regular expression block in the configuration file is present, see the [configuration file](CONFIGURATION_FILE.md)
Expand All @@ -91,3 +92,10 @@ component is found.
When changing the type of data to color with, the index of the array within the data will be kept if valid
with the new data. If not, it will cycle until a valid array is found. After that, the component will be checked
as specified above.

## Taking Screenshots

The destination filename used to save the screenshots (created by pressing <kbd>F12</kbd>) is configurable (using the `screenshot-filename` option) and can use template variables as described [on the options page](OPTIONS.md#filename-templating).

Unless the configured filename template is an absolute path, images will be saved into the user's home directory
(using the following environment variables, if defined and pointing to an existing directory, in that order: `XDG_PICTURES_DIR`, `HOME`, or `USERPROFILE`).
Loading