diff --git a/Client/udpscope/.gitignore b/Client/udpscope/.gitignore new file mode 100644 index 0000000..6654b8d --- /dev/null +++ b/Client/udpscope/.gitignore @@ -0,0 +1,2 @@ +build/ +compile_commands.json diff --git a/Client/udpscope/CMakeLists.txt b/Client/udpscope/CMakeLists.txt new file mode 100644 index 0000000..2d1a59f --- /dev/null +++ b/Client/udpscope/CMakeLists.txt @@ -0,0 +1,125 @@ +cmake_minimum_required(VERSION 3.16) +project(UDPScope CXX C) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_C_STANDARD 99) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +option(UDPSCOPE_BUILD_TESTS "Build the unit tests" ON) + +set(STREAMHUB_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../streamhub) +set(CCLIENT_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../Common/Client/c) + +# ── The standalone C UDPS client, compiled in directly ──────────────────────── +# Building it here rather than shelling out to its own Makefile keeps this a +# single cmake --build away from a working binary. +add_library(udpsclient STATIC ${CCLIENT_DIR}/udps_client.c) +target_include_directories(udpsclient PUBLIC ${CCLIENT_DIR}) +target_compile_options(udpsclient PRIVATE -Wall -Wextra -Wpedantic) + +# ── System packages ─────────────────────────────────────────────────────────── +find_package(OpenGL REQUIRED) + +find_package(SDL2 QUIET CONFIG) +if(NOT SDL2_FOUND) + find_package(PkgConfig REQUIRED) + pkg_check_modules(SDL2 REQUIRED sdl2) + add_library(SDL2::SDL2 INTERFACE IMPORTED) + target_include_directories(SDL2::SDL2 INTERFACE ${SDL2_INCLUDE_DIRS}) + target_link_libraries(SDL2::SDL2 INTERFACE ${SDL2_LIBRARIES}) + target_compile_options(SDL2::SDL2 INTERFACE ${SDL2_CFLAGS_OTHER}) +endif() + +# ── Dear ImGui + ImPlot ─────────────────────────────────────────────────────── +include(FetchContent) + +FetchContent_Declare(imgui + GIT_REPOSITORY https://github.com/ocornut/imgui.git + GIT_TAG v1.91.8 + GIT_SHALLOW TRUE) +FetchContent_MakeAvailable(imgui) + +FetchContent_Declare(implot + GIT_REPOSITORY https://github.com/epezent/implot.git + GIT_TAG v0.17 + GIT_SHALLOW TRUE) +FetchContent_MakeAvailable(implot) + +add_library(imgui_lib STATIC + ${imgui_SOURCE_DIR}/imgui.cpp + ${imgui_SOURCE_DIR}/imgui_draw.cpp + ${imgui_SOURCE_DIR}/imgui_tables.cpp + ${imgui_SOURCE_DIR}/imgui_widgets.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_sdl2.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp + ${implot_SOURCE_DIR}/implot.cpp + ${implot_SOURCE_DIR}/implot_items.cpp) +target_include_directories(imgui_lib PUBLIC + ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends ${implot_SOURCE_DIR}) +target_link_libraries(imgui_lib PUBLIC SDL2::SDL2 OpenGL::GL) +target_compile_options(imgui_lib PRIVATE -w) + +# ── Bundled resources, borrowed read-only from the StreamHub client ─────────── +set(RESOURCE_DIR ${STREAMHUB_DIR}/resources) +set(FONT_DIR ${RESOURCE_DIR}/fonts) + +if(EXISTS ${FONT_DIR}/fa-solid-900.ttf AND EXISTS ${FONT_DIR}/IconsFontAwesome6.h) + set(HAVE_FONT_AWESOME TRUE) + message(STATUS "Font Awesome icons enabled (${FONT_DIR})") +else() + set(HAVE_FONT_AWESOME FALSE) + message(WARNING "Bundled Font Awesome missing — using ASCII icon fallbacks") +endif() + +file(COPY ${FONT_DIR} DESTINATION ${CMAKE_BINARY_DIR}/resources) + +# ── Core library: everything except main.cpp, so tests can link it ──────────── +set(CORE_SOURCES + Decimate.cpp +) + +add_library(udpscope_core STATIC ${CORE_SOURCES}) +target_include_directories(udpscope_core PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${STREAMHUB_DIR}) # SignalBuffer.h, reused verbatim +target_link_libraries(udpscope_core PUBLIC udpsclient pthread) +target_compile_options(udpscope_core PRIVATE -Wall -Wextra -Wno-unused-parameter) + +# ── Application ─────────────────────────────────────────────────────────────── +set(APP_SOURCES + main.cpp +) + +if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp) + add_executable(UDPScope ${APP_SOURCES}) + target_link_libraries(UDPScope PRIVATE udpscope_core imgui_lib SDL2::SDL2 OpenGL::GL) + target_compile_definitions(UDPScope PRIVATE APP_RESOURCE_DIR="${RESOURCE_DIR}") + if(HAVE_FONT_AWESOME) + target_include_directories(UDPScope PRIVATE ${FONT_DIR}) + target_compile_definitions(UDPScope PRIVATE HAVE_FONT_AWESOME) + endif() + target_compile_options(UDPScope PRIVATE -Wall -Wextra -Wno-unused-parameter) + + install(TARGETS UDPScope DESTINATION bin) + install(DIRECTORY ${FONT_DIR} DESTINATION share/udpscope) +endif() + +# ── Tests ───────────────────────────────────────────────────────────────────── +if(UDPSCOPE_BUILD_TESTS) + FetchContent_Declare(googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.15.2 + GIT_SHALLOW TRUE) + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(googletest) + + file(GLOB TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/*.cpp) + add_executable(udpscope_tests ${TEST_SOURCES}) + target_link_libraries(udpscope_tests PRIVATE udpscope_core GTest::gtest_main) + target_compile_options(udpscope_tests PRIVATE -Wall -Wextra -Wno-unused-parameter) + + enable_testing() + include(GoogleTest) + gtest_discover_tests(udpscope_tests) +endif() diff --git a/Client/udpscope/Decimate.cpp b/Client/udpscope/Decimate.cpp new file mode 100644 index 0000000..e005944 --- /dev/null +++ b/Client/udpscope/Decimate.cpp @@ -0,0 +1,47 @@ +#include "Decimate.h" + +#include + +namespace udpscope { + +void MinMaxDecimate(const double* t, const double* v, size_t n, + size_t maxPoints, Series& out) { + out.clear(); + if (n == 0 || t == nullptr || v == nullptr) { + return; + } + if (n <= maxPoints || maxPoints < 4) { + out.t.assign(t, t + n); + out.v.assign(v, v + n); + return; + } + + /* Two points per bucket, so the bucket count is half the budget. */ + const size_t buckets = maxPoints / 2; + out.t.reserve(buckets * 2); + out.v.reserve(buckets * 2); + + for (size_t b = 0; b < buckets; b++) { + const size_t begin = (n * b) / buckets; + size_t end = (n * (b + 1)) / buckets; + if (end <= begin) { end = begin + 1; } + if (end > n) { end = n; } + + size_t lo = begin, hi = begin; + for (size_t i = begin + 1; i < end; i++) { + if (v[i] < v[lo]) { lo = i; } + if (v[i] > v[hi]) { hi = i; } + } + + const size_t first = std::min(lo, hi); + const size_t second = std::max(lo, hi); + out.t.push_back(t[first]); + out.v.push_back(v[first]); + if (second != first) { + out.t.push_back(t[second]); + out.v.push_back(v[second]); + } + } +} + +} /* namespace udpscope */ diff --git a/Client/udpscope/Decimate.h b/Client/udpscope/Decimate.h new file mode 100644 index 0000000..baf4a0d --- /dev/null +++ b/Client/udpscope/Decimate.h @@ -0,0 +1,25 @@ +/** + * @file Decimate.h + * @brief Min/max envelope decimation for screen rendering. + */ +#pragma once + +#include "Types.h" + +namespace udpscope { + +/** + * @brief Reduce n points to at most maxPoints by emitting each bucket's + * minimum and maximum, in time order. + * + * LTTB is deliberately not used. It selects representative points and will + * silently drop a one-sample glitch; on a scope that glitch is usually the + * thing being looked for. The emitted pair stays in time order rather than + * value order because callers binary-search the result by time. + * + * Input shorter than maxPoints is copied through unchanged. + */ +void MinMaxDecimate(const double* t, const double* v, size_t n, + size_t maxPoints, Series& out); + +} /* namespace udpscope */ diff --git a/Client/udpscope/Types.h b/Client/udpscope/Types.h new file mode 100644 index 0000000..878f640 --- /dev/null +++ b/Client/udpscope/Types.h @@ -0,0 +1,38 @@ +/** + * @file Types.h + * @brief Plain data shared across UDPScope modules. No logic, no dependencies. + */ +#pragma once + +#include +#include +#include +#include + +namespace udpscope { + +/** A time series as two parallel arrays, which is what ImPlot wants. */ +struct Series { + std::vector t; + std::vector v; + + void clear() { t.clear(); v.clear(); } + size_t size() const { return t.size(); } + bool empty() const { return t.empty(); } +}; + +/** RGBA in 0..1. Framework-free so PaneTree needs no ImGui. */ +struct Color { + float r = 1.f, g = 1.f, b = 1.f, a = 1.f; +}; + +/** Screen rectangle in pixels. */ +struct Rect { + double x = 0.0, y = 0.0, w = 0.0, h = 0.0; + + bool contains(double px, double py) const { + return px >= x && px < (x + w) && py >= y && py < (y + h); + } +}; + +} /* namespace udpscope */ diff --git a/Client/udpscope/tests/DecimateTest.cpp b/Client/udpscope/tests/DecimateTest.cpp new file mode 100644 index 0000000..85a6d7e --- /dev/null +++ b/Client/udpscope/tests/DecimateTest.cpp @@ -0,0 +1,73 @@ +#include "Decimate.h" + +#include + +#include +#include + +using namespace udpscope; + +TEST(MinMaxDecimate, PassesShortInputThroughUnchanged) { + const std::vector t{0.0, 1.0, 2.0}; + const std::vector v{5.0, 6.0, 7.0}; + Series out; + + MinMaxDecimate(t.data(), v.data(), t.size(), 100, out); + + EXPECT_EQ(out.t, t); + EXPECT_EQ(out.v, v); +} + +// The whole reason for preferring min/max over LTTB: a single-sample spike is +// usually the thing the user is looking for, and it must survive decimation. +TEST(MinMaxDecimate, PreservesAnIsolatedSpike) { + std::vector t(1000), v(1000, 0.0); + for (size_t i = 0; i < t.size(); i++) { t[i] = static_cast(i); } + v[437] = 42.0; + Series out; + + MinMaxDecimate(t.data(), v.data(), t.size(), 50, out); + + ASSERT_FALSE(out.v.empty()); + EXPECT_EQ(*std::max_element(out.v.begin(), out.v.end()), 42.0); +} + +TEST(MinMaxDecimate, PreservesTheExtremesOfEveryBucket) { + std::vector t(100), v(100); + for (size_t i = 0; i < t.size(); i++) { + t[i] = static_cast(i); + v[i] = (i % 10 == 3) ? -9.0 : ((i % 10 == 7) ? 9.0 : 0.0); + } + Series out; + + MinMaxDecimate(t.data(), v.data(), t.size(), 20, out); + + EXPECT_EQ(*std::min_element(out.v.begin(), out.v.end()), -9.0); + EXPECT_EQ(*std::max_element(out.v.begin(), out.v.end()), 9.0); +} + +// A ring whose timestamps are not monotonic breaks any later binary search by +// time, so the pair emitted per bucket must be ordered by time, not by value. +TEST(MinMaxDecimate, EmitsPointsInTimeOrder) { + std::vector t(400), v(400); + for (size_t i = 0; i < t.size(); i++) { + t[i] = static_cast(i); + v[i] = (i % 2 == 0) ? -static_cast(i) : static_cast(i); + } + Series out; + + MinMaxDecimate(t.data(), v.data(), t.size(), 40, out); + + ASSERT_GT(out.t.size(), 1u); + for (size_t i = 1; i < out.t.size(); i++) { + EXPECT_LE(out.t[i - 1], out.t[i]) << "at index " << i; + } +} + +TEST(MinMaxDecimate, HandlesEmptyInput) { + Series out; + out.t.push_back(1.0); // must be cleared + MinMaxDecimate(nullptr, nullptr, 0, 10, out); + EXPECT_TRUE(out.t.empty()); + EXPECT_TRUE(out.v.empty()); +}