From 32a1190bf4d3cca62bd6ca37358eb92594be86cf Mon Sep 17 00:00:00 2001 From: Tianyu Song Date: Sat, 13 Jun 2026 20:56:58 +0200 Subject: [PATCH 1/2] Add in-app log console; stop launching a separate terminal window Launching the app from a GUI no longer opens a separate console window (CMD on Windows, Terminal on macOS). All logging now appears in a docked "Log" console along the bottom of the app window, and still prints to the terminal when the app is launched from one. Logging - New LogConsole (include/Log.h, src/Log.cpp): tees std::cout/std::cerr so every line goes to both the original stream (terminal) and a bounded, thread-safe ring buffer rendered as the bottom panel. No changes needed at the ~54 existing log call sites. The render path snapshots under the lock and draws unlocked, so a worker thread logging never blocks the GUI and there is no re-entrancy path back into the lock. No separate console on launch - Windows: built as a GUI-subsystem executable (WIN32_EXECUTABLE + /ENTRY:mainCRTStartup to keep int main). AttachConsole(ATTACH_PARENT_PROCESS) reattaches stdio when started from a terminal so logs still print there. - macOS: CMake now builds an ir-tracking-app.app bundle (Info.plist.in + icon), so double-clicking in Finder does not spawn Terminal. release.yml's macOS packaging consumes the CMake bundle (dylibbundler + PlistBuddy version stamp) instead of hand-assembling it. macOS data directory - A Finder-launched .app has working directory "/", which broke the relative "Tools" folder and CSV paths. On macOS these now live under ~/Library/Application Support/IR Tracking App/ regardless of launch method; Windows and Linux keep their current working-directory behavior. The data directory is logged at startup. Layout - Default window grown to 1060x900; IR/Depth monitors lifted above the log console and clamped so they never render off the top on a short window. Tracker fixes (from code review) - UdpThreadFunction: GetToolTransform() returns 8 floats, so copying begin()+3..end() wrote 5 floats into data.quaternion[4] (out-of-bounds). Copy exactly the 4 quaternion components. - MIN_SPHERES 4 -> 3 to match the tracker, which supports 3-sphere tools in both AddTool and CalibrateTool. The old value rejected valid 3-sphere manual/ROM definitions and 3-sphere calibration results. --- .github/workflows/release.yml | 37 ++---- CMakeLists.txt | 52 ++++++-- README.md | 25 +++- include/Log.h | 77 +++++++++++ include/ViewerWindow.h | 2 +- resources/Info.plist.in | 28 ++++ src/Log.cpp | 239 ++++++++++++++++++++++++++++++++++ src/ViewerWindow.cpp | 79 +++++++++-- src/viewer_main.cpp | 47 +++++++ 9 files changed, 539 insertions(+), 47 deletions(-) create mode 100644 include/Log.h create mode 100644 resources/Info.plist.in create mode 100644 src/Log.cpp diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59d8125..7ce89a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -215,14 +215,16 @@ jobs: run: | set -euxo pipefail APP_NAME="IR Tracking App" + + # CMake now emits the .app bundle directly (Info.plist + icon already + # baked in). Self-contain it with dylibbundler and stamp the version. + BUILT_APP="build/ir-tracking-app.app" + test -d "$BUILT_APP" || { echo "Expected $BUILT_APP from the CMake build"; exit 1; } + APP_DIR="$RUNNER_TEMP/$APP_NAME.app" rm -rf "$APP_DIR" - mkdir -p "$APP_DIR/Contents/MacOS" \ - "$APP_DIR/Contents/Resources" \ - "$APP_DIR/Contents/Frameworks" - - cp build/ir-tracking-app "$APP_DIR/Contents/MacOS/" - cp resources/app_icon.icns "$APP_DIR/Contents/Resources/app_icon.icns" + cp -R "$BUILT_APP" "$APP_DIR" + mkdir -p "$APP_DIR/Contents/Frameworks" # Bundle dylibs into Contents/Frameworks/, with install names rewritten # to @executable_path/../Frameworks/ so the .app is self-contained. @@ -232,25 +234,10 @@ jobs: -p "@executable_path/../Frameworks/" \ || true - cat > "$APP_DIR/Contents/Info.plist" < - - - - CFBundleExecutableir-tracking-app - CFBundleIdentifiercom.medivis.ir-tracking-app - CFBundleName$APP_NAME - CFBundleDisplayName$APP_NAME - CFBundleVersion$VERSION - CFBundleShortVersionString$VERSION - CFBundlePackageTypeAPPL - CFBundleIconFileapp_icon.icns - LSMinimumSystemVersion11.0 - NSHighResolutionCapable - NSCameraUsageDescriptionUsed to display the IR camera feed for tool tracking. - - - EOF + # Stamp the release version into the CMake-generated Info.plist. + PLIST="$APP_DIR/Contents/Info.plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $VERSION" "$PLIST" + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST" # Compose the DMG. UDZO = zlib-compressed read-only. DMG_NAME="ir-tracking-app-${VERSION}-${{ matrix.label }}.dmg" diff --git a/CMakeLists.txt b/CMakeLists.txt index a986b63..e924b2e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,7 +85,8 @@ target_link_libraries(IRToolTracking PUBLIC Eigen3 ${realsense2_LIBRARY} opencv_ -#set(MACOS_ICON "resources/app_icon.icns") +set(MACOS_ICON_NAME "app_icon.icns") +set(MACOS_ICON "${CMAKE_CURRENT_SOURCE_DIR}/resources/${MACOS_ICON_NAME}") find_package(OpenGL REQUIRED) @@ -95,25 +96,56 @@ include_directories( ) set(VIEWER_SOURCE_FILES - #${MACOS_ICON} win.rc include/ROMParser.h include/ViewerWindow.h + include/Log.h src/ViewerWindow.cpp src/viewer_main.cpp + src/Log.cpp ) +# On macOS the app icon is compiled into the .app bundle's Resources folder. +if(APPLE) + list(APPEND VIEWER_SOURCE_FILES ${MACOS_ICON}) +endif() + # Add the main application add_executable(ir-tracking-app ${VIEWER_SOURCE_FILES}) -# if(APPLE) -# set_target_properties(ir-tracking-app PROPERTIES -# MACOSX_BUNDLE TRUE -# MACOSX_BUNDLE_ICON_FILE app_icon.icns -# RESOURCE "${MACOS_ICON}" -# ) -# set_source_files_properties(${MACOS_ICON} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") -# endif() +# ----------------------------------------------------------------------------- +# Windowing / launch behavior: don't pop a separate console (CMD / Terminal) +# window when the app is launched from a GUI. +# ----------------------------------------------------------------------------- +if(WIN32) + # Build as a GUI-subsystem (/SUBSYSTEM:WINDOWS) executable so launching from + # Explorer or a Start-menu shortcut no longer spawns a CMD window. Keep the + # standard int main(...) entry point instead of WinMain by pointing the + # linker at the console CRT startup, which calls main(). The app still + # AttachConsole()s to a parent terminal at runtime so `ir-tracking-app.exe` + # launched from cmd/PowerShell keeps printing logs. + set_target_properties(ir-tracking-app PROPERTIES WIN32_EXECUTABLE TRUE) + if(MSVC) + target_link_options(ir-tracking-app PRIVATE /ENTRY:mainCRTStartup) + endif() +endif() + +if(APPLE) + # Build a .app bundle so double-clicking it in Finder does not open a + # Terminal window (a bare Unix executable would). Logs still go to the + # terminal when the inner binary is run from a shell, and always to the + # in-app log console. + set_source_files_properties(${MACOS_ICON} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources") + set_target_properties(ir-tracking-app PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/resources/Info.plist.in" + MACOSX_BUNDLE_BUNDLE_NAME "IR Tracking App" + MACOSX_BUNDLE_GUI_IDENTIFIER "com.medivis.ir-tracking-app" + MACOSX_BUNDLE_ICON_FILE "${MACOS_ICON_NAME}" + MACOSX_BUNDLE_BUNDLE_VERSION "0.0.0" + MACOSX_BUNDLE_SHORT_VERSION_STRING "0.0.0" + ) +endif() include_directories( ${IMGUI_DIR} ) include_directories( ${IMGUI_DIR}/backends) diff --git a/README.md b/README.md index 67af1cc..103ed9b 100644 --- a/README.md +++ b/README.md @@ -45,15 +45,36 @@ Note: I have only tested this build process with Visual Studio 2022. ## Running +The application no longer opens a separate console window when launched from a +GUI (a CMD window on Windows or a Terminal window on macOS). Instead, all log +output is shown in a **Log console docked along the bottom of the app window**. +When you launch the app from a terminal, the same logs are also printed to that +terminal. + ### For Linux and Windows: ```bash ./ir-tracking-app ``` +On Windows the app is now a GUI-subsystem executable, so double-clicking it (or +its Start-menu shortcut) does not spawn a CMD window. Run it from `cmd` / +PowerShell if you want the logs in your terminal as well. + ### For MacOS -Due to certain permissions and security features in MacOS, you might need to run the application with elevated privileges. +The build now produces an `ir-tracking-app.app` bundle, so you can launch it +from Finder without a Terminal window appearing: +```bash +open build/ir-tracking-app.app +``` +To see the logs in your terminal as well (and to run with elevated privileges +for camera/USB access), run the binary inside the bundle directly: ```bash -sudo ./ir-tracking-app +sudo ./build/ir-tracking-app.app/Contents/MacOS/ir-tracking-app ``` +> Note: on macOS, tool definitions (`Tools/`) and recorded CSV files are stored +> under `~/Library/Application Support/IR Tracking App/` regardless of how the +> app is launched (the data directory is also printed to the log console at +> startup). On Windows and Linux these are read/written relative to the current +> working directory, as before. ## RealSense Camera Modification: Adding a Light Diffuser The laser projector of the RealSense camera emits a sharp, focused IR dot pattern. While this is generally beneficial for depth sensing, it is not ideal for doing thresholding on IR stream to find retroreflective surfaces. diff --git a/include/Log.h b/include/Log.h new file mode 100644 index 0000000..a55f6ae --- /dev/null +++ b/include/Log.h @@ -0,0 +1,77 @@ +#pragma once + +#ifndef LOG_CONSOLE_H +#define LOG_CONSOLE_H + +#include +#include +#include +#include +#include + +// In-app log console. +// +// Install() tees std::cout / std::cerr so every line written through them is +// BOTH forwarded to the original stream (so a launch from a terminal keeps +// printing logs as before) AND captured into a bounded ring buffer that the GUI +// renders as a docked panel at the bottom of the window. This means the 50-odd +// existing std::cout/std::cerr call sites need no changes — they light up the +// in-app console automatically. +// +// Thread-safe: AddLine() may be called concurrently from worker threads (the +// UDP / CSV / tracking threads all log), while DrawContents() reads on the GUI +// thread. +class LogConsole +{ +public: + enum class Level + { + Info, // captured from std::cout + Error // captured from std::cerr + }; + + struct Entry + { + Level level; + std::string text; // includes a leading "HH:MM:SS " timestamp + }; + + static LogConsole& Get(); + + // Redirect std::cout / std::cerr through the tee. Idempotent. + void Install(); + // Restore the original stream buffers. Idempotent; safe to call at shutdown. + void Restore(); + + // Append a fully-formed log line (no trailing newline). Thread-safe. + void AddLine(Level level, const std::string& text); + + void Clear(); + + // Render the console body (toolbar + scrolling region) into the *current* + // ImGui window. The caller owns the window (position / size / Begin/End). + void DrawContents(); + + LogConsole(const LogConsole&) = delete; + LogConsole& operator=(const LogConsole&) = delete; + +private: + LogConsole() = default; + ~LogConsole(); + + std::mutex mutex_; + std::deque entries_; + std::size_t maxEntries_ = 2000; + + bool installed_ = false; + bool autoScroll_ = true; + + // Saved originals so Restore() can put them back. + std::streambuf* originalCout_ = nullptr; + std::streambuf* originalCerr_ = nullptr; + // Tee buffers that wrap the originals; owned here. + std::unique_ptr coutTee_; + std::unique_ptr cerrTee_; +}; + +#endif // LOG_CONSOLE_H diff --git a/include/ViewerWindow.h b/include/ViewerWindow.h index 25d6ba3..1a43b77 100644 --- a/include/ViewerWindow.h +++ b/include/ViewerWindow.h @@ -61,7 +61,7 @@ class ViewerWindow { // Validation bounds. static constexpr int MAX_TOOLS = 32; // upper bound for "Number of Tools" - static constexpr int MIN_SPHERES = 4; // lower bound for spheres per tool + static constexpr int MIN_SPHERES = 3; // lower bound for spheres per tool (matches the tracker's 3-sphere minimum) static constexpr int MAX_SPHERES = 20; // safety cap for manual entry / ROM static constexpr int MAX_CALIB_SPHERES = 6; // a calibration must not exceed this diff --git a/resources/Info.plist.in b/resources/Info.plist.in new file mode 100644 index 0000000..6be3ce2 --- /dev/null +++ b/resources/Info.plist.in @@ -0,0 +1,28 @@ + + + + + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundleDisplayName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundleIconFile + ${MACOSX_BUNDLE_ICON_FILE} + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundlePackageType + APPL + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + NSCameraUsageDescription + Used to display the IR camera feed for tool tracking. + + diff --git a/src/Log.cpp b/src/Log.cpp new file mode 100644 index 0000000..089c81c --- /dev/null +++ b/src/Log.cpp @@ -0,0 +1,239 @@ +#include "Log.h" + +#include +#include +#include +#include +#include + +#include + +namespace +{ + std::string Timestamp() + { + using namespace std::chrono; + const auto now = system_clock::now(); + const std::time_t t = system_clock::to_time_t(now); + std::tm tm{}; +#if defined(_WIN32) + localtime_s(&tm, &t); +#else + localtime_r(&t, &tm); +#endif + char buf[16]; + std::snprintf(buf, sizeof(buf), "%02d:%02d:%02d", tm.tm_hour, tm.tm_min, tm.tm_sec); + return std::string(buf); + } + + // A streambuf that forwards every character to a wrapped "original" buffer + // (so the terminal still sees output) while assembling complete lines and + // pushing them into the LogConsole ring buffer. + // + // All writes are serialized on its own mutex_, which also makes concurrent + // std::cout/std::cerr writes from worker threads non-interleaving at the + // character level. The nesting order is always (tee mutex_ -> LogConsole + // mutex); LogConsole never calls back into the tee, so there is no deadlock. + class TeeStreamBuf : public std::streambuf + { + public: + TeeStreamBuf(std::streambuf* original, LogConsole::Level level, LogConsole* console) + : original_(original), level_(level), console_(console) + { + } + + protected: + int overflow(int ch) override + { + if (ch == traits_type::eof()) + { + return traits_type::not_eof(ch); + } + std::lock_guard lock(mutex_); + const char c = static_cast(ch); + if (original_) + { + original_->sputc(c); + } + Consume(c); + return ch; + } + + std::streamsize xsputn(const char* s, std::streamsize n) override + { + std::lock_guard lock(mutex_); + if (original_) + { + original_->sputn(s, n); + } + for (std::streamsize i = 0; i < n; ++i) + { + Consume(s[i]); + } + return n; + } + + int sync() override + { + std::lock_guard lock(mutex_); + return original_ ? original_->pubsync() : 0; + } + + private: + // Caller holds mutex_. + void Consume(char c) + { + if (c == '\n') + { + // Drop a trailing CR so Windows "\r\n" lines render cleanly. + if (!line_.empty() && line_.back() == '\r') + { + line_.pop_back(); + } + console_->AddLine(level_, line_); + line_.clear(); + } + else + { + line_.push_back(c); + } + } + + std::mutex mutex_; + std::streambuf* original_ = nullptr; + LogConsole::Level level_; + LogConsole* console_ = nullptr; + std::string line_; + }; +} // namespace + +LogConsole& LogConsole::Get() +{ + static LogConsole instance; + return instance; +} + +LogConsole::~LogConsole() +{ + Restore(); +} + +void LogConsole::Install() +{ + if (installed_) + { + return; + } + originalCout_ = std::cout.rdbuf(); + originalCerr_ = std::cerr.rdbuf(); + coutTee_ = std::make_unique(originalCout_, Level::Info, this); + cerrTee_ = std::make_unique(originalCerr_, Level::Error, this); + std::cout.rdbuf(coutTee_.get()); + std::cerr.rdbuf(cerrTee_.get()); + installed_ = true; +} + +void LogConsole::Restore() +{ + if (!installed_) + { + return; + } + // Put the originals back before destroying the tees, so nothing keeps a + // dangling rdbuf pointer. + std::cout.rdbuf(originalCout_); + std::cerr.rdbuf(originalCerr_); + coutTee_.reset(); + cerrTee_.reset(); + originalCout_ = nullptr; + originalCerr_ = nullptr; + installed_ = false; +} + +void LogConsole::AddLine(Level level, const std::string& text) +{ + std::lock_guard lock(mutex_); + entries_.push_back(Entry{level, Timestamp() + " " + text}); + while (entries_.size() > maxEntries_) + { + entries_.pop_front(); + } +} + +void LogConsole::Clear() +{ + std::lock_guard lock(mutex_); + entries_.clear(); +} + +void LogConsole::DrawContents() +{ + // Toolbar. + if (ImGui::SmallButton("Clear")) + { + Clear(); + } + ImGui::SameLine(); + const bool copy = ImGui::SmallButton("Copy"); + ImGui::SameLine(); + ImGui::Checkbox("Auto-scroll", &autoScroll_); + ImGui::Separator(); + + // Snapshot the lines under the lock, then render WITHOUT holding it. This + // keeps the (non-recursive) LogConsole mutex entirely off the ImGui call + // path, so there is no way for an ImGui code path that happens to write to + // cout/cerr to re-enter AddLine() and self-deadlock; it also means a worker + // thread logging via cout/cerr never blocks on the GUI render. The copy is + // bounded by maxEntries_ and trivially cheap for a log view. + std::vector snapshot; + { + std::lock_guard lock(mutex_); + snapshot.assign(entries_.begin(), entries_.end()); + } + + if (copy) + { + std::string all; + for (const auto& e : snapshot) + { + all += e.text; + all.push_back('\n'); + } + ImGui::SetClipboardText(all.c_str()); + } + + // Scrolling region. Reserve nothing extra — fill the rest of the window. + ImGui::BeginChild("LogScrollRegion", ImVec2(0, 0), false, + ImGuiWindowFlags_HorizontalScrollbar); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4, 1)); + ImGuiListClipper clipper; + clipper.Begin(static_cast(snapshot.size())); + while (clipper.Step()) + { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) + { + const Entry& e = snapshot[static_cast(i)]; + const bool isError = (e.level == Level::Error); + if (isError) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.4f, 0.4f, 1.0f)); + } + ImGui::TextUnformatted(e.text.c_str()); + if (isError) + { + ImGui::PopStyleColor(); + } + } + } + clipper.End(); + ImGui::PopStyleVar(); + + // Keep pinned to the bottom while the user hasn't scrolled away. + if (autoScroll_ && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) + { + ImGui::SetScrollHereY(1.0f); + } + + ImGui::EndChild(); +} diff --git a/src/ViewerWindow.cpp b/src/ViewerWindow.cpp index 718168f..8a8846b 100644 --- a/src/ViewerWindow.cpp +++ b/src/ViewerWindow.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,7 @@ #include "ViewerWindow.h" #include "ROMParser.h" +#include "Log.h" #include "AppIcon.inc" #define STB_IMAGE_IMPLEMENTATION @@ -24,15 +26,43 @@ namespace fs = std::filesystem; +// Height (px) reserved for the docked log console along the bottom edge of the +// window. The IR/Depth monitors are lifted by this amount so they never sit +// under it. +static constexpr float kLogConsoleHeight = 170.0f; + double GetCurrentUnixTimestamp() { auto now = std::chrono::system_clock::now(); auto duration = now.time_since_epoch(); return std::chrono::duration(duration).count(); } +// Base directory for app-managed data (tool definitions under Tools/, recorded +// CSVs). On macOS the app runs as a .app bundle whose working directory is "/", +// so relative paths like "Tools" would resolve under root and silently fail to +// load/save. Use a stable per-user location instead, so it behaves the same +// whether launched from Finder or a terminal. On Windows and Linux the working +// directory is kept unchanged (paths stay relative to "."). +static fs::path GetDataDirectory() +{ +#if defined(__APPLE__) + if (const char* home = std::getenv("HOME"); home && *home) + { + fs::path dir = fs::path(home) / "Library" / "Application Support" / "IR Tracking App"; + std::error_code ec; + fs::create_directories(dir, ec); + if (!ec) + { + return dir; + } + } +#endif + return fs::path("."); +} + void ViewerWindow::SaveToolDefinition(const Tool &tool) { - fs::path toolsDir("Tools"); + fs::path toolsDir = GetDataDirectory() / "Tools"; if (!fs::exists(toolsDir)) { fs::create_directories(toolsDir); // Create the Tools directory if it doesn't exist @@ -60,7 +90,7 @@ void ViewerWindow::SaveToolDefinition(const Tool &tool) bool ViewerWindow::LoadToolDefinition() { - fs::path toolDir("Tools"); + fs::path toolDir = GetDataDirectory() / "Tools"; if (!fs::exists(toolDir) || !fs::is_directory(toolDir)) { std::cerr << "Tool directory not found." << std::endl; @@ -156,7 +186,7 @@ void ViewerWindow::Initialize(const std::string& file) { //glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); // 3.0+ only #endif - window = glfwCreateWindow( 1060, 720, "RealSense Tool Tracker", nullptr, nullptr ); + window = glfwCreateWindow( 1060, 900, "RealSense Tool Tracker", nullptr, nullptr ); if (window == nullptr) { std::cerr <<"glfwCreateWindow failed" << std::endl; @@ -189,11 +219,12 @@ void ViewerWindow::Initialize(const std::string& file) { glfwSetWindowShouldClose(window, GL_TRUE); }); + std::cout << "Data directory: " << GetDataDirectory().string() << std::endl; LoadToolDefinition(); tracker.RemoveAllToolDefinitions(); Terminated = false; - Render(); + Render(); } Eigen::Matrix4f ViewerWindow::TrackingDataToMatrix(const TrackingData& data) @@ -427,8 +458,11 @@ void ViewerWindow::UdpThreadFunction() { // Value-initialize so no uninitialized padding goes on the wire. TrackingData data{}; - std::copy(tool_transform.begin(), tool_transform.begin() + 3, data.position); - std::copy(tool_transform.begin() + 3, tool_transform.end(), data.quaternion); + std::copy(tool_transform.begin(), tool_transform.begin() + 3, data.position); + // GetToolTransform() returns 8 floats (XYZ, quaternion XYZW, visibility). + // Copy only the 4 quaternion components — copying through end() would + // write a 5th float past data.quaternion[4]. + std::copy(tool_transform.begin() + 3, tool_transform.begin() + 7, data.quaternion); data.toolId = static_cast(id) + 1; data.timestamp = GetCurrentUnixTimestamp(); data.serialNumber = serialNumber; @@ -457,8 +491,14 @@ void ViewerWindow::UdpThreadFunction() void ViewerWindow::WriteToCSV() { - // Check if csvFileName exists, if so, add a number to the end of the filename + // Check if csvFileName exists, if so, add a number to the end of the filename. + // A bare default filename is anchored to the app data directory; an absolute + // path chosen via the "Save To" dialog is used as-is. fs::path basePath(csvFileName); + if (basePath.is_relative()) + { + basePath = GetDataDirectory() / basePath; + } std::string stem = basePath.stem().string(); std::string extension = basePath.extension().string(); int count = 1; @@ -994,7 +1034,12 @@ void ViewerWindow::Render() { int windowWidth, windowHeight; glfwGetWindowSize(window, &windowWidth, &windowHeight); - ImGui::SetNextWindowPos(ImVec2(20, windowHeight - 300)); + // Sit above the docked log console rather than under it, but never + // off the top of the window if it has been resized very short. + const float monitorY = std::max(0.0f, + static_cast(windowHeight) - 300.0f - kLogConsoleHeight); + + ImGui::SetNextWindowPos(ImVec2(20, monitorY)); ImGui::Begin("IR Monitor", nullptr, overlayFlags); glBindTexture(GL_TEXTURE_2D, texture); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); @@ -1004,7 +1049,7 @@ void ViewerWindow::Render() { ImGui::Image(reinterpret_cast(static_cast(texture)), ImVec2(424, 240)); ImGui::End(); - ImGui::SetNextWindowPos(ImVec2(480, windowHeight - 300)); + ImGui::SetNextWindowPos(ImVec2(480, monitorY)); ImGui::Begin("Depth Monitor", nullptr, overlayFlags); glBindTexture(GL_TEXTURE_2D, dtexture); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); @@ -1096,6 +1141,22 @@ void ViewerWindow::Render() { ImGui::EndPopup(); } + // Docked log console along the bottom edge: always visible, full width, + // and re-pinned every frame so it tracks live window resizes. + { + int logWinWidth = 0, logWinHeight = 0; + glfwGetWindowSize(window, &logWinWidth, &logWinHeight); + const ImGuiWindowFlags logFlags = + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoFocusOnAppearing; + ImGui::SetNextWindowPos(ImVec2(0.0f, logWinHeight - kLogConsoleHeight)); + ImGui::SetNextWindowSize(ImVec2(static_cast(logWinWidth), kLogConsoleHeight)); + ImGui::Begin("Log", nullptr, logFlags); + LogConsole::Get().DrawContents(); + ImGui::End(); + } + ImGui::Render(); ImGui_ImplOpenGL3_RenderDrawData( ImGui::GetDrawData() ); diff --git a/src/viewer_main.cpp b/src/viewer_main.cpp index c5b7f35..ada9626 100644 --- a/src/viewer_main.cpp +++ b/src/viewer_main.cpp @@ -1,6 +1,22 @@ +// On Windows the app is built as a GUI-subsystem executable (see CMakeLists.txt) +// so launching it from Explorer / a Start-menu shortcut no longer spawns a CMD +// window. To keep the "launched from a terminal still prints logs" behavior, we +// reattach to the parent console when there is one. +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#endif + #include "ViewerWindow.h" #include #include "cmdparser.h" +#include "Log.h" std::atomic Terminated = ATOMIC_VAR_INIT(false); @@ -9,8 +25,37 @@ void SignalHandler(int) Terminated = true; } +#ifdef _WIN32 +// If the process was started from an existing console (cmd / PowerShell / CI), +// adopt it and point the C/C++ standard streams at it so logs appear there. +// When launched from Explorer there is no parent console and this is a no-op, +// so no console window pops up. +static void AttachParentConsole() +{ + if (AttachConsole(ATTACH_PARENT_PROCESS)) + { + FILE* dummy = nullptr; + freopen_s(&dummy, "CONOUT$", "w", stdout); + freopen_s(&dummy, "CONOUT$", "w", stderr); + freopen_s(&dummy, "CONIN$", "r", stdin); + std::ios::sync_with_stdio(true); + std::cout.clear(); + std::cerr.clear(); + std::cin.clear(); + } +} +#endif + int main(int argc, char **argv) { +#ifdef _WIN32 + AttachParentConsole(); +#endif + // Tee std::cout/std::cerr into the in-app log console. Installed before any + // logging so startup messages are captured too; the static instance restores + // the original buffers at process exit even on early-exit paths. + LogConsole::Get().Install(); + try { std::string inputFilePath = ""; @@ -51,8 +96,10 @@ int main(int argc, char **argv) catch (const std::exception &e) { std::cerr << "An error occurred: " << e.what() << std::endl; + LogConsole::Get().Restore(); return EXIT_FAILURE; } + LogConsole::Get().Restore(); return EXIT_SUCCESS; } From a5de308be934eeb740a28333ff1c87a18b232852 Mon Sep 17 00:00:00 2001 From: Tianyu Song Date: Sat, 13 Jun 2026 21:19:29 +0200 Subject: [PATCH 2/2] Update ViewerWindow.cpp --- src/ViewerWindow.cpp | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/ViewerWindow.cpp b/src/ViewerWindow.cpp index 8a8846b..c8b0cef 100644 --- a/src/ViewerWindow.cpp +++ b/src/ViewerWindow.cpp @@ -51,10 +51,16 @@ static fs::path GetDataDirectory() fs::path dir = fs::path(home) / "Library" / "Application Support" / "IR Tracking App"; std::error_code ec; fs::create_directories(dir, ec); - if (!ec) + if (ec) { - return dir; + // Don't fall back to "." here: launched from Finder the working + // directory is "/", so that would reintroduce the very problem this + // helper exists to avoid. Log why and return the per-user path + // anyway, so subsequent file operations fail in an actionable place. + std::cerr << "Failed to create data directory " << dir.string() + << ": " << ec.message() << std::endl; } + return dir; } #endif return fs::path("."); @@ -193,6 +199,11 @@ void ViewerWindow::Initialize(const std::string& file) { return; } + // Keep the window large enough for the fixed-height docked log console and + // the monitor panels. Below this the log console's pinned Y (windowHeight - + // kLogConsoleHeight) would go negative and the panels would overlap. + glfwSetWindowSizeLimits(window, 940, 600, GLFW_DONT_CARE, GLFW_DONT_CARE); + GLFWimage icon; icon.pixels = stbi_load_from_memory( AppIcon_png, @@ -1150,7 +1161,8 @@ void ViewerWindow::Render() { ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoFocusOnAppearing; - ImGui::SetNextWindowPos(ImVec2(0.0f, logWinHeight - kLogConsoleHeight)); + ImGui::SetNextWindowPos(ImVec2(0.0f, + std::max(0.0f, static_cast(logWinHeight) - kLogConsoleHeight))); ImGui::SetNextWindowSize(ImVec2(static_cast(logWinWidth), kLogConsoleHeight)); ImGui::Begin("Log", nullptr, logFlags); LogConsole::Get().DrawContents();