From c165dc5e50e5cb6371b3062fa4dec5e209686047 Mon Sep 17 00:00:00 2001 From: Peter Johnson Date: Sat, 1 Feb 2020 21:30:23 -0800 Subject: [PATCH] Simulation GUI: Add 2D field view (#2261) The field image and robot image can be loaded or just a wireframe used. The robot can be moved and rotated with a mouse click + drag. The robot position is settable in robot code via the Field2d class. --- .styleguide | 1 + ThirdPartyNotices.txt | 1 + imgui/CMakeLists.txt | 9 +- imgui/CMakeLists.txt.in | 10 + shared/config.gradle | 2 +- .../src/main/native/cpp/Field2D.cpp | 645 ++++++++ .../halsim_gui/src/main/native/cpp/Field2D.h | 17 + .../src/main/native/cpp/GuiUtil.cpp | 62 + .../src/main/native/cpp/HALSimGui.cpp | 17 + .../halsim_gui/src/main/native/cpp/main.cpp | 4 +- .../src/main/native/include/GuiUtil.h | 29 + .../src/main/native/include/HALSimGui.h | 11 +- .../native/include/portable-file-dialogs.h | 1305 +++++++++++++++++ .../main/native/cpp/simulation/Field2d.cpp | 49 + .../native/include/frc/simulation/Field2d.h | 70 + .../wpi/first/wpilibj/simulation/Field2d.java | 98 ++ 16 files changed, 2325 insertions(+), 5 deletions(-) create mode 100644 simulation/halsim_gui/src/main/native/cpp/Field2D.cpp create mode 100644 simulation/halsim_gui/src/main/native/cpp/Field2D.h create mode 100644 simulation/halsim_gui/src/main/native/cpp/GuiUtil.cpp create mode 100644 simulation/halsim_gui/src/main/native/include/GuiUtil.h create mode 100644 simulation/halsim_gui/src/main/native/include/portable-file-dialogs.h create mode 100644 wpilibc/src/main/native/cpp/simulation/Field2d.cpp create mode 100644 wpilibc/src/main/native/include/frc/simulation/Field2d.h create mode 100644 wpilibj/src/main/java/edu/wpi/first/wpilibj/simulation/Field2d.java diff --git a/.styleguide b/.styleguide index d465d74f49..8a98b1b7c1 100644 --- a/.styleguide +++ b/.styleguide @@ -11,6 +11,7 @@ cppSrcFileInclude { generatedFileExclude { FRCNetComm\.java$ simulation/gz_msgs/src/include/simulation/gz_msgs/msgs\.h$ + simulation/halsim_gui/src/main/native/include/portable-file-dialogs\.h$ } repoRootNameOverride { diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index fae1250016..61ad9a75e0 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -44,6 +44,7 @@ Team 254 Library wpilibj/src/main/java/edu/wpi/first/wpilibj/spline/SplineP wpilibc/src/main/native/include/spline/SplineParameterizer.h wpilibc/src/main/native/include/trajectory/TrajectoryParameterizer.h wpilibc/src/main/native/cpp/trajectory/TrajectoryParameterizer.cpp +Portable File Dialogs simulation/halsim_gui/src/main/native/include/portable-file-dialogs.h ============================================================================== diff --git a/imgui/CMakeLists.txt b/imgui/CMakeLists.txt index 664c045293..b64cc6767f 100644 --- a/imgui/CMakeLists.txt +++ b/imgui/CMakeLists.txt @@ -32,6 +32,11 @@ file(GENERATE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/imgui_ProggyDotted.h set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/imgui_ProggyDotted.cpp PROPERTIES OBJECT_DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/ProggyDotted.inc) +# stb_image +file(GENERATE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/stb_image.cpp + CONTENT "#define STBI_WINDOWS_UTF8\n#define STB_IMAGE_IMPLEMENTATION\n#include \"stb_image.h\"\n" +) + # Add imgui directly to our build. set(SAVE_BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS}) set(BUILD_SHARED_LIBS OFF) @@ -46,8 +51,8 @@ add_subdirectory(${CMAKE_CURRENT_BINARY_DIR}/gl3w-src set(imgui_srcdir ${CMAKE_CURRENT_BINARY_DIR}/imgui-src) file(GLOB imgui_sources ${imgui_srcdir}/*.cpp) -add_library(imgui STATIC ${imgui_sources} ${imgui_srcdir}/examples/imgui_impl_glfw.cpp ${imgui_srcdir}/examples/imgui_impl_opengl3.cpp ${CMAKE_CURRENT_BINARY_DIR}/imgui_ProggyDotted.cpp) +add_library(imgui STATIC ${imgui_sources} ${imgui_srcdir}/examples/imgui_impl_glfw.cpp ${imgui_srcdir}/examples/imgui_impl_opengl3.cpp ${CMAKE_CURRENT_BINARY_DIR}/imgui_ProggyDotted.cpp ${CMAKE_CURRENT_BINARY_DIR}/stb_image.cpp) target_link_libraries(imgui PUBLIC gl3w glfw) -target_include_directories(imgui PUBLIC "$" "$" "$") +target_include_directories(imgui PUBLIC "$" "$" "$" "$") set_property(TARGET imgui PROPERTY POSITION_INDEPENDENT_CODE ON) diff --git a/imgui/CMakeLists.txt.in b/imgui/CMakeLists.txt.in index a5c60915c9..48714eb620 100644 --- a/imgui/CMakeLists.txt.in +++ b/imgui/CMakeLists.txt.in @@ -41,3 +41,13 @@ ExternalProject_Add(proggyfonts INSTALL_COMMAND "" TEST_COMMAND "" ) +ExternalProject_Add(stb + GIT_REPOSITORY https://github.com/nothings/stb.git + GIT_TAG f67165c2bb2af3060ecae7d20d6f731173485ad0 + SOURCE_DIR "${CMAKE_CURRENT_BINARY_DIR}/stb-src" + BINARY_DIR "${CMAKE_CURRENT_BINARY_DIR}/stb-build" + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND "" + TEST_COMMAND "" +) diff --git a/shared/config.gradle b/shared/config.gradle index 7952018ffb..d8eec3e575 100644 --- a/shared/config.gradle +++ b/shared/config.gradle @@ -11,7 +11,7 @@ nativeUtils { niLibVersion = "2020.10.1" opencvVersion = "3.4.7-2" googleTestVersion = "1.9.0-4-437e100-1" - imguiVersion = "1.72b-2" + imguiVersion = "1.72b-3" } } } diff --git a/simulation/halsim_gui/src/main/native/cpp/Field2D.cpp b/simulation/halsim_gui/src/main/native/cpp/Field2D.cpp new file mode 100644 index 0000000000..5310853809 --- /dev/null +++ b/simulation/halsim_gui/src/main/native/cpp/Field2D.cpp @@ -0,0 +1,645 @@ +/*----------------------------------------------------------------------------*/ +/* Copyright (c) 2020 FIRST. All Rights Reserved. */ +/* Open Source Software - may be modified and shared by FRC teams. The code */ +/* must be accompanied by the FIRST BSD license file in the root directory of */ +/* the project. */ +/*----------------------------------------------------------------------------*/ + +#include "Field2D.h" + +#include + +#include +#include +#include + +#define IMGUI_DEFINE_MATH_OPERATORS +#include +#include +#include +#include +#include +#include +#include +#include + +#include "GuiUtil.h" +#include "HALSimGui.h" +#include "SimDeviceGui.h" +#include "portable-file-dialogs.h" + +using namespace halsimgui; + +namespace { + +// Per-frame field data (not persistent) +struct FieldFrameData { + // in window coordinates + ImVec2 imageMin; + ImVec2 imageMax; + ImVec2 min; + ImVec2 max; + + float scale; // scaling from field units to screen units +}; + +class FieldInfo { + public: + static constexpr float kDefaultWidth = 15.98f; + static constexpr float kDefaultHeight = 8.21f; + + std::unique_ptr m_fileOpener; + float m_width = kDefaultWidth; + float m_height = kDefaultHeight; + + void Reset(); + void LoadImage(); + void LoadJson(const wpi::Twine& jsonfile); + FieldFrameData GetFrameData() const; + void Draw(ImDrawList* drawList, const ImVec2& windowPos, + const FieldFrameData& frameData) const; + + bool ReadIni(wpi::StringRef name, wpi::StringRef value); + void WriteIni(ImGuiTextBuffer* out) const; + + private: + bool LoadImageImpl(const wpi::Twine& fn); + + std::string m_filename; + GLuint m_texture = 0; + int m_imageWidth = 0; + int m_imageHeight = 0; + int m_top = 0; + int m_left = 0; + int m_bottom = -1; + int m_right = -1; +}; + +// Per-frame robot data (not persistent) +struct RobotFrameData { + // in window coordinates + ImVec2 center; + ImVec2 corners[4]; + ImVec2 arrow[3]; + + // scaled width/2 and length/2, in screen units + float width2; + float length2; +}; + +class RobotInfo { + public: + static constexpr float kDefaultWidth = 0.6858f; + static constexpr float kDefaultLength = 0.8204f; + + std::unique_ptr m_fileOpener; + float m_width = kDefaultWidth; + float m_length = kDefaultLength; + + void Reset(); + void LoadImage(); + void UpdateFromSimDevice(); + void SetPosition(double x, double y); + // set and get rotation in radians + void SetRotation(double rot); + double GetRotation() const { + return units::convert(m_rot); + } + RobotFrameData GetFrameData(const FieldFrameData& ffd) const; + void Draw(ImDrawList* drawList, const ImVec2& windowPos, + const RobotFrameData& frameData, int hit, float hitRadius) const; + + bool ReadIni(wpi::StringRef name, wpi::StringRef value); + void WriteIni(ImGuiTextBuffer* out) const; + + private: + bool LoadImageImpl(const wpi::Twine& fn); + + std::string m_filename; + GLuint m_texture = 0; + + HAL_SimDeviceHandle m_devHandle = 0; + hal::SimDouble m_xHandle; + hal::SimDouble m_yHandle; + hal::SimDouble m_rotHandle; + + double m_x = 0; + double m_y = 0; + double m_rot = 0; +}; + +} // namespace + +static FieldInfo gField; +static RobotInfo gRobot; +static int gDragRobot = 0; +static ImVec2 gDragInitialOffset; +static double gDragInitialAngle; + +// read/write settings to ini file +static void* Field2DReadOpen(ImGuiContext* ctx, ImGuiSettingsHandler* handler, + const char* name) { + if (name == wpi::StringRef{"Field"}) return &gField; + if (name == wpi::StringRef{"Robot"}) return &gRobot; + return nullptr; +} + +static void Field2DReadLine(ImGuiContext* ctx, ImGuiSettingsHandler* handler, + void* entry, const char* lineStr) { + wpi::StringRef line{lineStr}; + auto [name, value] = line.split('='); + name = name.trim(); + value = value.trim(); + if (entry == &gField) + gField.ReadIni(name, value); + else if (entry == &gRobot) + gRobot.ReadIni(name, value); +} + +static void Field2DWriteAll(ImGuiContext* ctx, ImGuiSettingsHandler* handler, + ImGuiTextBuffer* out_buf) { + gField.WriteIni(out_buf); + gRobot.WriteIni(out_buf); +} + +void FieldInfo::Reset() { + if (m_texture != 0) glDeleteTextures(1, &m_texture); + m_texture = 0; + m_filename.clear(); + m_imageWidth = 0; + m_imageHeight = 0; + m_top = 0; + m_left = 0; + m_bottom = -1; + m_right = -1; +} + +void FieldInfo::LoadImage() { + if (m_fileOpener && m_fileOpener->ready(0)) { + if (wpi::StringRef(m_fileOpener->result()[0]).endswith(".json")) { + LoadJson(m_fileOpener->result()[0]); + } else { + LoadImageImpl(m_fileOpener->result()[0]); + m_top = 0; + m_left = 0; + m_bottom = -1; + m_right = -1; + } + m_fileOpener.reset(); + } + if (m_texture == 0 && !m_filename.empty()) { + if (!LoadImageImpl(m_filename)) m_filename.clear(); + } +} + +void FieldInfo::LoadJson(const wpi::Twine& jsonfile) { + std::error_code ec; + wpi::raw_fd_istream f(jsonfile, ec); + if (ec) { + wpi::errs() << "GUI: could not open field JSON file\n"; + return; + } + + // parse file + wpi::json j; + try { + j = wpi::json::parse(f); + } catch (const wpi::json::parse_error& e) { + wpi::errs() << "GUI: JSON: could not parse: " << e.what() << '\n'; + } + + // top level must be an object + if (!j.is_object()) { + wpi::errs() << "GUI: JSON: does not contain a top object\n"; + return; + } + + // image filename + std::string image; + try { + image = j.at("field-image").get(); + } catch (const wpi::json::exception& e) { + wpi::errs() << "GUI: JSON: could not read field-image: " << e.what() + << '\n'; + return; + } + + // corners + int top, left, bottom, right; + try { + top = j.at("field-corners").at("top-left").at(1).get(); + left = j.at("field-corners").at("top-left").at(0).get(); + bottom = j.at("field-corners").at("bottom-right").at(1).get(); + right = j.at("field-corners").at("bottom-right").at(0).get(); + } catch (const wpi::json::exception& e) { + wpi::errs() << "GUI: JSON: could not read field-corners: " << e.what() + << '\n'; + return; + } + + // size + float width; + float height; + try { + width = j.at("field-size").at(0).get(); + height = j.at("field-size").at(1).get(); + } catch (const wpi::json::exception& e) { + wpi::errs() << "GUI: JSON: could not read field-size: " << e.what() << '\n'; + return; + } + + // units for size + std::string unit; + try { + unit = j.at("field-unit").get(); + } catch (const wpi::json::exception& e) { + wpi::errs() << "GUI: JSON: could not read field-unit: " << e.what() << '\n'; + return; + } + + // convert size units to meters + if (unit == "foot" || unit == "feet") { + width = units::convert(width); + height = units::convert(height); + } + + // the image filename is relative to the json file + wpi::SmallString<128> pathname; + jsonfile.toVector(pathname); + wpi::sys::path::remove_filename(pathname); + wpi::sys::path::append(pathname, image); + + // load field image + if (!LoadImageImpl(pathname)) return; + + // save to field info + m_filename = pathname.str(); + m_top = top; + m_left = left; + m_bottom = bottom; + m_right = right; + m_width = width; + m_height = height; +} + +bool FieldInfo::LoadImageImpl(const wpi::Twine& fn) { + wpi::outs() << "GUI: loading field image '" << fn << "'\n"; + GLuint oldTexture = m_texture; + if (!LoadTextureFromFile(fn, &m_texture, &m_imageWidth, &m_imageHeight)) { + wpi::errs() << "GUI: could not read field image\n"; + return false; + } + if (oldTexture != 0) glDeleteTextures(1, &oldTexture); + m_filename = fn.str(); + return true; +} + +FieldFrameData FieldInfo::GetFrameData() const { + FieldFrameData ffd; + + // get window content region + ffd.imageMin = ImGui::GetWindowContentRegionMin(); + ffd.imageMax = ImGui::GetWindowContentRegionMax(); + + // fit the image into the window + if (m_texture != 0 && m_imageHeight != 0 && m_imageWidth != 0) + MaxFit(&ffd.imageMin, &ffd.imageMax, m_imageWidth, m_imageHeight); + + ImVec2 min = ffd.imageMin; + ImVec2 max = ffd.imageMax; + + // size down the box by the image corners (if any) + if (m_bottom > 0 && m_right > 0) { + min.x += m_left * (max.x - min.x) / m_imageWidth; + min.y += m_top * (max.y - min.y) / m_imageHeight; + max.x -= (m_imageWidth - m_right) * (max.x - min.x) / m_imageWidth; + max.y -= (m_imageHeight - m_bottom) * (max.y - min.y) / m_imageHeight; + } + + // draw the field "active area" as a yellow boundary box + MaxFit(&min, &max, m_width, m_height); + + ffd.min = min; + ffd.max = max; + ffd.scale = (max.x - min.x) / m_width; + return ffd; +} + +void FieldInfo::Draw(ImDrawList* drawList, const ImVec2& windowPos, + const FieldFrameData& ffd) const { + if (m_texture != 0 && m_imageHeight != 0 && m_imageWidth != 0) { + drawList->AddImage( + reinterpret_cast(static_cast(m_texture)), + windowPos + ffd.imageMin, windowPos + ffd.imageMax); + } + + // draw the field "active area" as a yellow boundary box + drawList->AddRect(windowPos + ffd.min, windowPos + ffd.max, + IM_COL32(255, 255, 0, 255)); +} + +bool FieldInfo::ReadIni(wpi::StringRef name, wpi::StringRef value) { + if (name == "image") { + m_filename = value; + } else if (name == "top") { + int num; + if (value.getAsInteger(10, num)) return true; + m_top = num; + } else if (name == "left") { + int num; + if (value.getAsInteger(10, num)) return true; + m_left = num; + } else if (name == "bottom") { + int num; + if (value.getAsInteger(10, num)) return true; + m_bottom = num; + } else if (name == "right") { + int num; + if (value.getAsInteger(10, num)) return true; + m_right = num; + } else if (name == "width") { + std::sscanf(value.data(), "%f", &m_width); + } else if (name == "height") { + std::sscanf(value.data(), "%f", &m_height); + } else { + return false; + } + return true; +} + +void FieldInfo::WriteIni(ImGuiTextBuffer* out) const { + out->appendf( + "[Field2D][Field]\nimage=%s\ntop=%d\nleft=%d\nbottom=%d\nright=%d\nwidth=" + "%f\nheight=%f\n\n", + m_filename.c_str(), m_top, m_left, m_bottom, m_right, m_width, m_height); +} + +void RobotInfo::Reset() { + if (m_texture != 0) glDeleteTextures(1, &m_texture); + m_texture = 0; + m_filename.clear(); +} + +void RobotInfo::LoadImage() { + if (m_fileOpener && m_fileOpener->ready(0)) { + LoadImageImpl(m_fileOpener->result()[0]); + m_fileOpener.reset(); + } + if (m_texture == 0 && !m_filename.empty()) { + if (!LoadImageImpl(m_filename)) m_filename.clear(); + } +} + +bool RobotInfo::LoadImageImpl(const wpi::Twine& fn) { + wpi::outs() << "GUI: loading robot image '" << fn << "'\n"; + GLuint oldTexture = m_texture; + if (!LoadTextureFromFile(fn, &m_texture, nullptr, nullptr)) { + wpi::errs() << "GUI: could not read robot image\n"; + return false; + } + if (oldTexture != 0) glDeleteTextures(1, &oldTexture); + m_filename = fn.str(); + return true; +} + +void RobotInfo::UpdateFromSimDevice() { + if (m_devHandle == 0) m_devHandle = HALSIM_GetSimDeviceHandle("Field2D"); + if (m_devHandle == 0) return; + + if (!m_xHandle) m_xHandle = HALSIM_GetSimValueHandle(m_devHandle, "x"); + if (m_xHandle) m_x = m_xHandle.Get(); + + if (!m_yHandle) m_yHandle = HALSIM_GetSimValueHandle(m_devHandle, "y"); + if (m_yHandle) m_y = m_yHandle.Get(); + + if (!m_rotHandle) m_rotHandle = HALSIM_GetSimValueHandle(m_devHandle, "rot"); + if (m_rotHandle) m_rot = m_rotHandle.Get(); +} + +void RobotInfo::SetPosition(double x, double y) { + m_x = x; + m_y = y; + if (m_xHandle) m_xHandle.Set(x); + if (m_yHandle) m_yHandle.Set(y); +} + +void RobotInfo::SetRotation(double rot) { + double rotDegrees = units::convert(rot); + // force to -180 to +180 range + rotDegrees = rotDegrees + std::ceil((-rotDegrees - 180) / 360) * 360; + m_rot = rotDegrees; + if (m_rotHandle) m_rotHandle.Set(rotDegrees); +} + +RobotFrameData RobotInfo::GetFrameData(const FieldFrameData& ffd) const { + RobotFrameData rfd; + float width2 = ffd.scale * m_width / 2; + float length2 = ffd.scale * m_length / 2; + + // (0,0) origin is bottom left + ImVec2 center(ffd.min.x + ffd.scale * m_x, ffd.max.y - ffd.scale * m_y); + + // build rotated points around center + double rot = GetRotation(); + float cos_a = std::cos(-rot); + float sin_a = std::sin(-rot); + + rfd.corners[0] = center + ImRotate(ImVec2(-length2, -width2), cos_a, sin_a); + rfd.corners[1] = center + ImRotate(ImVec2(length2, -width2), cos_a, sin_a); + rfd.corners[2] = center + ImRotate(ImVec2(length2, width2), cos_a, sin_a); + rfd.corners[3] = center + ImRotate(ImVec2(-length2, width2), cos_a, sin_a); + rfd.arrow[0] = + center + ImRotate(ImVec2(-length2 / 2, -width2 / 2), cos_a, sin_a); + rfd.arrow[1] = center + ImRotate(ImVec2(length2 / 2, 0), cos_a, sin_a); + rfd.arrow[2] = + center + ImRotate(ImVec2(-length2 / 2, width2 / 2), cos_a, sin_a); + + rfd.center = center; + rfd.width2 = width2; + rfd.length2 = length2; + return rfd; +} + +void RobotInfo::Draw(ImDrawList* drawList, const ImVec2& windowPos, + const RobotFrameData& rfd, int hit, + float hitRadius) const { + if (m_texture != 0) { + drawList->AddImageQuad( + reinterpret_cast(static_cast(m_texture)), + windowPos + rfd.corners[0], windowPos + rfd.corners[1], + windowPos + rfd.corners[2], windowPos + rfd.corners[3]); + } else { + drawList->AddQuad(windowPos + rfd.corners[0], windowPos + rfd.corners[1], + windowPos + rfd.corners[2], windowPos + rfd.corners[3], + IM_COL32(255, 0, 0, 255), 4.0); + drawList->AddTriangle(windowPos + rfd.arrow[0], windowPos + rfd.arrow[1], + windowPos + rfd.arrow[2], IM_COL32(0, 255, 0, 255), + 4.0); + } + + if (hit > 0) { + if (hit == 1) { + drawList->AddCircle(windowPos + rfd.center, hitRadius, + IM_COL32(0, 255, 0, 255)); + } else { + drawList->AddCircle(windowPos + rfd.corners[hit - 2], hitRadius, + IM_COL32(0, 255, 0, 255)); + } + } +} + +bool RobotInfo::ReadIni(wpi::StringRef name, wpi::StringRef value) { + if (name == "image") { + m_filename = value; + } else if (name == "width") { + std::sscanf(value.data(), "%f", &m_width); + } else if (name == "length") { + std::sscanf(value.data(), "%f", &m_length); + } else { + return false; + } + return true; +} + +void RobotInfo::WriteIni(ImGuiTextBuffer* out) const { + out->appendf("[Field2D][Robot]\nimage=%s\nwidth=%f\nlength=%f\n\n", + m_filename.c_str(), m_width, m_length); +} + +static void OptionMenuField2D() { + if (ImGui::BeginMenu("2D Field View")) { + if (ImGui::MenuItem("Choose field image...")) { + gField.m_fileOpener = std::make_unique( + "Choose field image", "", + std::vector{"Image File", + "*.jpg *.jpeg *.png *.bmp *.psd *.tga *.gif " + "*.hdr *.pic *.ppm *.pgm", + "PathWeaver JSON File", "*.json"}); + } + if (ImGui::MenuItem("Reset field image")) { + gField.Reset(); + } + if (ImGui::MenuItem("Choose robot image...")) { + gRobot.m_fileOpener = std::make_unique( + "Choose robot image", "", + std::vector{"Image File", + "*.jpg *.jpeg *.png *.bmp *.psd *.tga *.gif " + "*.hdr *.pic *.ppm *.pgm"}); + } + if (ImGui::MenuItem("Reset robot image")) { + gRobot.Reset(); + } + ImGui::EndMenu(); + } +} + +static void DisplayField2DSettings() { + ImGui::PushItemWidth(ImGui::GetFontSize() * 4); + ImGui::InputFloat("Field Width", &gField.m_width); + ImGui::InputFloat("Field Height", &gField.m_height); + // ImGui::InputInt("Field Top", &gField.m_top); + // ImGui::InputInt("Field Left", &gField.m_left); + // ImGui::InputInt("Field Right", &gField.m_right); + // ImGui::InputInt("Field Bottom", &gField.m_bottom); + ImGui::InputFloat("Robot Width", &gRobot.m_width); + ImGui::InputFloat("Robot Length", &gRobot.m_length); + ImGui::PopItemWidth(); +} + +static void DisplayField2D() { + // load images + gField.LoadImage(); + gRobot.LoadImage(); + + // get robot coordinates from SimDevice + gRobot.UpdateFromSimDevice(); + + FieldFrameData ffd = gField.GetFrameData(); + RobotFrameData rfd = gRobot.GetFrameData(ffd); + + ImVec2 windowPos = ImGui::GetWindowPos(); + + // for dragging to work, there needs to be a button (otherwise the window is + // dragged) + ImGui::InvisibleButton("field", ImGui::GetContentRegionAvail()); + + // allow dragging the robot around + ImVec2 cursor = ImGui::GetIO().MousePos - windowPos; + + int hit = 0; + float hitRadius = (std::min)(rfd.width2, rfd.length2) / 2; + // only allow initiation of dragging when invisible button is hovered; this + // prevents the window resize handles from simultaneously activating the drag + // functionality + if (ImGui::IsItemHovered()) { + float hitRadiusSquared = hitRadius * hitRadius; + // it's within the hit radius of the center? + if (GetDistSquared(cursor, rfd.center) < hitRadiusSquared) + hit = 1; + else if (GetDistSquared(cursor, rfd.corners[0]) < hitRadiusSquared) + hit = 2; + else if (GetDistSquared(cursor, rfd.corners[1]) < hitRadiusSquared) + hit = 3; + else if (GetDistSquared(cursor, rfd.corners[2]) < hitRadiusSquared) + hit = 4; + else if (GetDistSquared(cursor, rfd.corners[3]) < hitRadiusSquared) + hit = 5; + if (hit > 0 && ImGui::IsMouseClicked(0)) { + if (hit == 1) { + gDragRobot = hit; + gDragInitialOffset = cursor - rfd.center; + } else { + gDragRobot = hit; + ImVec2 off = cursor - rfd.center; + gDragInitialAngle = std::atan2(off.y, off.x) + gRobot.GetRotation(); + } + } + } + + if (gDragRobot > 0 && ImGui::IsMouseDown(0)) { + if (gDragRobot == 1) { + ImVec2 newPos = cursor - gDragInitialOffset; + gRobot.SetPosition( + (std::clamp(newPos.x, ffd.min.x, ffd.max.x) - ffd.min.x) / ffd.scale, + (ffd.max.y - std::clamp(newPos.y, ffd.min.y, ffd.max.y)) / ffd.scale); + rfd = gRobot.GetFrameData(ffd); + } else { + ImVec2 off = cursor - rfd.center; + gRobot.SetRotation(gDragInitialAngle - std::atan2(off.y, off.x)); + } + hit = gDragRobot; // keep it highlighted + } else { + gDragRobot = 0; + } + + // draw + auto drawList = ImGui::GetWindowDrawList(); + gField.Draw(drawList, windowPos, ffd); + gRobot.Draw(drawList, windowPos, rfd, hit, hitRadius); +} + +void Field2D::Initialize() { + // hook ini handler to save settings + ImGuiSettingsHandler iniHandler; + iniHandler.TypeName = "Field2D"; + iniHandler.TypeHash = ImHashStr(iniHandler.TypeName); + iniHandler.ReadOpenFn = Field2DReadOpen; + iniHandler.ReadLineFn = Field2DReadLine; + iniHandler.WriteAllFn = Field2DWriteAll; + ImGui::GetCurrentContext()->SettingsHandlers.push_back(iniHandler); + + HALSimGui::AddOptionMenu(OptionMenuField2D); + + HALSimGui::AddWindow("2D Field Settings", DisplayField2DSettings, + ImGuiWindowFlags_AlwaysAutoResize); + HALSimGui::SetWindowVisibility("2D Field Settings", HALSimGui::kHide); + HALSimGui::SetDefaultWindowPos("2D Field Settings", 200, 150); + + HALSimGui::AddWindow("2D Field View", DisplayField2D); + HALSimGui::SetWindowVisibility("2D Field View", HALSimGui::kHide); + HALSimGui::SetDefaultWindowPos("2D Field View", 200, 200); + HALSimGui::SetDefaultWindowSize("2D Field View", 400, 200); + HALSimGui::SetWindowPadding("2D Field View", 0, 0); + + // SimDeviceGui::Hide("Field2D"); +} diff --git a/simulation/halsim_gui/src/main/native/cpp/Field2D.h b/simulation/halsim_gui/src/main/native/cpp/Field2D.h new file mode 100644 index 0000000000..52218f3878 --- /dev/null +++ b/simulation/halsim_gui/src/main/native/cpp/Field2D.h @@ -0,0 +1,17 @@ +/*----------------------------------------------------------------------------*/ +/* Copyright (c) 2020 FIRST. All Rights Reserved. */ +/* Open Source Software - may be modified and shared by FRC teams. The code */ +/* must be accompanied by the FIRST BSD license file in the root directory of */ +/* the project. */ +/*----------------------------------------------------------------------------*/ + +#pragma once + +namespace halsimgui { + +class Field2D { + public: + static void Initialize(); +}; + +} // namespace halsimgui diff --git a/simulation/halsim_gui/src/main/native/cpp/GuiUtil.cpp b/simulation/halsim_gui/src/main/native/cpp/GuiUtil.cpp new file mode 100644 index 0000000000..1b2640f706 --- /dev/null +++ b/simulation/halsim_gui/src/main/native/cpp/GuiUtil.cpp @@ -0,0 +1,62 @@ +/*----------------------------------------------------------------------------*/ +/* Copyright (c) 2020 FIRST. All Rights Reserved. */ +/* Open Source Software - may be modified and shared by FRC teams. The code */ +/* must be accompanied by the FIRST BSD license file in the root directory of */ +/* the project. */ +/*----------------------------------------------------------------------------*/ + +#include "GuiUtil.h" + +#include + +#include + +bool halsimgui::LoadTextureFromFile(const wpi::Twine& filename, + GLuint* out_texture, int* out_width, + int* out_height) { + wpi::SmallString<128> buf; + + // Load from file + int width = 0; + int height = 0; + unsigned char* data = + stbi_load(filename.toNullTerminatedStringRef(buf).data(), &width, &height, + nullptr, 4); + if (!data) return false; + + // Create a OpenGL texture identifier + GLuint texture; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + + // Setup filtering parameters for display + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + + // Upload pixels into texture + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, + GL_UNSIGNED_BYTE, data); + stbi_image_free(data); + + *out_texture = texture; + if (out_width) *out_width = width; + if (out_height) *out_height = height; + + return true; +} + +void halsimgui::MaxFit(ImVec2* min, ImVec2* max, float width, float height) { + float destWidth = max->x - min->x; + float destHeight = max->y - min->y; + if (width == 0 || height == 0) return; + if (destWidth * height > destHeight * width) { + float outputWidth = width * destHeight / height; + min->x += (destWidth - outputWidth) / 2; + max->x -= (destWidth - outputWidth) / 2; + } else { + float outputHeight = height * destWidth / width; + min->y += (destHeight - outputHeight) / 2; + max->y -= (destHeight - outputHeight) / 2; + } +} diff --git a/simulation/halsim_gui/src/main/native/cpp/HALSimGui.cpp b/simulation/halsim_gui/src/main/native/cpp/HALSimGui.cpp index e630e00362..28e1fed44f 100644 --- a/simulation/halsim_gui/src/main/native/cpp/HALSimGui.cpp +++ b/simulation/halsim_gui/src/main/native/cpp/HALSimGui.cpp @@ -40,6 +40,8 @@ struct WindowInfo { ImGuiCond sizeCond = 0; ImVec2 pos; ImVec2 size; + bool setPadding = false; + ImVec2 padding; }; } // namespace @@ -248,6 +250,14 @@ void HALSimGui::SetDefaultWindowSize(const char* name, float width, window.size = ImVec2{width, height}; } +void HALSimGui::SetWindowPadding(const char* name, float x, float y) { + auto it = gWindowMap.find(name); + if (it == gWindowMap.end()) return; + auto& window = gWindows[it->second]; + window.setPadding = true; + window.padding = ImVec2{x, y}; +} + bool HALSimGui::AreOutputsDisabled() { return gDisableOutputsOnDSDisable && !HALSIM_GetDriverStationEnabled(); } @@ -521,9 +531,12 @@ void HALSimGui::Main(void*) { ImGui::SetNextWindowPos(window.pos, window.posCond); if (window.sizeCond != 0) ImGui::SetNextWindowSize(window.size, window.sizeCond); + if (window.setPadding) + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, window.padding); if (ImGui::Begin(window.name.c_str(), &window.visible, window.flags)) window.display(); ImGui::End(); + if (window.setPadding) ImGui::PopStyleVar(); } } @@ -597,6 +610,10 @@ void HALSIMGUI_SetDefaultWindowSize(const char* name, float width, HALSimGui::SetDefaultWindowSize(name, width, height); } +void HALSIMGUI_SetWindowPadding(const char* name, float x, float y) { + HALSimGui::SetDefaultWindowSize(name, x, y); +} + int HALSIMGUI_AreOutputsDisabled(void) { return HALSimGui::AreOutputsDisabled(); } diff --git a/simulation/halsim_gui/src/main/native/cpp/main.cpp b/simulation/halsim_gui/src/main/native/cpp/main.cpp index d5e0b168a6..2362d34311 100644 --- a/simulation/halsim_gui/src/main/native/cpp/main.cpp +++ b/simulation/halsim_gui/src/main/native/cpp/main.cpp @@ -1,5 +1,5 @@ /*----------------------------------------------------------------------------*/ -/* Copyright (c) 2019 FIRST. All Rights Reserved. */ +/* Copyright (c) 2019-2020 FIRST. All Rights Reserved. */ /* Open Source Software - may be modified and shared by FRC teams. The code */ /* must be accompanied by the FIRST BSD license file in the root directory of */ /* the project. */ @@ -17,6 +17,7 @@ #include "DIOGui.h" #include "DriverStationGui.h" #include "EncoderGui.h" +#include "Field2D.h" #include "HALSimGui.h" #include "PDPGui.h" #include "PWMGui.h" @@ -42,6 +43,7 @@ __declspec(dllexport) HALSimGui::Add(DriverStationGui::Initialize); HALSimGui::Add(DIOGui::Initialize); HALSimGui::Add(EncoderGui::Initialize); + HALSimGui::Add(Field2D::Initialize); HALSimGui::Add(PDPGui::Initialize); HALSimGui::Add(PWMGui::Initialize); HALSimGui::Add(RelayGui::Initialize); diff --git a/simulation/halsim_gui/src/main/native/include/GuiUtil.h b/simulation/halsim_gui/src/main/native/include/GuiUtil.h new file mode 100644 index 0000000000..08850dbdf3 --- /dev/null +++ b/simulation/halsim_gui/src/main/native/include/GuiUtil.h @@ -0,0 +1,29 @@ +/*----------------------------------------------------------------------------*/ +/* Copyright (c) 2020 FIRST. All Rights Reserved. */ +/* Open Source Software - may be modified and shared by FRC teams. The code */ +/* must be accompanied by the FIRST BSD license file in the root directory of */ +/* the project. */ +/*----------------------------------------------------------------------------*/ + +#pragma once + +#include +#include +#include + +namespace halsimgui { + +bool LoadTextureFromFile(const wpi::Twine& filename, GLuint* out_texture, + int* out_width, int* out_height); + +// get distance^2 between two ImVec's +inline float GetDistSquared(const ImVec2& a, const ImVec2& b) { + float deltaX = b.x - a.x; + float deltaY = b.y - a.y; + return deltaX * deltaX + deltaY * deltaY; +} + +// maximize fit while preserving aspect ratio +void MaxFit(ImVec2* min, ImVec2* max, float width, float height); + +} // namespace halsimgui diff --git a/simulation/halsim_gui/src/main/native/include/HALSimGui.h b/simulation/halsim_gui/src/main/native/include/HALSimGui.h index fd71cbee1e..7a710d100c 100644 --- a/simulation/halsim_gui/src/main/native/include/HALSimGui.h +++ b/simulation/halsim_gui/src/main/native/include/HALSimGui.h @@ -1,5 +1,5 @@ /*----------------------------------------------------------------------------*/ -/* Copyright (c) 2019 FIRST. All Rights Reserved. */ +/* Copyright (c) 2019-2020 FIRST. All Rights Reserved. */ /* Open Source Software - may be modified and shared by FRC teams. The code */ /* must be accompanied by the FIRST BSD license file in the root directory of */ /* the project. */ @@ -23,6 +23,7 @@ void HALSIMGUI_SetWindowVisibility(const char* name, int32_t visibility); void HALSIMGUI_SetDefaultWindowPos(const char* name, float x, float y); void HALSIMGUI_SetDefaultWindowSize(const char* name, float width, float height); +void HALSIMGUI_SetWindowPadding(const char* name, float x, float y); int HALSIMGUI_AreOutputsDisabled(void); } // extern "C" @@ -127,6 +128,14 @@ class HALSimGui { */ static void SetDefaultWindowSize(const char* name, float width, float height); + /** + * Sets internal padding of window added with AddWindow(). + * @param name window name (same as name passed to AddWindow()) + * @param x horizontal padding + * @param y vertical padding + */ + static void SetWindowPadding(const char* name, float x, float y); + /** * Returns true if outputs are disabled. * diff --git a/simulation/halsim_gui/src/main/native/include/portable-file-dialogs.h b/simulation/halsim_gui/src/main/native/include/portable-file-dialogs.h new file mode 100644 index 0000000000..1fbd11cf7f --- /dev/null +++ b/simulation/halsim_gui/src/main/native/include/portable-file-dialogs.h @@ -0,0 +1,1305 @@ +// +// Portable File Dialogs +// +// Copyright (c) 2018—2019 Sam Hocevar +// +// This library is free software. It comes without any warranty, to +// the extent permitted by applicable law. You can redistribute it +// and/or modify it under the terms of the Do What the **** You Want +// to Public License, Version 2, as published by the WTFPL Task Force. +// See http://www.wtfpl.net/ for more details. +// + +#pragma once + +#if _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN 1 +#endif +#include +#include +#include +#include +#include +#else +#ifndef _POSIX_C_SOURCE +# define _POSIX_C_SOURCE 2 // for popen() +#endif +#include // for popen() +#include // for std::getenv() +#include // for fcntl() +#include // for read() +#endif + +#include +#include +#include +#include +#include +#include +#include + +namespace pfd +{ + +enum class button +{ + cancel = -1, + ok, + yes, + no, + abort, + retry, + ignore, +}; + +enum class choice +{ + ok = 0, + ok_cancel, + yes_no, + yes_no_cancel, + retry_cancel, + abort_retry_ignore, +}; + +enum class icon +{ + info = 0, + warning, + error, + question, +}; + +// Process wait timeout, in milliseconds +static int const default_wait_timeout = 20; + +// The settings class, only exposing to the user a way to set verbose mode +// and to force a rescan of installed desktop helpers (zenity, kdialog…). +class settings +{ +public: + static void verbose(bool value) + { + settings().flags(flag::is_verbose) = value; + } + + static void rescan() + { + settings(true); + } + +protected: + enum class flag + { + is_scanned = 0, + is_verbose, + + has_zenity, + has_matedialog, + has_qarma, + has_kdialog, + is_vista, + + max_flag, + }; + + explicit settings(bool resync = false) + { + flags(flag::is_scanned) &= !resync; + } + + inline bool is_osascript() const + { +#if __APPLE__ + return true; +#else + return false; +#endif + } + + inline bool is_zenity() const + { + return flags(flag::has_zenity) || + flags(flag::has_matedialog) || + flags(flag::has_qarma); + } + + inline bool is_kdialog() const + { + return flags(flag::has_kdialog); + } + + // Static array of flags for internal state + bool const &flags(flag in_flag) const + { + static bool flags[(size_t)flag::max_flag]; + return flags[(size_t)in_flag]; + } + + // Non-const getter for the static array of flags + bool &flags(flag in_flag) + { + return const_cast(static_cast(this)->flags(in_flag)); + } +}; + +// Forward declarations for our API +class notify; +class message; + +// Internal classes, not to be used by client applications +namespace internal +{ + +#if _WIN32 +static inline std::wstring str2wstr(std::string const &str) +{ + int len = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), (int)str.size(), nullptr, 0); + std::wstring ret(len, '\0'); + MultiByteToWideChar(CP_UTF8, 0, str.c_str(), (int)str.size(), (LPWSTR)ret.data(), (int)ret.size()); + return ret; +} + +static inline std::string wstr2str(std::wstring const &str) +{ + int len = WideCharToMultiByte(CP_UTF8, 0, str.c_str(), (int)str.size(), nullptr, 0, nullptr, nullptr); + std::string ret(len, '\0'); + WideCharToMultiByte(CP_UTF8, 0, str.c_str(), (int)str.size(), (LPSTR)ret.data(), (int)ret.size(), nullptr, nullptr); + return ret; +} + +static inline bool is_vista() +{ + OSVERSIONINFOEXW osvi; + memset(&osvi, 0, sizeof(osvi)); + DWORDLONG const mask = VerSetConditionMask( + VerSetConditionMask( + VerSetConditionMask( + 0, VER_MAJORVERSION, VER_GREATER_EQUAL), + VER_MINORVERSION, VER_GREATER_EQUAL), + VER_SERVICEPACKMAJOR, VER_GREATER_EQUAL); + osvi.dwOSVersionInfoSize = sizeof(osvi); + osvi.dwMajorVersion = HIBYTE(_WIN32_WINNT_VISTA); + osvi.dwMinorVersion = LOBYTE(_WIN32_WINNT_VISTA); + osvi.wServicePackMajor = 0; + + return VerifyVersionInfoW(&osvi, VER_MAJORVERSION | VER_MINORVERSION | VER_SERVICEPACKMAJOR, mask) != FALSE; +} +#endif + +// This is necessary until C++20 which will have std::string::ends_with() etc. +static inline bool ends_with(std::string const &str, std::string const &suffix) +{ + return suffix.size() <= str.size() && + str.compare(str.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +static inline bool starts_with(std::string const &str, std::string const &prefix) +{ + return prefix.size() <= str.size() && + str.compare(0, prefix.size(), prefix) == 0; +} + +class executor +{ + friend class dialog; + +public: + // High level function to get the result of a command + std::string result(int *exit_code = nullptr) + { + stop(); + if (exit_code) + *exit_code = m_exit_code; + return m_stdout; + } + +#if _WIN32 + void start(std::function const &fun) + { + stop(); + m_future = std::async(fun, &m_exit_code); + m_running = true; + } +#endif + + void start(std::string const &command) + { + stop(); + m_stdout.clear(); + m_exit_code = -1; + +#if _WIN32 + STARTUPINFOW si; + + memset(&si, 0, sizeof(si)); + si.cb = sizeof(si); + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = SW_HIDE; + + std::wstring wcommand = str2wstr(command); + if (!CreateProcessW(nullptr, (LPWSTR)wcommand.c_str(), nullptr, nullptr, + FALSE, CREATE_NEW_CONSOLE, nullptr, nullptr, &si, &m_pi)) + return; /* TODO: GetLastError()? */ + WaitForInputIdle(m_pi.hProcess, INFINITE); +#elif __EMSCRIPTEN__ || __NX__ + // FIXME: do something +#else + m_stream = popen((command + " 2>/dev/null").c_str(), "r"); + if (!m_stream) + return; + m_fd = fileno(m_stream); + fcntl(m_fd, F_SETFL, O_NONBLOCK); +#endif + m_running = true; + } + + ~executor() + { + stop(); + } + +protected: + bool ready(int timeout = default_wait_timeout) + { + if (!m_running) + return true; + +#if _WIN32 + if (m_future.valid()) + { + auto status = m_future.wait_for(std::chrono::milliseconds(timeout)); + if (status != std::future_status::ready) + return false; + + m_stdout = m_future.get(); + } + else + { + if (WaitForSingleObject(m_pi.hProcess, timeout) == WAIT_TIMEOUT) + return false; + + DWORD ret; + GetExitCodeProcess(m_pi.hProcess, &ret); + m_exit_code = (int)ret; + CloseHandle(m_pi.hThread); + CloseHandle(m_pi.hProcess); + } +#elif __EMSCRIPTEN__ || __NX__ + // FIXME: do something +#else + char buf[BUFSIZ]; + ssize_t received = read(m_fd, buf, BUFSIZ - 1); + if (received == -1 && errno == EAGAIN) + { + // FIXME: this happens almost always at first iteration + std::this_thread::sleep_for(std::chrono::milliseconds(timeout)); + return false; + } + if (received > 0) + { + m_stdout += std::string(buf, received); + return false; + } + m_exit_code = pclose(m_stream); +#endif + + m_running = false; + return true; + } + + void stop() + { + // Loop until the user closes the dialog + while (!ready()) + { +#if _WIN32 + // On Windows, we need to run the message pump. If the async + // thread uses a Windows API dialog, it may be attached to the + // main thread and waiting for messages that only we can dispatch. + MSG msg; + while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) + { + TranslateMessage(&msg); + DispatchMessage(&msg); + } +#endif + } + } + +private: + bool m_running = false; + std::string m_stdout; + int m_exit_code = -1; +#if _WIN32 + std::future m_future; + PROCESS_INFORMATION m_pi; +#else + FILE *m_stream = nullptr; + int m_fd = -1; +#endif +}; + +class platform +{ +protected: +#if _WIN32 + // Helper class around LoadLibraryA() and GetProcAddress() with some safety + class dll + { + public: + dll(std::string const &name) + : handle(::LoadLibraryA(name.c_str())) + {} + + ~dll() + { + if (handle) + ::FreeLibrary(handle); + } + + template class proc + { + public: + proc(dll const &lib, std::string const &sym) + : m_proc(reinterpret_cast(::GetProcAddress(lib.handle, sym.c_str()))) + {} + + operator bool() const + { + return m_proc != nullptr; + } + + operator T *() const + { + return m_proc; + } + + private: + T *m_proc; + }; + + private: + HMODULE handle; + }; + + // Helper class around CreateActCtx() and ActivateActCtx() + class new_style_context + { + public: + new_style_context() + { + // Only create one activation context for the whole app lifetime. + static HANDLE hctx = create(); + + if (hctx != INVALID_HANDLE_VALUE) + ActivateActCtx(hctx, &m_cookie); + } + + ~new_style_context() + { + DeactivateActCtx(0, m_cookie); + } + + private: + HANDLE create() + { + // This “hack” seems to be necessary for this code to work on windows XP. + // Without it, dialogs do not show and close immediately. GetError() + // returns 0 so I don’t know what causes this. I was not able to reproduce + // this behavior on Windows 7 and 10 but just in case, let it be here for + // those versions too. + // This hack is not required if other dialogs are used (they load comdlg32 + // automatically), only if message boxes are used. + dll comdlg32("comdlg32.dll"); + + // Using approach as shown here: https://stackoverflow.com/a/10444161 + UINT len = ::GetSystemDirectoryA(nullptr, 0); + std::string sys_dir(len, '\0'); + ::GetSystemDirectoryA(&sys_dir[0], len); + + ACTCTXA act_ctx = + { + // Do not set flag ACTCTX_FLAG_SET_PROCESS_DEFAULT, since it causes a + // crash with error “default context is already set”. + sizeof(act_ctx), + ACTCTX_FLAG_RESOURCE_NAME_VALID | ACTCTX_FLAG_ASSEMBLY_DIRECTORY_VALID, + "shell32.dll", 0, 0, sys_dir.c_str(), (LPCSTR)124, + }; + + return ::CreateActCtxA(&act_ctx); + } + + ULONG_PTR m_cookie = 0; + }; +#endif +}; + +class dialog : protected settings, protected platform +{ + friend class pfd::notify; + friend class pfd::message; + +public: + bool ready(int timeout = default_wait_timeout) + { + return m_async->ready(timeout); + } + +protected: + explicit dialog() + : m_async(std::make_shared()) + { + if (!flags(flag::is_scanned)) + { +#if _WIN32 + flags(flag::is_vista) = is_vista(); +#elif !__APPLE__ + flags(flag::has_zenity) = check_program("zenity"); + flags(flag::has_matedialog) = check_program("matedialog"); + flags(flag::has_qarma) = check_program("qarma"); + flags(flag::has_kdialog) = check_program("kdialog"); + + // If multiple helpers are available, try to default to the best one + if (flags(flag::has_zenity) && flags(flag::has_kdialog)) + { + auto desktop_name = std::getenv("XDG_SESSION_DESKTOP"); + if (desktop_name && desktop_name == std::string("gnome")) + flags(flag::has_kdialog) = false; + else if (desktop_name && desktop_name == std::string("KDE")) + flags(flag::has_zenity) = false; + } +#endif + flags(flag::is_scanned) = true; + } + } + + std::string desktop_helper() const + { +#if __APPLE__ + return "osascript"; +#else + return flags(flag::has_zenity) ? "zenity" + : flags(flag::has_matedialog) ? "matedialog" + : flags(flag::has_qarma) ? "qarma" + : flags(flag::has_kdialog) ? "kdialog" + : "echo"; +#endif + } + + std::string buttons_to_name(choice choice) const + { + switch (choice) + { + case choice::ok_cancel: return "okcancel"; + case choice::yes_no: return "yesno"; + case choice::yes_no_cancel: return "yesnocancel"; + case choice::retry_cancel: return "retrycancel"; + case choice::abort_retry_ignore: return "abortretryignore"; + /* case choice::ok: */ default: return "ok"; + } + } + + std::string get_icon_name(icon icon) const + { + switch (icon) + { + case icon::warning: return "warning"; + case icon::error: return "error"; + case icon::question: return "question"; + // Zenity wants "information" but WinForms wants "info" + /* case icon::info: */ default: +#if _WIN32 + return "info"; +#else + return "information"; +#endif + } + } + + // Properly quote a string for Powershell: replace ' or " with '' or "" + // FIXME: we should probably get rid of newlines! + // FIXME: the \" sequence seems unsafe, too! + std::string powershell_quote(std::string const &str) const + { + return "'" + std::regex_replace(str, std::regex("['\"]"), "$&$&") + "'"; + } + + // Properly quote a string for osascript: replace ' with '\'' and \ or " with \\ or \" + std::string osascript_quote(std::string const &str) const + { + return "\"" + std::regex_replace(std::regex_replace(str, + std::regex("[\\\\\"]"), "\\$&"), std::regex("'"), "'\\''") + "\""; + } + + // Properly quote a string for the shell: just replace ' with '\'' + std::string shell_quote(std::string const &str) const + { + return "'" + std::regex_replace(str, std::regex("'"), "'\\''") + "'"; + } + + // Check whether a program is present using “which”. + bool check_program(std::string const &program) + { +#if _WIN32 + (void)program; + return false; +#else + int exit_code = -1; + m_async->start("which " + program + " 2>/dev/null"); + m_async->result(&exit_code); + return exit_code == 0; +#endif + } + +protected: + // Keep handle to executing command + std::shared_ptr m_async; +}; + +class file_dialog : public dialog +{ +protected: + enum type + { + open, + save, + folder, + }; + + file_dialog(type in_type, + std::string const &title, + std::string const &default_path = "", + std::vector filters = {}, + bool allow_multiselect = false, + bool confirm_overwrite = true) + { +#if _WIN32 + std::string filter_list; + std::regex whitespace(" *"); + for (size_t i = 0; i + 1 < filters.size(); i += 2) + { + filter_list += filters[i] + '\0'; + filter_list += std::regex_replace(filters[i + 1], whitespace, ";") + '\0'; + } + filter_list += '\0'; + + m_async->start([this, in_type, title, default_path, filter_list, + allow_multiselect, confirm_overwrite](int *exit_code) -> std::string + { + (void)exit_code; + m_wtitle = internal::str2wstr(title); + m_wdefault_path = internal::str2wstr(default_path); + auto wfilter_list = internal::str2wstr(filter_list); + + // Folder selection uses a different method + if (in_type == type::folder) + { + dll ole32("ole32.dll"); + + auto status = dll::proc(ole32, "CoInitializeEx") + (nullptr, COINIT_APARTMENTTHREADED); + if (flags(flag::is_vista)) + { + // On Vista and higher we should be able to use IFileDialog for folder selection + IFileDialog *ifd; + HRESULT hr = dll::proc(ole32, "CoCreateInstance") + (CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&ifd)); + + // In case CoCreateInstance fails (which it should not), try legacy approach + if (SUCCEEDED(hr)) + return select_folder_vista(ifd); + } + + BROWSEINFOW bi; + memset(&bi, 0, sizeof(bi)); + + bi.lpfn = &bffcallback; + bi.lParam = (LPARAM)this; + + if (flags(flag::is_vista)) + { + // This hangs on Windows XP, as reported here: + // https://github.com/samhocevar/portable-file-dialogs/pull/21 + if (status == S_OK) + bi.ulFlags |= BIF_NEWDIALOGSTYLE; + bi.ulFlags |= BIF_EDITBOX; + bi.ulFlags |= BIF_STATUSTEXT; + } + + auto *list = SHBrowseForFolderW(&bi); + std::string ret; + if (list) + { + auto buffer = new wchar_t[MAX_PATH]; + SHGetPathFromIDListW(list, buffer); + dll::proc(ole32, "CoTaskMemFree")(list); + ret = internal::wstr2str(buffer); + delete[] buffer; + } + if (status == S_OK) + dll::proc(ole32, "CoUninitialize")(); + return ret; + } + + OPENFILENAMEW ofn; + memset(&ofn, 0, sizeof(ofn)); + ofn.lStructSize = sizeof(OPENFILENAMEW); + ofn.hwndOwner = GetForegroundWindow(); + + ofn.lpstrFilter = wfilter_list.c_str(); + + auto woutput = std::wstring(MAX_PATH * 256, L'\0'); + ofn.lpstrFile = (LPWSTR)woutput.data(); + ofn.nMaxFile = (DWORD)woutput.size(); + if (!m_wdefault_path.empty()) + { + // If a directory was provided, use it as the initial directory. If + // a valid path was provided, use it as the initial file. Otherwise, + // let the Windows API decide. + auto path_attr = GetFileAttributesW(m_wdefault_path.c_str()); + if (path_attr != INVALID_FILE_ATTRIBUTES && (path_attr & FILE_ATTRIBUTE_DIRECTORY)) + ofn.lpstrInitialDir = m_wdefault_path.c_str(); + else if (m_wdefault_path.size() <= woutput.size()) + lstrcpyW(ofn.lpstrFile, m_wdefault_path.c_str()); + else + { + ofn.lpstrFileTitle = (LPWSTR)m_wdefault_path.data(); + ofn.nMaxFileTitle = (DWORD)m_wdefault_path.size(); + } + } + ofn.lpstrTitle = m_wtitle.c_str(); + ofn.Flags = OFN_NOCHANGEDIR | OFN_EXPLORER; + + dll comdlg32("comdlg32.dll"); + + if (in_type == type::save) + { + if (confirm_overwrite) + ofn.Flags |= OFN_OVERWRITEPROMPT; + + // using set context to apply new visual style (required for windows XP) + new_style_context ctx; + + dll::proc get_save_file_name(comdlg32, "GetSaveFileNameW"); + if (get_save_file_name(&ofn) == 0) + return ""; + return internal::wstr2str(woutput.c_str()); + } + + if (allow_multiselect) + ofn.Flags |= OFN_ALLOWMULTISELECT; + ofn.Flags |= OFN_PATHMUSTEXIST; + + // using set context to apply new visual style (required for windows XP) + new_style_context ctx; + + dll::proc get_open_file_name(comdlg32, "GetOpenFileNameW"); + if (get_open_file_name(&ofn) == 0) + return ""; + + std::string prefix; + for (wchar_t const *p = woutput.c_str(); *p; ) + { + auto filename = internal::wstr2str(p); + p += filename.size(); + // In multiselect mode, we advance p one step more and + // check for another filename. If there is one and the + // prefix is empty, it means we just read the prefix. + if (allow_multiselect && *++p && prefix.empty()) + { + prefix = filename + "/"; + continue; + } + + m_vector_result.push_back(prefix + filename); + } + + return ""; + }); +#else + auto command = desktop_helper(); + + if (is_osascript()) + { + command += " -e 'set ret to choose"; + switch (in_type) + { + case type::save: + command += " file name"; + break; + case type::open: default: + command += " file"; + if (allow_multiselect) + command += " with multiple selections allowed"; + break; + case type::folder: + command += " folder"; + break; + } + + if (default_path.size()) + command += " default location " + osascript_quote(default_path); + command += " with prompt " + osascript_quote(title); + + if (in_type == type::open) + { + // Concatenate all user-provided filter patterns + std::string patterns; + for (size_t i = 0; i < filters.size() / 2; ++i) + patterns += " " + filters[2 * i + 1]; + + // Split the pattern list to check whether "*" is in there; if it + // is, we have to disable filters because there is no mechanism in + // OS X for the user to override the filter. + std::regex sep("\\s+"); + std::string filter_list; + bool has_filter = true; + std::sregex_token_iterator iter(patterns.begin(), patterns.end(), sep, -1); + std::sregex_token_iterator end; + for ( ; iter != end; ++iter) + { + auto pat = iter->str(); + if (pat == "*" || pat == "*.*") + has_filter = false; + else if (internal::starts_with(pat, "*.")) + filter_list += (filter_list.size() == 0 ? "" : ",") + + osascript_quote(pat.substr(2, pat.size() - 2)); + } + if (has_filter && filter_list.size() > 0) + command += " of type {" + filter_list + "}"; + } + + if (in_type == type::open && allow_multiselect) + { + command += "\nset s to \"\""; + command += "\nrepeat with i in ret"; + command += "\n set s to s & (POSIX path of i) & \"\\n\""; + command += "\nend repeat"; + command += "\ncopy s to stdout'"; + } + else + { + command += "\nPOSIX path of ret'"; + } + } + else if (is_zenity()) + { + command += " --file-selection --filename=" + shell_quote(default_path) + + " --title " + shell_quote(title) + + " --separator='\n'"; + + for (size_t i = 0; i < filters.size() / 2; ++i) + command += " --file-filter " + shell_quote(filters[2 * i] + "|" + filters[2 * i + 1]); + + if (in_type == type::save) + command += " --save"; + if (in_type == type::folder) + command += " --directory"; + if (confirm_overwrite) + command += " --confirm-overwrite"; + if (allow_multiselect) + command += " --multiple"; + } + else if (is_kdialog()) + { + switch (in_type) + { + case type::save: command += " --getsavefilename"; break; + case type::open: command += " --getopenfilename"; break; + case type::folder: command += " --getexistingdirectory"; break; + } + command += " " + shell_quote(default_path); + + std::string filter; + for (size_t i = 0; i < filters.size() / 2; ++i) + filter += (i == 0 ? "" : " | ") + filters[2 * i] + "(" + filters[2 * i + 1] + ")"; + command += " " + shell_quote(filter); + + command += " --title " + shell_quote(title); + } + + if (flags(flag::is_verbose)) + std::cerr << "pfd: " << command << std::endl; + + m_async->start(command); +#endif + } + +protected: + std::string string_result() + { +#if _WIN32 + return m_async->result(); +#else + // Strip the newline character + auto ret = m_async->result(); + return ret.back() == '\n' ? ret.substr(0, ret.size() - 1) : ret; +#endif + } + + std::vector vector_result() + { +#if _WIN32 + m_async->result(); + return m_vector_result; +#else + std::vector ret; + auto result = m_async->result(); + for (;;) + { + // Split result along newline characters + auto i = result.find('\n'); + if (i == 0 || i == std::string::npos) + break; + ret.push_back(result.substr(0, i)); + result = result.substr(i + 1, result.size()); + } + return ret; +#endif + } + +#if _WIN32 + // Use a static function to pass as BFFCALLBACK for legacy folder select + static int CALLBACK bffcallback(HWND hwnd, UINT uMsg, LPARAM, LPARAM pData) + { + auto inst = (file_dialog *)pData; + switch (uMsg) + { + case BFFM_INITIALIZED: + SendMessage(hwnd, BFFM_SETSELECTIONW, TRUE, (LPARAM)inst->m_wdefault_path.c_str()); + break; + } + return 0; + } + + std::string select_folder_vista(IFileDialog *ifd) + { + std::string result; + + IShellItem *folder; + + // Load library at runtime so app doesn't link it at load time (which will fail on windows XP) + dll shell32("shell32.dll"); + dll::proc + create_item(shell32, "SHCreateItemFromParsingName"); + + if (!create_item) + return ""; + + auto hr = create_item(m_wdefault_path.c_str(), + nullptr, + IID_PPV_ARGS(&folder)); + + // Set default folder if found. This only sets the default folder. If + // Windows has any info about the most recently selected folder, it + // will display it instead. Generally, calling SetFolder() to set the + // current directory “is not a good or expected user experience and + // should therefore be avoided”: + // https://docs.microsoft.com/windows/win32/api/shobjidl_core/nf-shobjidl_core-ifiledialog-setfolder + if (SUCCEEDED(hr)) + { + ifd->SetDefaultFolder(folder); + folder->Release(); + } + + // Set the dialog title and option to select folders + ifd->SetOptions(FOS_PICKFOLDERS); + ifd->SetTitle(m_wtitle.c_str()); + + hr = ifd->Show(GetForegroundWindow()); + if (SUCCEEDED(hr)) + { + IShellItem* item; + hr = ifd->GetResult(&item); + if (SUCCEEDED(hr)) + { + wchar_t* wselected = nullptr; + item->GetDisplayName(SIGDN_FILESYSPATH, &wselected); + item->Release(); + + if (wselected) + { + result = internal::wstr2str(std::wstring(wselected)); + dll ole32("ole32.dll"); + dll::proc(ole32, "CoTaskMemFree")(wselected); + } + } + } + + ifd->Release(); + + return result; + } + + std::wstring m_wtitle; + std::wstring m_wdefault_path; + + std::vector m_vector_result; +#endif +}; + +} // namespace internal + +class notify : public internal::dialog +{ +public: + notify(std::string const &title, + std::string const &message, + icon icon = icon::info) + { + if (icon == icon::question) // Not supported by notifications + icon = icon::info; + +#if _WIN32 + // Use a static shared pointer for notify_icon so that we can delete + // it whenever we need to display a new one, and we can also wait + // until the program has finished running. + struct notify_icon_data : public NOTIFYICONDATAW + { + ~notify_icon_data() { Shell_NotifyIconW(NIM_DELETE, this); } + }; + + static std::shared_ptr nid; + + // Release the previous notification icon, if any, and allocate a new + // one. Note that std::make_shared() does value initialization, so there + // is no need to memset the structure. + nid = nullptr; + nid = std::make_shared(); + + // For XP support + nid->cbSize = NOTIFYICONDATAW_V2_SIZE; + nid->hWnd = nullptr; + nid->uID = 0; + + // Flag Description: + // - NIF_ICON The hIcon member is valid. + // - NIF_MESSAGE The uCallbackMessage member is valid. + // - NIF_TIP The szTip member is valid. + // - NIF_STATE The dwState and dwStateMask members are valid. + // - NIF_INFO Use a balloon ToolTip instead of a standard ToolTip. The szInfo, uTimeout, szInfoTitle, and dwInfoFlags members are valid. + // - NIF_GUID Reserved. + nid->uFlags = NIF_MESSAGE | NIF_ICON | NIF_INFO; + + // Flag Description + // - NIIF_ERROR An error icon. + // - NIIF_INFO An information icon. + // - NIIF_NONE No icon. + // - NIIF_WARNING A warning icon. + // - NIIF_ICON_MASK Version 6.0. Reserved. + // - NIIF_NOSOUND Version 6.0. Do not play the associated sound. Applies only to balloon ToolTips + switch (icon) + { + case icon::warning: nid->dwInfoFlags = NIIF_WARNING; break; + case icon::error: nid->dwInfoFlags = NIIF_ERROR; break; + /* case icon::info: */ default: nid->dwInfoFlags = NIIF_INFO; break; + } + + ENUMRESNAMEPROC icon_enum_callback = [](HMODULE, LPCTSTR, LPTSTR lpName, LONG_PTR lParam) -> BOOL + { + ((NOTIFYICONDATAW *)lParam)->hIcon = ::LoadIcon(GetModuleHandle(nullptr), lpName); + return false; + }; + + nid->hIcon = ::LoadIcon(nullptr, IDI_APPLICATION); + ::EnumResourceNames(nullptr, RT_GROUP_ICON, icon_enum_callback, (LONG_PTR)nid.get()); + + nid->uTimeout = 5000; + + // FIXME check buffer length + lstrcpyW(nid->szInfoTitle, internal::str2wstr(title).c_str()); + lstrcpyW(nid->szInfo, internal::str2wstr(message).c_str()); + + // Display the new icon + Shell_NotifyIconW(NIM_ADD, nid.get()); +#else + auto command = desktop_helper(); + + if (is_osascript()) + { + command += " -e 'display notification " + osascript_quote(message) + + " with title " + osascript_quote(title) + "'"; + } + else if (is_zenity()) + { + command += " --notification" + " --window-icon " + get_icon_name(icon) + + " --text " + shell_quote(title + "\n" + message); + } + else if (is_kdialog()) + { + command += " --icon " + get_icon_name(icon) + + " --title " + shell_quote(title) + + " --passivepopup " + shell_quote(message) + + " 5"; + } + + if (flags(flag::is_verbose)) + std::cerr << "pfd: " << command << std::endl; + + m_async->start(command); +#endif + } +}; + +class message : public internal::dialog +{ +public: + message(std::string const &title, + std::string const &text, + choice choice = choice::ok_cancel, + icon icon = icon::info) + { +#if _WIN32 + UINT style = MB_TOPMOST; + switch (icon) + { + case icon::warning: style |= MB_ICONWARNING; break; + case icon::error: style |= MB_ICONERROR; break; + case icon::question: style |= MB_ICONQUESTION; break; + /* case icon::info: */ default: style |= MB_ICONINFORMATION; break; + } + + switch (choice) + { + case choice::ok_cancel: style |= MB_OKCANCEL; break; + case choice::yes_no: style |= MB_YESNO; break; + case choice::yes_no_cancel: style |= MB_YESNOCANCEL; break; + case choice::retry_cancel: style |= MB_RETRYCANCEL; break; + case choice::abort_retry_ignore: style |= MB_ABORTRETRYIGNORE; break; + /* case choice::ok: */ default: style |= MB_OK; break; + } + + m_mappings[IDCANCEL] = button::cancel; + m_mappings[IDOK] = button::ok; + m_mappings[IDYES] = button::yes; + m_mappings[IDNO] = button::no; + m_mappings[IDABORT] = button::abort; + m_mappings[IDRETRY] = button::retry; + m_mappings[IDIGNORE] = button::ignore; + + m_async->start([text, title, style](int *exit_code) -> std::string + { + auto wtext = internal::str2wstr(text); + auto wtitle = internal::str2wstr(title); + // using set context to apply new visual style (required for all windows versions) + new_style_context ctx; + *exit_code = MessageBoxW(GetForegroundWindow(), wtext.c_str(), wtitle.c_str(), style); + return ""; + }); +#else + auto command = desktop_helper(); + + if (is_osascript()) + { + command += " -e 'display dialog " + osascript_quote(text) + + " with title " + osascript_quote(title); + switch (choice) + { + case choice::ok_cancel: + command += "buttons {\"OK\", \"Cancel\"} " + "default button \"OK\" " + "cancel button \"Cancel\""; + m_mappings[256] = button::cancel; + break; + case choice::yes_no: + command += "buttons {\"Yes\", \"No\"} " + "default button \"Yes\" " + "cancel button \"No\""; + m_mappings[256] = button::no; + break; + case choice::yes_no_cancel: + command += "buttons {\"Yes\", \"No\", \"Cancel\"} " + "default button \"Yes\" " + "cancel button \"Cancel\""; + m_mappings[256] = button::cancel; + break; + case choice::retry_cancel: + command += "buttons {\"Retry\", \"Cancel\"} " + "default button \"Retry\" " + "cancel button \"Cancel\""; + m_mappings[256] = button::cancel; + break; + case choice::abort_retry_ignore: + command += "buttons {\"Abort\", \"Retry\", \"Ignore\"} " + "default button \"Retry\" " + "cancel button \"Retry\""; + m_mappings[256] = button::cancel; + break; + case choice::ok: default: + command += "buttons {\"OK\"} " + "default button \"OK\" " + "cancel button \"OK\""; + m_mappings[256] = button::ok; + break; + } + command += " with icon "; + switch (icon) + { + #define PFD_OSX_ICON(n) "alias ((path to library folder from system domain) as text " \ + "& \"CoreServices:CoreTypes.bundle:Contents:Resources:" n ".icns\")" + case icon::info: default: command += PFD_OSX_ICON("ToolBarInfo"); break; + case icon::warning: command += "caution"; break; + case icon::error: command += "stop"; break; + case icon::question: command += PFD_OSX_ICON("GenericQuestionMarkIcon"); break; + #undef PFD_OSX_ICON + } + command += "'"; + } + else if (is_zenity()) + { + switch (choice) + { + case choice::ok_cancel: + command += " --question --ok-label=OK --cancel-label=Cancel"; break; + case choice::yes_no: + // Do not use standard --question because it causes “No” to return -1, + // which is inconsistent with the “Yes/No/Cancel” mode below. + command += " --question --switch --extra-button No --extra-button Yes"; break; + case choice::yes_no_cancel: + command += " --question --switch --extra-button No --extra-button Yes --extra-button Cancel"; break; + case choice::retry_cancel: + command += " --question --switch --extra-button Retry --extra-button Cancel"; break; + case choice::abort_retry_ignore: + command += " --question --switch --extra-button Abort --extra-button Retry --extra-button Ignore"; break; + default: + switch (icon) + { + case icon::error: command += " --error"; break; + case icon::warning: command += " --warning"; break; + default: command += " --info"; break; + } + } + + command += " --title " + shell_quote(title) + + " --width 300 --height 0" // sensible defaults + + " --text " + shell_quote(text) + + " --icon-name=dialog-" + get_icon_name(icon); + } + else if (is_kdialog()) + { + if (choice == choice::ok) + { + switch (icon) + { + case icon::error: command += " --error"; break; + case icon::warning: command += " --sorry"; break; + default: command += " --msgbox"; break; + } + } + else + { + command += " --"; + if (icon == icon::warning || icon == icon::error) + command += "warning"; + command += "yesno"; + if (choice == choice::yes_no_cancel) + command += "cancel"; + if (choice == choice::yes_no || choice == choice::yes_no_cancel) + { + m_mappings[0] = button::yes; + m_mappings[256] = button::no; + } + } + + command += " " + shell_quote(text) + + " --title " + shell_quote(title); + + // Must be after the above part + if (choice == choice::ok_cancel) + command += " --yes-label OK --no-label Cancel"; + } + + if (flags(flag::is_verbose)) + std::cerr << "pfd: " << command << std::endl; + + m_async->start(command); +#endif + } + + button result() + { + int exit_code; + auto ret = m_async->result(&exit_code); + // osascript will say "button returned:Cancel\n" + // and others will just say "Cancel\n" + if (exit_code < 0 || // this means cancel + internal::ends_with(ret, "Cancel\n")) + return button::cancel; + if (internal::ends_with(ret, "OK\n")) + return button::ok; + if (internal::ends_with(ret, "Yes\n")) + return button::yes; + if (internal::ends_with(ret, "No\n")) + return button::no; + if (internal::ends_with(ret, "Abort\n")) + return button::abort; + if (internal::ends_with(ret, "Retry\n")) + return button::retry; + if (internal::ends_with(ret, "Ignore\n")) + return button::ignore; + if (m_mappings.count(exit_code) != 0) + return m_mappings[exit_code]; + return exit_code == 0 ? button::ok : button::cancel; + } + +private: + // Some extra logic to map the exit code to button number + std::map m_mappings; +}; + +class open_file : public internal::file_dialog +{ +public: + open_file(std::string const &title, + std::string const &default_path = "", + std::vector filters = { "All Files", "*" }, + bool allow_multiselect = false) + : file_dialog(type::open, title, default_path, + filters, allow_multiselect, false) + { + } + + std::vector result() + { + return vector_result(); + } +}; + +class save_file : public internal::file_dialog +{ +public: + save_file(std::string const &title, + std::string const &default_path = "", + std::vector filters = { "All Files", "*" }, + bool confirm_overwrite = true) + : file_dialog(type::save, title, default_path, + filters, false, confirm_overwrite) + { + } + + std::string result() + { + return string_result(); + } +}; + +class select_folder : public internal::file_dialog +{ +public: + select_folder(std::string const &title, + std::string const &default_path = "") + : file_dialog(type::folder, title, default_path) + { + } + + std::string result() + { + return string_result(); + } +}; + +} // namespace pfd + diff --git a/wpilibc/src/main/native/cpp/simulation/Field2d.cpp b/wpilibc/src/main/native/cpp/simulation/Field2d.cpp new file mode 100644 index 0000000000..a6098b67b4 --- /dev/null +++ b/wpilibc/src/main/native/cpp/simulation/Field2d.cpp @@ -0,0 +1,49 @@ +/*----------------------------------------------------------------------------*/ +/* Copyright (c) 2020 FIRST. All Rights Reserved. */ +/* Open Source Software - may be modified and shared by FRC teams. The code */ +/* must be accompanied by the FIRST BSD license file in the root directory of */ +/* the project. */ +/*----------------------------------------------------------------------------*/ + +#include "frc/simulation/Field2d.h" + +using namespace frc; + +Field2d::Field2d() : m_device{"Field2D"} { + if (m_device) { + m_x = m_device.CreateDouble("x", false, 0.0); + m_y = m_device.CreateDouble("y", false, 0.0); + m_rot = m_device.CreateDouble("rot", false, 0.0); + } +} + +void Field2d::SetRobotPose(const Pose2d& pose) { + if (m_device) { + auto& translation = pose.Translation(); + m_x.Set(translation.X().to()); + m_y.Set(translation.Y().to()); + m_rot.Set(pose.Rotation().Degrees().to()); + } else { + m_pose = pose; + } +} + +void Field2d::SetRobotPose(units::meter_t x, units::meter_t y, + Rotation2d rotation) { + if (m_device) { + m_x.Set(x.to()); + m_y.Set(y.to()); + m_rot.Set(rotation.Degrees().to()); + } else { + m_pose = Pose2d{x, y, rotation}; + } +} + +Pose2d Field2d::GetRobotPose() { + if (m_device) { + return Pose2d{units::meter_t{m_x.Get()}, units::meter_t{m_y.Get()}, + Rotation2d{units::degree_t{m_rot.Get()}}}; + } else { + return m_pose; + } +} diff --git a/wpilibc/src/main/native/include/frc/simulation/Field2d.h b/wpilibc/src/main/native/include/frc/simulation/Field2d.h new file mode 100644 index 0000000000..5f00606c20 --- /dev/null +++ b/wpilibc/src/main/native/include/frc/simulation/Field2d.h @@ -0,0 +1,70 @@ +/*----------------------------------------------------------------------------*/ +/* Copyright (c) 2020 FIRST. All Rights Reserved. */ +/* Open Source Software - may be modified and shared by FRC teams. The code */ +/* must be accompanied by the FIRST BSD license file in the root directory of */ +/* the project. */ +/*----------------------------------------------------------------------------*/ + +#pragma once + +#include +#include + +#include "frc/geometry/Pose2d.h" +#include "frc/geometry/Rotation2d.h" + +namespace frc { + +/** + * 2D representation of game field (for simulation). + * + * In non-simulation mode this simply stores and returns the robot pose. + * + * The robot pose is the actual location shown on the simulation view. + * This may or may not match the robot's internal odometry. For example, if + * the robot is shown at a particular starting location, the pose in this + * class would represent the actual location on the field, but the robot's + * internal state might have a 0,0,0 pose (unless it's initialized to + * something different). + * + * As the user is able to edit the pose, code performing updates should get + * the robot pose, transform it as appropriate (e.g. based on simulated wheel + * velocity), and set the new pose. + */ +class Field2d { + public: + Field2d(); + + /** + * Set the robot pose from a Pose object. + * + * @param pose 2D pose + */ + void SetRobotPose(const Pose2d& pose); + + /** + * Set the robot pose from x, y, and rotation. + * + * @param x X location + * @param y Y location + * @param rotation rotation + */ + void SetRobotPose(units::meter_t x, units::meter_t y, Rotation2d rotation); + + /** + * Get the robot pose. + * + * @return 2D pose + */ + Pose2d GetRobotPose(); + + private: + Pose2d m_pose; + + hal::SimDevice m_device; + hal::SimDouble m_x; + hal::SimDouble m_y; + hal::SimDouble m_rot; +}; + +} // namespace frc diff --git a/wpilibj/src/main/java/edu/wpi/first/wpilibj/simulation/Field2d.java b/wpilibj/src/main/java/edu/wpi/first/wpilibj/simulation/Field2d.java new file mode 100644 index 0000000000..ee8cf50862 --- /dev/null +++ b/wpilibj/src/main/java/edu/wpi/first/wpilibj/simulation/Field2d.java @@ -0,0 +1,98 @@ +/*----------------------------------------------------------------------------*/ +/* Copyright (c) 2020 FIRST. All Rights Reserved. */ +/* Open Source Software - may be modified and shared by FRC teams. The code */ +/* must be accompanied by the FIRST BSD license file in the root directory of */ +/* the project. */ +/*----------------------------------------------------------------------------*/ + +import edu.wpi.first.hal.SimDevice; +import edu.wpi.first.hal.SimDouble; +import edu.wpi.first.wpilibj.geometry.Pose2d; +import edu.wpi.first.wpilibj.geometry.Rotation2d; +import edu.wpi.first.wpilibj.geometry.Translation2d; + +/** + * 2D representation of game field (for simulation). + * + *

In non-simulation mode this simply stores and returns the robot pose. + * + *

The robot pose is the actual location shown on the simulation view. + * This may or may not match the robot's internal odometry. For example, if + * the robot is shown at a particular starting location, the pose in this + * class would represent the actual location on the field, but the robot's + * internal state might have a 0,0,0 pose (unless it's initialized to + * something different). + * + *

As the user is able to edit the pose, code performing updates should get + * the robot pose, transform it as appropriate (e.g. based on simulated wheel + * velocity), and set the new pose. + */ +public class Field2d { + public Field2d() { + m_device = SimDevice.create("Field2D"); + if (m_device != null) { + m_x = m_device.createDouble("x", false, 0.0); + m_y = m_device.createDouble("y", false, 0.0); + m_rot = m_device.createDouble("rot", false, 0.0); + } + } + + /** + * Set the robot pose from a Pose object. + * + * @param pose 2D pose + */ + public void setRobotPose(Pose2d pose) { + if (m_device != null) { + Translation2d translation = pose.getTranslation(); + m_x.set(translation.getX()); + m_y.set(translation.getY()); + m_rot.set(pose.getRotation().getDegrees()); + } else { + m_pose = pose; + } + } + + /** + * Set the robot pose from x, y, and rotation. + * + * @param xMeters X location, in meters + * @param yMeters Y location, in meters + * @param rotation rotation + */ + public void setRobotPose(double xMeters, double yMeters, Rotation2d rotation) { + if (m_device != null) { + m_x.set(xMeters); + m_y.set(yMeters); + m_rot.set(rotation.getDegrees()); + } else { + m_pose = new Pose2d(xMeters, yMeters, rotation); + } + } + + /** + * Get the robot pose. + * + * @return 2D pose + */ + public Pose2d getRobotPose() { + if (m_device != null) { + return new Pose2d(m_x.get(), m_y.get(), Rotation2d.fromDegrees(m_rot.get())); + } else { + return m_pose; + } + } + + private Pose2d m_pose; + + @SuppressWarnings("MemberName") + private final SimDevice m_device; + + @SuppressWarnings("MemberName") + private SimDouble m_x; + + @SuppressWarnings("MemberName") + private SimDouble m_y; + + private SimDouble m_rot; +}