diff --git a/CMakeLists.txt b/CMakeLists.txt index cee8cdf6..f8e71175 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -99,14 +99,6 @@ FetchContent_Declare( ) FetchContent_Populate(kissfft) -# Fetch stb for PNG loading in icon registry (single-header, no build needed) -FetchContent_Declare( - stb - GIT_REPOSITORY https://github.com/nothings/stb.git - GIT_TAG master -) -FetchContent_Populate(stb) - # Fetch nlohmann/json for serialization (header-only) FetchContent_Declare( nlohmann_json @@ -213,7 +205,6 @@ add_subdirectory("adc") add_subdirectory("coax") add_subdirectory("pfb_channelizer") add_subdirectory("iq_plot") -add_subdirectory("icon_registry") add_subdirectory("ideal_filter") add_subdirectory("attenuator") add_subdirectory("combiner") @@ -224,6 +215,14 @@ add_subdirectory("tutorial") # Install targets # ============================================================================= install(TARGETS tiny-rf-simulator RUNTIME DESTINATION bin) +# Install component data (JSON library definitions + S-parameter files) and +# built-in extension payloads next to the executable. The app resolves both +# scan roots exe-relative (refreshExtensions in app.cpp, scanRoots in +# extension_manager.cpp), matching the layout/ + SessionState exe-relative +# convention. +install(DIRECTORY component_data DESTINATION bin) +install(DIRECTORY extensions DESTINATION bin) + # Install documentation install(FILES README.md LICENSE DESTINATION share/doc/rf-simulator) diff --git a/app/src/app.cpp b/app/src/app.cpp index ba6c038a..539195d2 100644 --- a/app/src/app.cpp +++ b/app/src/app.cpp @@ -16,6 +16,15 @@ #include #include #include +#ifdef _WIN32 +#include +#elif defined(__APPLE__) +#include +#include +#else +#include +#include +#endif // Keep only filesystem-safe characters for path segments: [A-Za-z0-9-_ ]. // Strips everything else (incl. /, \\, and . which eliminates .. risks). // Trims leading/trailing spaces. Returns fallback if result is empty. @@ -34,6 +43,37 @@ static std::string sanitizePathSegment(const std::string &s, const std::string & return out.empty() ? fallback : out; } +// Directory of the running executable, for exe-relative data/layout lookup. +// Falls back to the current working directory if exe-path detection fails. +// Same convention as layout/ (LayoutManager) and tutorial/ (TutorialState). +static std::string appExeDir() { + std::string exe_path; +#ifdef _WIN32 + char buf[MAX_PATH] = {}; + DWORD n = GetModuleFileNameA(nullptr, buf, sizeof(buf)); + if (n > 0 && n < sizeof(buf)) + exe_path = buf; +#elif defined(__APPLE__) + char buf[PATH_MAX] = {}; + uint32_t size = sizeof(buf); + if (_NSGetExecutablePath(buf, &size) == 0) + exe_path = buf; +#else + char buf[PATH_MAX] = {}; + ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (n > 0) { + buf[n] = '\0'; + exe_path = buf; + } +#endif + if (exe_path.empty()) + return std::filesystem::current_path().string(); + std::filesystem::path parent = std::filesystem::path(exe_path).parent_path(); + if (parent.empty()) + return std::filesystem::current_path().string(); + return parent.string(); +} + RfSimulatorApp::RfSimulatorApp() : m_components(m_graph_engine, m_view_manager) { m_graph_widget = std::make_unique(m_graph_engine); m_serializer = std::make_unique( @@ -234,7 +274,16 @@ void RfSimulatorApp::refreshExtensions() { if (fs::exists("rf-sim-libraries")) { m_library.scan("rf-sim-libraries"); } - if (fs::exists("component_data/library")) { + // Built-in examples: prefer the exe-relative install location + // (/component_data/library) so installed binaries find their + // shipped data; fall back to the source-tree-relative path (CWD == repo + // root) when running from a build tree. Same exe-relative convention as + // layout/ and SessionState. + const std::filesystem::path exe_builtin_library = + std::filesystem::path(appExeDir()) / "component_data" / "library"; + if (std::filesystem::exists(exe_builtin_library)) { + m_library.scan(exe_builtin_library.string()); + } else if (std::filesystem::exists("component_data/library")) { m_library.scan("component_data/library"); } diff --git a/app/src/extension_manager.cpp b/app/src/extension_manager.cpp index c729b6a0..54518588 100644 --- a/app/src/extension_manager.cpp +++ b/app/src/extension_manager.cpp @@ -2,17 +2,57 @@ #include #include +#include #include #include #include #include +#ifdef _WIN32 +#include +#elif defined(__APPLE__) +#include +#else +#include +#endif + #include namespace fs = std::filesystem; namespace { +// Directory of the running executable, for exe-relative installed payloads. +// Falls back to the current working directory if exe-path detection fails. +// Same convention as layout/ (LayoutManager) and tutorial/ (TutorialState). +std::string detectExeDir() { + std::string exe_path; +#ifdef _WIN32 + char buf[MAX_PATH] = {}; + DWORD n = GetModuleFileNameA(nullptr, buf, sizeof(buf)); + if (n > 0 && n < sizeof(buf)) + exe_path = buf; +#elif defined(__APPLE__) + char buf[PATH_MAX] = {}; + uint32_t size = sizeof(buf); + if (_NSGetExecutablePath(buf, &size) == 0) + exe_path = buf; +#else + char buf[PATH_MAX] = {}; + ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (n > 0) { + buf[n] = '\0'; + exe_path = buf; + } +#endif + if (exe_path.empty()) + return fs::current_path().string(); + fs::path parent = fs::path(exe_path).parent_path(); + if (parent.empty()) + return fs::current_path().string(); + return parent.string(); +} + std::optional parseVersionPart(std::string_view part) { if (part.empty()) return std::nullopt; @@ -101,6 +141,14 @@ std::optional readManifestId(const fs::path &manifest_path) { std::vector ExtensionManager::scanRoots(const fs::path &project_root) const { std::vector roots; roots.push_back(fs::path(PROJECT_SOURCE_DIR) / "extensions"); + // Installed built-in payloads live next to the executable + // (/extensions), matching the install rules and the layout/ + + // SessionState exe-relative convention. Nonexistent in dev/build-tree + // layouts (loadRoot skips missing roots), so discovery is unchanged + // there. Placed right after the source-tree root and before the + // global/project-local roots, so later-root shadowing precedence is + // preserved (built-in > global > project-local). + roots.push_back(fs::path(detectExeDir()) / "extensions"); #ifdef _WIN32 if (const char *home = std::getenv("USERPROFILE")) roots.push_back(fs::path(home) / ".rf-sim" / "extensions"); diff --git a/attenuator/CMakeLists.txt b/attenuator/CMakeLists.txt index 15da0565..91a7a5f4 100644 --- a/attenuator/CMakeLists.txt +++ b/attenuator/CMakeLists.txt @@ -1,8 +1,11 @@ +cmake_minimum_required(VERSION 3.20) +project(attenuator LANGUAGES CXX) + add_library(attenuator_engine src/attenuator_engine.cpp ) -target_include_directories(attenuator_engine PUBLIC include) +target_include_directories(attenuator_engine PUBLIC ${PROJECT_SOURCE_DIR}/include) target_link_libraries(attenuator_engine PUBLIC common PUBLIC simulator::touchstone_parser diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt index 1432b216..2fff2793 100644 --- a/common/CMakeLists.txt +++ b/common/CMakeLists.txt @@ -1,3 +1,6 @@ +cmake_minimum_required(VERSION 3.20) +project(common LANGUAGES CXX) + add_library(common INTERFACE) target_include_directories(common diff --git a/icon_registry/CMakeLists.txt b/icon_registry/CMakeLists.txt deleted file mode 100644 index dec0ff0d..00000000 --- a/icon_registry/CMakeLists.txt +++ /dev/null @@ -1,25 +0,0 @@ -cmake_minimum_required(VERSION 3.20) -project(icon_registry LANGUAGES CXX) - -add_library(icon_registry STATIC - src/icon_registry.cpp - src/texture_loader.cpp -) - -target_include_directories(icon_registry - PUBLIC ${PROJECT_SOURCE_DIR}/include - PRIVATE ${PROJECT_SOURCE_DIR}/src -) - -target_include_directories(icon_registry - PRIVATE ${stb_SOURCE_DIR} -) - -target_link_libraries(icon_registry - PUBLIC - imgui - PRIVATE - OpenGL::GL -) - -add_library(simulator::icon_registry ALIAS icon_registry) diff --git a/icon_registry/include/icon_registry.h b/icon_registry/include/icon_registry.h deleted file mode 100644 index f9e97bfa..00000000 --- a/icon_registry/include/icon_registry.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once -#include "imgui.h" -#include - -class IconRegistry { - public: - IconRegistry(); - ~IconRegistry(); - - void load(const std::string &label_prefix, const char *png_path); - ImTextureID get(const std::string &node_label) const; - void clear(); - - private: - struct Impl; - Impl *m_impl = nullptr; -}; diff --git a/icon_registry/src/icon_registry.cpp b/icon_registry/src/icon_registry.cpp deleted file mode 100644 index 42e2989c..00000000 --- a/icon_registry/src/icon_registry.cpp +++ /dev/null @@ -1,33 +0,0 @@ -#include "icon_registry.h" -#include "texture_loader.h" -#include -struct IconRegistry::Impl { - std::unordered_map icons; -}; - -IconRegistry::IconRegistry() : m_impl(new Impl()) {} - -IconRegistry::~IconRegistry() { - clear(); - delete m_impl; -} - -void IconRegistry::load(const std::string &label_prefix, const char *png_path) { - ImTextureID tex = loadTextureFromFile(png_path); - if (tex) - m_impl->icons[label_prefix] = tex; -} - -ImTextureID IconRegistry::get(const std::string &node_label) const { - for (const auto &[prefix, tex] : m_impl->icons) { - if (node_label.rfind(prefix, 0) == 0) - return tex; - } - return ImTextureID(0); -} - -void IconRegistry::clear() { - for (auto &[prefix, tex] : m_impl->icons) - freeTexture(tex); - m_impl->icons.clear(); -} diff --git a/icon_registry/src/texture_loader.cpp b/icon_registry/src/texture_loader.cpp deleted file mode 100644 index 8f2c3a2d..00000000 --- a/icon_registry/src/texture_loader.cpp +++ /dev/null @@ -1,37 +0,0 @@ -#include "texture_loader.h" - -#define STB_IMAGE_IMPLEMENTATION -#include "stb_image.h" - -#ifdef _WIN32 -#include -#endif -#include - -ImTextureID loadTextureFromFile(const char *png_path) { - int w, h, channels; - unsigned char *data = stbi_load(png_path, &w, &h, &channels, 4); - if (!data) - return ImTextureID(0); - - GLuint tex = 0; - glGenTextures(1, &tex); - glBindTexture(GL_TEXTURE_2D, tex); - - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, data); - - stbi_image_free(data); - return ImTextureID(static_cast(tex)); -} - -void freeTexture(ImTextureID tex) { - if (!tex) - return; - GLuint gl_tex = static_cast(static_cast(tex)); - glDeleteTextures(1, &gl_tex); -} diff --git a/icon_registry/src/texture_loader.h b/icon_registry/src/texture_loader.h deleted file mode 100644 index 9cc11893..00000000 --- a/icon_registry/src/texture_loader.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once -#include "imgui.h" - -ImTextureID loadTextureFromFile(const char *png_path); -void freeTexture(ImTextureID tex); diff --git a/logging/CMakeLists.txt b/logging/CMakeLists.txt index 89ca6a3e..21ae5dac 100644 --- a/logging/CMakeLists.txt +++ b/logging/CMakeLists.txt @@ -1,4 +1,5 @@ -project(logging) +cmake_minimum_required(VERSION 3.20) +project(logging LANGUAGES CXX) # Backend add_library(logging_core STATIC diff --git a/node_graph/CMakeLists.txt b/node_graph/CMakeLists.txt index d216dc79..833ddd5c 100644 --- a/node_graph/CMakeLists.txt +++ b/node_graph/CMakeLists.txt @@ -17,6 +17,9 @@ add_library(simulator::node_graph_engine ALIAS node_graph_engine) # ---- UI Widget ---- add_library(node_graph_widget STATIC src/node_graph_widget.cpp + src/node_graph_widget_groups.cpp + src/node_graph_widget_tooltips.cpp + src/schematic_symbols.cpp ) target_include_directories(node_graph_widget @@ -28,7 +31,6 @@ target_link_libraries(node_graph_widget simulator::node_graph_engine imnodes simulator::core - simulator::icon_registry ) add_library(simulator::node_graph_widget ALIAS node_graph_widget) diff --git a/node_graph/include/node_graph_widget.h b/node_graph/include/node_graph_widget.h index 19af7dfc..0c628ecf 100644 --- a/node_graph/include/node_graph_widget.h +++ b/node_graph/include/node_graph_widget.h @@ -1,7 +1,7 @@ #pragma once #include "group.h" -#include "icon_registry.h" +#include "imgui.h" #include "node_graph_engine.h" #include #include @@ -20,8 +20,6 @@ class NodeGraphWidget { void draw(const char *title, bool *p_open = nullptr); - IconRegistry &iconRegistry() { return m_icons; } - // Callbacks for app to create/destroy components std::function onNodeMoved; std::function onRemoveNode; @@ -63,7 +61,6 @@ class NodeGraphWidget { private: NodeGraphEngine &m_engine; ImNodesEditorContext *m_context; - IconRegistry m_icons; // Interaction state tracking int m_clicked_pin = -1; diff --git a/node_graph/src/node_graph_widget.cpp b/node_graph/src/node_graph_widget.cpp index cbe93f94..f86aa25e 100644 --- a/node_graph/src/node_graph_widget.cpp +++ b/node_graph/src/node_graph_widget.cpp @@ -2,12 +2,9 @@ #include "node_graph_widget.h" #include "imgui.h" #include "imnodes.h" -#include "logging_core.h" -#include #include #include #include -#include #include #include @@ -522,235 +519,6 @@ void NodeGraphWidget::handleProbeClick() { } } -namespace { - -void showSpectrumTooltip(const Spectrum &spec, const char *direction) { - ImGui::BeginTooltip(); - - if (spec.frequencies.empty() && spec.tones.empty()) { - ImGui::Text("%s: No signal", direction); - ImGui::EndTooltip(); - return; - } - - int num_tones = static_cast(spec.tones.size()); - if (num_tones > 0) { - double strongest_power = -std::numeric_limits::infinity(); - double strongest_freq = 0.0; - for (const auto &t : spec.tones) { - if (t.power_dBm > strongest_power) { - strongest_power = t.power_dBm; - strongest_freq = t.freq_Hz; - } - } - char buf[128]; - std::snprintf(buf, sizeof(buf), "Tones: %d | Strongest: %.3f MHz @ %.1f dBm", num_tones, - strongest_freq / 1e6, strongest_power); - ImGui::TextUnformatted(buf); - } else { - ImGui::Text("Tones: 0"); - } - - if (!spec.noise_total_W.empty()) { - double sum = 0.0; - for (double n : spec.noise_total_W) - sum += n; - double avg_W = sum / static_cast(spec.noise_total_W.size()); - double avg_dBm_per_Hz = 10.0 * std::log10(avg_W) + 30.0; - ImGui::Text("Noise floor: %.1f dBm/Hz", avg_dBm_per_Hz); - } else { - ImGui::Text("Noise floor: -- dBm/Hz"); - } - - if (!spec.frequencies.empty()) { - double f_min = spec.frequencies.front(); - double f_max = spec.frequencies.back(); - double f_center = (f_min + f_max) / 2.0; - - auto fmt_freq = [](double hz) -> std::string { - char buf[32]; - std::snprintf(buf, sizeof(buf), "%.3f MHz", hz / 1e6); - return buf; - }; - ImGui::Text("Freq range: %s - %s (center: %s)", fmt_freq(f_min).c_str(), - fmt_freq(f_max).c_str(), fmt_freq(f_center).c_str()); - } - - ImGui::EndTooltip(); -} - -// ponytail: per-name symbol helpers are static and one-shot. - -static void drawGeneratorSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { - constexpr int N = 16; - constexpr float W = 30.0f, H = 12.0f; - ImVec2 pts[N]; - for (int i = 0; i < N; ++i) { - float t = static_cast(i) / (N - 1); - float x = c.x - W + 2.0f * W * t; - float y = c.y - H * std::sin(2.0f * 3.14159265f * 2.0f * t); - pts[i] = ImVec2(x, y); - } - dl->AddPolyline(pts, N, color, ImDrawFlags_None, 2.0f); -} - -static void drawAmplifierSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { - ImVec2 a(c.x - 20, c.y - 16); - ImVec2 b(c.x - 20, c.y + 16); - ImVec2 d(c.x + 20, c.y); - dl->AddTriangle(a, b, d, color, 2.0f); - dl->AddLine(ImVec2(a.x - 8, c.y - 6), ImVec2(a.x - 8, c.y - 14), color, 2.0f); - dl->AddLine(ImVec2(a.x - 12, c.y - 10), ImVec2(a.x - 4, c.y - 10), color, 2.0f); - dl->AddLine(ImVec2(a.x - 8, c.y + 6), ImVec2(a.x - 8, c.y + 14), color, 2.0f); -} - -static void drawMixerSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { - dl->AddCircle(c, 14.0f, color, 24, 2.0f); - dl->AddLine(ImVec2(c.x - 10, c.y - 10), ImVec2(c.x + 10, c.y + 10), color, 2.0f); - dl->AddLine(ImVec2(c.x - 10, c.y + 10), ImVec2(c.x + 10, c.y - 10), color, 2.0f); -} - -static void drawSplitterSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { - ImVec2 in_pt(c.x - 22, c.y); - ImVec2 mid(c.x, c.y); - ImVec2 out_a(c.x + 22, c.y - 12); - ImVec2 out_b(c.x + 22, c.y + 12); - dl->AddLine(in_pt, mid, color, 2.0f); - dl->AddLine(mid, out_a, color, 2.0f); - dl->AddLine(mid, out_b, color, 2.0f); - dl->AddCircleFilled(in_pt, 2.5f, color); - dl->AddCircleFilled(out_a, 2.5f, color); - dl->AddCircleFilled(out_b, 2.5f, color); -} - -static void drawCombinerSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { - // Reverse of splitter: 2 inputs on left, 1 output on right - ImVec2 in_a(c.x - 22, c.y - 12); - ImVec2 in_b(c.x - 22, c.y + 12); - ImVec2 mid(c.x, c.y); - ImVec2 out_pt(c.x + 22, c.y); - dl->AddLine(in_a, mid, color, 2.0f); - dl->AddLine(in_b, mid, color, 2.0f); - dl->AddLine(mid, out_pt, color, 2.0f); - dl->AddCircleFilled(in_a, 2.5f, color); - dl->AddCircleFilled(in_b, 2.5f, color); - dl->AddCircleFilled(out_pt, 2.5f, color); -} - -static void drawAdcSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { - ImVec2 pts[6] = { - ImVec2(c.x - 24, c.y + 8), ImVec2(c.x - 16, c.y + 8), ImVec2(c.x - 16, c.y - 4), - ImVec2(c.x - 8, c.y - 4), ImVec2(c.x - 8, c.y + 8), ImVec2(c.x + 24, c.y + 8), - }; - dl->AddPolyline(pts, 6, color, ImDrawFlags_None, 2.0f); -} - -static void drawFilterSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { - dl->AddCircle(ImVec2(c.x - 10, c.y), 5.0f, color, 16, 2.0f); - dl->AddCircle(ImVec2(c.x + 10, c.y), 5.0f, color, 16, 2.0f); - dl->AddLine(ImVec2(c.x - 22, c.y - 10), ImVec2(c.x - 22, c.y + 10), color, 2.0f); - dl->AddLine(ImVec2(c.x + 22, c.y - 10), ImVec2(c.x + 22, c.y + 10), color, 2.0f); - dl->AddLine(ImVec2(c.x - 22, c.y), ImVec2(c.x + 22, c.y), color, 2.0f); -} - -static void drawCoaxSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { - dl->AddCircle(c, 16.0f, color, 32, 2.0f); - dl->AddCircle(c, 8.0f, color, 24, 2.0f); -} - -static void drawEqualizerSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { - // Rising/falling slope line - dl->AddLine(ImVec2(c.x - 20, c.y + 8), ImVec2(c.x + 20, c.y - 8), color, 2.0f); - // Small reference markers - dl->AddCircleFilled(ImVec2(c.x - 14, c.y + 4), 2.0f, color); - dl->AddCircleFilled(ImVec2(c.x + 14, c.y - 4), 2.0f, color); -} - -static void drawAttenuatorSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { - // Zigzag resistor-style symbol with "ATT" label - const float x0 = c.x - 15.0f; - const float x1 = c.x + 15.0f; - const float amp = 4.0f; - dl->PathClear(); - dl->PathLineTo(ImVec2(x0, c.y)); - for (int i = 0; i < 4; ++i) { - float x = x0 + (x1 - x0) * (i + 0.5f) / 4.0f; - float yo = (i % 2 == 0) ? -amp : amp; - dl->PathLineTo(ImVec2(x, c.y + yo)); - } - dl->PathLineTo(ImVec2(x1, c.y)); - dl->PathStroke(color, 0, 2.0f); - const char *label = "ATT"; - ImVec2 ts = ImGui::CalcTextSize(label); - dl->AddText(ImVec2(c.x - ts.x * 0.5f, c.y - 14.0f), color, label); -} - -static void drawPfbSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { - for (int i = 0; i < 3; ++i) { - float y_off = (i - 1) * 8.0f; - dl->AddRect(ImVec2(c.x - 20, c.y - 4 + y_off), ImVec2(c.x + 20, c.y + 4 + y_off), color, - 0.0f, ImDrawFlags_None, 2.0f); - } - dl->AddText(ImVec2(c.x - 4, c.y - 18), color, "M"); -} - -static void drawGroupCollapsedSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { - ImVec2 a(c.x - 18, c.y - 6); - ImVec2 b(c.x, c.y); - ImVec2 d(c.x + 18, c.y + 6); - dl->AddLine(a, b, color, 2.0f); - dl->AddLine(b, d, color, 2.0f); - dl->AddCircleFilled(a, 3.0f, color); - dl->AddCircleFilled(b, 3.0f, color); - dl->AddCircleFilled(d, 3.0f, color); -} -} // namespace - -void NodeGraphWidget::drawSchematicSymbol(ImDrawList *dl, ImVec2 center, NodeKind kind, - ImU32 color) { - switch (kind) { - case NodeKind::Generator: - drawGeneratorSymbol(dl, center, color); - break; - case NodeKind::Amplifier: - drawAmplifierSymbol(dl, center, color); - break; - case NodeKind::Splitter: - drawSplitterSymbol(dl, center, color); - break; - case NodeKind::Mixer: - drawMixerSymbol(dl, center, color); - break; - case NodeKind::Adc: - drawAdcSymbol(dl, center, color); - break; - case NodeKind::PFB: - drawPfbSymbol(dl, center, color); - break; - case NodeKind::IdealFilter: - drawFilterSymbol(dl, center, color); - break; - case NodeKind::CoaxCable: - drawCoaxSymbol(dl, center, color); - break; - case NodeKind::Equalizer: - drawEqualizerSymbol(dl, center, color); - break; - case NodeKind::Attenuator: - drawAttenuatorSymbol(dl, center, color); - break; - case NodeKind::Combiner: - drawCombinerSymbol(dl, center, color); - break; - case NodeKind::GroupCollapsed: - drawGroupCollapsedSymbol(dl, center, color); - break; - default: - // Future/unknown kinds: draw nothing silently. - break; - } -} - void NodeGraphWidget::setupDarkTheme() { // ponytail: pushes are popped at the end of the same draw() call (balanced // before the closing ImGui::End()). Per-node title/border overrides are @@ -767,386 +535,8 @@ void NodeGraphWidget::setupDarkTheme() { ImNodes::PushColorStyle(ImNodesCol_PinHovered, IM_COL32(120, 200, 255, 255)); } -void NodeGraphWidget::rebuildSynthMaps() { - m_synth_pin_to_real_pin.clear(); - m_real_to_synth_pin.clear(); - for (const auto &g : m_engine.groups()) { - // Only COLLAPSED groups have registered synthetic pins; expanded groups' - // boundary pins are cleared (by setGroupCollapsed), but guard anyway. - if (!g.collapsed) - continue; - for (const auto &bp : g.boundary_pins) { - m_synth_pin_to_real_pin[bp.id] = bp.internal_pin_id; - // Only one synthetic pin per internal pin (the first wins; if the same - // internal pin appears in multiple boundary pins due to multiple outgoing - // links, the link rendering can still find the synth pin) - if (m_real_to_synth_pin.find(bp.internal_pin_id) == m_real_to_synth_pin.end()) { - m_real_to_synth_pin[bp.internal_pin_id] = bp.id; - } - } - } -} - -void NodeGraphWidget::drawGroupBackgrounds() { - // Group background is drawn here; internals (when expanded) are drawn by - // drawNodes() afterward, on top of this background. - ImDrawList *dl = ImGui::GetWindowDrawList(); - for (const auto &g : m_engine.groups()) { - if (g.collapsed) - continue; - if (g.member_node_ids.empty()) - continue; - - // Compute bounding box in screen space from cached screen-space positions - ImVec2 top_left(std::numeric_limits::max(), std::numeric_limits::max()); - ImVec2 bottom_right(std::numeric_limits::lowest(), - std::numeric_limits::lowest()); - const float NODE_W = 120.0f, NODE_H = 80.0f; // approximate; refined in spike - for (int nid : g.member_node_ids) { - auto pos_it = m_node_screen_positions.find(nid); - if (pos_it == m_node_screen_positions.end()) - continue; - ImVec2 pos = pos_it->second; - top_left.x = std::min(top_left.x, pos.x); - top_left.y = std::min(top_left.y, pos.y); - bottom_right.x = std::max(bottom_right.x, pos.x + NODE_W); - bottom_right.y = std::max(bottom_right.y, pos.y + NODE_H); - } - if (top_left.x > bottom_right.x) - continue; // no valid members - - ImVec2 pad(16, 16); - ImVec2 tl_screen = top_left - pad; - ImVec2 br_screen = bottom_right + pad; - - dl->AddRectFilled(tl_screen, br_screen, IM_COL32(80, 80, 120, 24)); - dl->AddRect(tl_screen, br_screen, IM_COL32(120, 120, 180, 96)); - - // Title bar at the top of the rectangle (in screen space) - drawGroupTitleBar(const_cast(g), top_left); - } -} - -void NodeGraphWidget::drawGroupCollapsedBlocks() { - for (const auto &g : m_engine.groups()) { - if (!g.collapsed) - continue; - - // Compute centroid in screen space using cached positions (member nodes may have been - // removed from the imnodes pool since hidden nodes aren't rendered each frame) - if (g.member_node_ids.empty()) - continue; - ImVec2 sum(0, 0); - int count = 0; - for (int nid : g.member_node_ids) { - auto pos_it = m_node_screen_positions.find(nid); - if (pos_it == m_node_screen_positions.end()) - continue; - sum.x += pos_it->second.x; - sum.y += pos_it->second.y; - ++count; - } - if (count == 0) - continue; - ImVec2 centroid_screen(sum.x / count, sum.y / count); - // Convert to grid space and position the collapsed block at the centroid - ImVec2 centroid_grid = centroid_screen - m_grid_to_screen_offset; - ImNodes::SetNodeGridSpacePos(g.id, centroid_grid - ImVec2(60, 40)); - - // Render the block as an imnodes node - ImNodes::BeginNode(g.id); - const ImU32 group_color = static_cast(themeColor(NodeKind::GroupCollapsed)); - ImNodes::PushColorStyle(ImNodesCol_TitleBar, group_color); - ImNodes::PushColorStyle(ImNodesCol_NodeOutline, group_color); - ImNodes::BeginNodeTitleBar(); - ImGui::TextUnformatted(g.name.c_str()); - ImNodes::EndNodeTitleBar(); - - // Render boundary pins inside the body. In imnodes, the pin circle is drawn at the - // left/right edge of the node and the ImGui text content you put between - // Begin{Input,Output}Attribute/End{Input,Output}Attribute becomes the pin's label. - // Layout: inputs on the left (vertically stacked), outputs on the right. - if (g.boundary_pins.empty()) { - // Fallback indicator so the collapsed block doesn't look broken when the - // group has no cross-boundary links - ImGui::Dummy(ImVec2(120, 30)); - ImGui::Indent(8); - ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(180, 180, 200, 180)); - ImGui::TextUnformatted("(no external connections)"); - ImGui::PopStyleColor(); - ImGui::Unindent(8); - } else { - for (const auto &bp : g.boundary_pins) { - int slot = m_engine.probeSlotForPin(bp.internal_pin_id); - if (slot >= 0) { - static const ImU32 probe_colors[4] = { - IM_COL32(22, 199, 154, 255), - IM_COL32(230, 150, 40, 255), - IM_COL32(120, 50, 170, 255), - IM_COL32(60, 140, 220, 255), - }; - ImNodes::PushColorStyle(ImNodesCol_Pin, probe_colors[slot]); - ImNodes::PushColorStyle(ImNodesCol_PinHovered, probe_colors[slot]); - } - if (bp.is_output) { - ImNodes::BeginOutputAttribute(bp.id); - ImGui::TextUnformatted(bp.label.c_str()); - ImNodes::EndOutputAttribute(); - } else { - ImNodes::BeginInputAttribute(bp.id); - ImGui::TextUnformatted(bp.label.c_str()); - ImNodes::EndInputAttribute(); - } - if (slot >= 0) { - ImNodes::PopColorStyle(); - ImNodes::PopColorStyle(); - } - } - } - - // Schematic symbol in the body (same machinery as drawNodes). - // ponytail: hardcoded body rect — see plain-node draw for upgrade path. - ImVec2 block_screen_pos = ImNodes::GetNodeScreenSpacePos(g.id); - constexpr float BODY_W = 120.0f; - constexpr float BODY_H = 60.0f; - constexpr float TITLE_BAR_H = 24.0f; - ImVec2 body_center(block_screen_pos.x + BODY_W * 0.5f, - block_screen_pos.y + TITLE_BAR_H + BODY_H * 0.5f); - ImDrawList *dl = ImGui::GetWindowDrawList(); - drawSchematicSymbol(dl, body_center, NodeKind::GroupCollapsed, group_color); - - // Expand button in the body. Placing it in the title bar would conflict with - // imnodes' title-bar drag handling. Right-click context menu still works as a fallback. - ImGui::Dummy(ImVec2(0, 4)); // small vertical spacer - if (ImGui::Button("Expand", ImVec2(120, 0))) { - m_engine.setGroupCollapsed(g.id, false); - } - - ImNodes::PopColorStyle(); // NodeOutline - ImNodes::PopColorStyle(); // TitleBar - ImNodes::EndNode(); - } -} - -void NodeGraphWidget::drawGroupTitleBar(Group &g, const ImVec2 &top_left_screen) { - ImDrawList *dl = ImGui::GetWindowDrawList(); - ImVec2 tl_screen = top_left_screen - ImVec2(16, 16); - ImVec2 br_screen = top_left_screen + ImVec2(180, 8); - - // Background of the title bar - dl->AddRectFilled(tl_screen, br_screen, IM_COL32(60, 60, 100, 200)); - - // Group name - dl->AddText(tl_screen + ImVec2(8, 4), IM_COL32(255, 255, 255, 255), g.name.c_str()); - - // Collapse button (▼) — full-height clickable area on the right side of the title bar - ImVec2 btn_min = ImVec2(br_screen.x - 24, tl_screen.y); - ImVec2 btn_max = br_screen; - ImVec2 mouse = ImGui::GetMousePos(); - bool btn_hovered = mouse.x >= btn_min.x && mouse.x <= btn_max.x && mouse.y >= btn_min.y && - mouse.y <= btn_max.y; - ImU32 btn_color = btn_hovered ? IM_COL32(130, 130, 180, 255) : IM_COL32(100, 100, 140, 255); - dl->AddRectFilled(btn_min, btn_max, btn_color); - // Center the glyph in the 24x24 button - ImVec2 text_pos(btn_min.x + 8, btn_min.y + 4); - dl->AddText(text_pos, IM_COL32(255, 255, 255, 255), "\xE2\x96\xBC"); // ▼ - - // Hit-test the button - if (ImGui::IsMouseClicked(0) && btn_hovered) { - m_engine.setGroupCollapsed(g.id, true); - } -} - -void NodeGraphWidget::detectNodeMoves() { - // Require BOTH a mouse release AND an actual position change. - // This prevents false positives: clicking menu items won't trigger, - // and stale position caches from a previous project load won't trigger - // because there was no mouse release during the load process. - if (!ImGui::IsMouseReleased(ImGuiMouseButton_Left)) - return; - bool moved = false; - for (const auto &node : m_engine.nodes()) { - // Skip nodes not drawn this frame (newly added nodes haven't been - // through BeginNode yet, so GetNodeEditorSpacePos would assert). - if (m_node_screen_positions.find(node.node_id) == m_node_screen_positions.end()) - continue; - ImVec2 current = ImNodes::GetNodeEditorSpacePos(node.node_id); - auto it = m_last_node_grid_positions.find(node.node_id); - if (it != m_last_node_grid_positions.end()) { - float dx = current.x - it->second.x; - float dy = current.y - it->second.y; - if (dx * dx + dy * dy > 1.0f) { - LOG_INFO("Node %d moved from (%.0f,%.0f) to (%.0f,%.0f)", node.node_id, - it->second.x, it->second.y, current.x, current.y); - moved = true; - } - } - m_last_node_grid_positions[node.node_id] = current; - } - if (moved && onNodeMoved) - onNodeMoved(); -} - -void NodeGraphWidget::handleRubberBand(bool editor_hovered) { - bool shift = ImGui::IsKeyDown(ImGuiKey_LeftShift); - bool left_down = ImGui::IsMouseDown(ImGuiMouseButton_Left); - bool left_released = ImGui::IsMouseReleased(ImGuiMouseButton_Left); - - // Start rubber-band on Shift + left-click down on empty space - if (left_down && shift && editor_hovered && !m_rubber_band_active) { - m_rubber_band_active = true; - m_rubber_band_start = ImGui::GetMousePos(); - m_rubber_band_end = m_rubber_band_start; - } - - // Update end position while dragging, draw the selection rectangle - if (m_rubber_band_active && left_down) { - m_rubber_band_end = ImGui::GetMousePos(); - ImDrawList *dl = ImGui::GetWindowDrawList(); - ImVec2 a(std::min(m_rubber_band_start.x, m_rubber_band_end.x), - std::min(m_rubber_band_start.y, m_rubber_band_end.y)); - ImVec2 b(std::max(m_rubber_band_start.x, m_rubber_band_end.x), - std::max(m_rubber_band_start.y, m_rubber_band_end.y)); - dl->AddRectFilled(a, b, IM_COL32(100, 200, 255, 32)); - dl->AddRect(a, b, IM_COL32(100, 200, 255, 200)); - } - - // On release, collect enclosed nodes - if (m_rubber_band_active && left_released) { - m_rubber_band_active = false; - m_rubber_band_members.clear(); - - ImVec2 screen_a(std::min(m_rubber_band_start.x, m_rubber_band_end.x), - std::min(m_rubber_band_start.y, m_rubber_band_end.y)); - ImVec2 screen_b(std::max(m_rubber_band_start.x, m_rubber_band_end.x), - std::max(m_rubber_band_start.y, m_rubber_band_end.y)); - - for (const auto &node : m_engine.nodes()) { - if (m_engine.groupIdForNode(node.node_id) != -1) - continue; // skip grouped - auto pos_it = m_node_screen_positions.find(node.node_id); - if (pos_it == m_node_screen_positions.end()) - continue; - ImVec2 pos = pos_it->second; - ImVec2 center = pos + ImVec2(60, 40); // approximate node center - if (center.x >= screen_a.x && center.x <= screen_b.x && center.y >= screen_a.y && - center.y <= screen_b.y) { - m_rubber_band_members.push_back(node.node_id); - } - } - - if (m_rubber_band_members.size() >= 2) { - m_show_create_popup = true; - ImGui::OpenPopup("CreateSubcircuit"); - } - } -} - -void NodeGraphWidget::handleGroupSelection() { - int hovered_node = -1; - if (ImGui::IsMouseClicked(0) && ImNodes::IsNodeHovered(&hovered_node)) { - if (hovered_node >= 50000 && hovered_node < 100000) { - // It's a group - m_engine.setSelectedGroupId(hovered_node); - ImNodes::ClearNodeSelection(); - } else { - // It's a regular node; deselect any group - m_engine.setSelectedGroupId(-1); - } - } - if (ImGui::IsMouseClicked(0)) { - bool editor_hovered = ImNodes::IsEditorHovered(); - int hovered_node = -1; - bool node_hovered = ImNodes::IsNodeHovered(&hovered_node); - int link_id = -1; - bool link_hovered = ImNodes::IsLinkHovered(&link_id); - if (editor_hovered && !node_hovered && !link_hovered) { - m_engine.setSelectedGroupId(-1); - } - } -} - size_t NodeGraphWidget::findNodeIndex(int node_id) const { (void)node_id; // Real implementation is added in a later task. return size_t(-1); } - -void NodeGraphWidget::showPinTooltips() { - int hovered_pin; - if (!ImNodes::IsPinHovered(&hovered_pin)) - return; - - for (const auto &node : m_engine.nodes()) { - const auto *signal = node.signal_node; - if (!signal) - continue; - - for (size_t i = 0; i < node.input_pin_ids.size(); ++i) { - if (node.input_pin_ids[i] != hovered_pin) - continue; - const Spectrum *spec = (i < signal->inputs.size()) ? signal->inputs[i] : nullptr; - if (spec) { - showSpectrumTooltip(*spec, "IN"); - return; - } - } - - for (size_t i = 0; i < node.output_pin_ids.size(); ++i) { - if (node.output_pin_ids[i] != hovered_pin) - continue; - const Spectrum &spec = (i < signal->outputs.size()) ? signal->outputs[i] : Spectrum(); - showSpectrumTooltip(spec, "OUT"); - return; - } - } - - // Boundary pin tooltips (synthesized pins with ids >= 100000) - if (hovered_pin >= 100000) { - auto it = m_synth_pin_to_real_pin.find(hovered_pin); - if (it != m_synth_pin_to_real_pin.end()) { - int real_pin = it->second; - for (const auto &node : m_engine.nodes()) { - for (size_t i = 0; i < node.output_pin_ids.size(); ++i) { - if (node.output_pin_ids[i] == real_pin && node.signal_node) { - if (i < node.signal_node->outputs.size()) { - showSpectrumTooltip(node.signal_node->outputs[i], "OUT"); - return; - } - } - } - for (size_t i = 0; i < node.input_pin_ids.size(); ++i) { - if (node.input_pin_ids[i] == real_pin && node.signal_node) { - const Spectrum *spec = (i < node.signal_node->inputs.size()) - ? node.signal_node->inputs[i] - : nullptr; - if (spec) { - showSpectrumTooltip(*spec, "IN"); - return; - } - } - } - } - } - } -} - -void NodeGraphWidget::showNodeHoverTooltips() { - if (!onNodeHover) - return; - - for (const auto &node : m_engine.nodes()) { - int hovered_node = -1; - if (ImNodes::IsNodeHovered(&hovered_node) && hovered_node == node.node_id) { - std::string summary = onNodeHover(node.node_id); - if (!summary.empty()) { - ImGui::BeginTooltip(); - ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); - ImGui::TextUnformatted(summary.c_str()); - ImGui::PopTextWrapPos(); - ImGui::EndTooltip(); - } - break; - } - } -} diff --git a/node_graph/src/node_graph_widget_groups.cpp b/node_graph/src/node_graph_widget_groups.cpp new file mode 100644 index 00000000..15415712 --- /dev/null +++ b/node_graph/src/node_graph_widget_groups.cpp @@ -0,0 +1,306 @@ +#define IMGUI_DEFINE_MATH_OPERATORS +#include "imgui.h" +#include "imnodes.h" +#include "logging_core.h" +#include "node_graph_widget.h" +#include +#include + +void NodeGraphWidget::rebuildSynthMaps() { + m_synth_pin_to_real_pin.clear(); + m_real_to_synth_pin.clear(); + for (const auto &g : m_engine.groups()) { + // Only COLLAPSED groups have registered synthetic pins; expanded groups' + // boundary pins are cleared (by setGroupCollapsed), but guard anyway. + if (!g.collapsed) + continue; + for (const auto &bp : g.boundary_pins) { + m_synth_pin_to_real_pin[bp.id] = bp.internal_pin_id; + // Only one synthetic pin per internal pin (the first wins; if the same + // internal pin appears in multiple boundary pins due to multiple outgoing + // links, the link rendering can still find the synth pin) + if (m_real_to_synth_pin.find(bp.internal_pin_id) == m_real_to_synth_pin.end()) { + m_real_to_synth_pin[bp.internal_pin_id] = bp.id; + } + } + } +} + +void NodeGraphWidget::drawGroupBackgrounds() { + // Group background is drawn here; internals (when expanded) are drawn by + // drawNodes() afterward, on top of this background. + ImDrawList *dl = ImGui::GetWindowDrawList(); + for (const auto &g : m_engine.groups()) { + if (g.collapsed) + continue; + if (g.member_node_ids.empty()) + continue; + + // Compute bounding box in screen space from cached screen-space positions + ImVec2 top_left(std::numeric_limits::max(), std::numeric_limits::max()); + ImVec2 bottom_right(std::numeric_limits::lowest(), + std::numeric_limits::lowest()); + const float NODE_W = 120.0f, NODE_H = 80.0f; // approximate; refined in spike + for (int nid : g.member_node_ids) { + auto pos_it = m_node_screen_positions.find(nid); + if (pos_it == m_node_screen_positions.end()) + continue; + ImVec2 pos = pos_it->second; + top_left.x = std::min(top_left.x, pos.x); + top_left.y = std::min(top_left.y, pos.y); + bottom_right.x = std::max(bottom_right.x, pos.x + NODE_W); + bottom_right.y = std::max(bottom_right.y, pos.y + NODE_H); + } + if (top_left.x > bottom_right.x) + continue; // no valid members + + ImVec2 pad(16, 16); + ImVec2 tl_screen = top_left - pad; + ImVec2 br_screen = bottom_right + pad; + + dl->AddRectFilled(tl_screen, br_screen, IM_COL32(80, 80, 120, 24)); + dl->AddRect(tl_screen, br_screen, IM_COL32(120, 120, 180, 96)); + + // Title bar at the top of the rectangle (in screen space) + drawGroupTitleBar(const_cast(g), top_left); + } +} + +void NodeGraphWidget::drawGroupCollapsedBlocks() { + for (const auto &g : m_engine.groups()) { + if (!g.collapsed) + continue; + + // Compute centroid in screen space using cached positions (member nodes may have been + // removed from the imnodes pool since hidden nodes aren't rendered each frame) + if (g.member_node_ids.empty()) + continue; + ImVec2 sum(0, 0); + int count = 0; + for (int nid : g.member_node_ids) { + auto pos_it = m_node_screen_positions.find(nid); + if (pos_it == m_node_screen_positions.end()) + continue; + sum.x += pos_it->second.x; + sum.y += pos_it->second.y; + ++count; + } + if (count == 0) + continue; + ImVec2 centroid_screen(sum.x / count, sum.y / count); + // Convert to grid space and position the collapsed block at the centroid + ImVec2 centroid_grid = centroid_screen - m_grid_to_screen_offset; + ImNodes::SetNodeGridSpacePos(g.id, centroid_grid - ImVec2(60, 40)); + + // Render the block as an imnodes node + ImNodes::BeginNode(g.id); + const ImU32 group_color = static_cast(themeColor(NodeKind::GroupCollapsed)); + ImNodes::PushColorStyle(ImNodesCol_TitleBar, group_color); + ImNodes::PushColorStyle(ImNodesCol_NodeOutline, group_color); + ImNodes::BeginNodeTitleBar(); + ImGui::TextUnformatted(g.name.c_str()); + ImNodes::EndNodeTitleBar(); + + // Render boundary pins inside the body. In imnodes, the pin circle is drawn at the + // left/right edge of the node and the ImGui text content you put between + // Begin{Input,Output}Attribute/End{Input,Output}Attribute becomes the pin's label. + // Layout: inputs on the left (vertically stacked), outputs on the right. + if (g.boundary_pins.empty()) { + // Fallback indicator so the collapsed block doesn't look broken when the + // group has no cross-boundary links + ImGui::Dummy(ImVec2(120, 30)); + ImGui::Indent(8); + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(180, 180, 200, 180)); + ImGui::TextUnformatted("(no external connections)"); + ImGui::PopStyleColor(); + ImGui::Unindent(8); + } else { + for (const auto &bp : g.boundary_pins) { + int slot = m_engine.probeSlotForPin(bp.internal_pin_id); + if (slot >= 0) { + static const ImU32 probe_colors[4] = { + IM_COL32(22, 199, 154, 255), + IM_COL32(230, 150, 40, 255), + IM_COL32(120, 50, 170, 255), + IM_COL32(60, 140, 220, 255), + }; + ImNodes::PushColorStyle(ImNodesCol_Pin, probe_colors[slot]); + ImNodes::PushColorStyle(ImNodesCol_PinHovered, probe_colors[slot]); + } + if (bp.is_output) { + ImNodes::BeginOutputAttribute(bp.id); + ImGui::TextUnformatted(bp.label.c_str()); + ImNodes::EndOutputAttribute(); + } else { + ImNodes::BeginInputAttribute(bp.id); + ImGui::TextUnformatted(bp.label.c_str()); + ImNodes::EndInputAttribute(); + } + if (slot >= 0) { + ImNodes::PopColorStyle(); + ImNodes::PopColorStyle(); + } + } + } + + // Schematic symbol in the body (same machinery as drawNodes). + // ponytail: hardcoded body rect — see plain-node draw for upgrade path. + ImVec2 block_screen_pos = ImNodes::GetNodeScreenSpacePos(g.id); + constexpr float BODY_W = 120.0f; + constexpr float BODY_H = 60.0f; + constexpr float TITLE_BAR_H = 24.0f; + ImVec2 body_center(block_screen_pos.x + BODY_W * 0.5f, + block_screen_pos.y + TITLE_BAR_H + BODY_H * 0.5f); + ImDrawList *dl = ImGui::GetWindowDrawList(); + drawSchematicSymbol(dl, body_center, NodeKind::GroupCollapsed, group_color); + + // Expand button in the body. Placing it in the title bar would conflict with + // imnodes' title-bar drag handling. Right-click context menu still works as a fallback. + ImGui::Dummy(ImVec2(0, 4)); // small vertical spacer + if (ImGui::Button("Expand", ImVec2(120, 0))) { + m_engine.setGroupCollapsed(g.id, false); + } + + ImNodes::PopColorStyle(); // NodeOutline + ImNodes::PopColorStyle(); // TitleBar + ImNodes::EndNode(); + } +} + +void NodeGraphWidget::drawGroupTitleBar(Group &g, const ImVec2 &top_left_screen) { + ImDrawList *dl = ImGui::GetWindowDrawList(); + ImVec2 tl_screen = top_left_screen - ImVec2(16, 16); + ImVec2 br_screen = top_left_screen + ImVec2(180, 8); + + // Background of the title bar + dl->AddRectFilled(tl_screen, br_screen, IM_COL32(60, 60, 100, 200)); + + // Group name + dl->AddText(tl_screen + ImVec2(8, 4), IM_COL32(255, 255, 255, 255), g.name.c_str()); + + // Collapse button (▼) — full-height clickable area on the right side of the title bar + ImVec2 btn_min = ImVec2(br_screen.x - 24, tl_screen.y); + ImVec2 btn_max = br_screen; + ImVec2 mouse = ImGui::GetMousePos(); + bool btn_hovered = mouse.x >= btn_min.x && mouse.x <= btn_max.x && mouse.y >= btn_min.y && + mouse.y <= btn_max.y; + ImU32 btn_color = btn_hovered ? IM_COL32(130, 130, 180, 255) : IM_COL32(100, 100, 140, 255); + dl->AddRectFilled(btn_min, btn_max, btn_color); + // Center the glyph in the 24x24 button + ImVec2 text_pos(btn_min.x + 8, btn_min.y + 4); + dl->AddText(text_pos, IM_COL32(255, 255, 255, 255), "\xE2\x96\xBC"); // ▼ + + // Hit-test the button + if (ImGui::IsMouseClicked(0) && btn_hovered) { + m_engine.setGroupCollapsed(g.id, true); + } +} + +void NodeGraphWidget::detectNodeMoves() { + // Require BOTH a mouse release AND an actual position change. + // This prevents false positives: clicking menu items won't trigger, + // and stale position caches from a previous project load won't trigger + // because there was no mouse release during the load process. + if (!ImGui::IsMouseReleased(ImGuiMouseButton_Left)) + return; + bool moved = false; + for (const auto &node : m_engine.nodes()) { + // Skip nodes not drawn this frame (newly added nodes haven't been + // through BeginNode yet, so GetNodeEditorSpacePos would assert). + if (m_node_screen_positions.find(node.node_id) == m_node_screen_positions.end()) + continue; + ImVec2 current = ImNodes::GetNodeEditorSpacePos(node.node_id); + auto it = m_last_node_grid_positions.find(node.node_id); + if (it != m_last_node_grid_positions.end()) { + float dx = current.x - it->second.x; + float dy = current.y - it->second.y; + if (dx * dx + dy * dy > 1.0f) { + LOG_INFO("Node %d moved from (%.0f,%.0f) to (%.0f,%.0f)", node.node_id, + it->second.x, it->second.y, current.x, current.y); + moved = true; + } + } + m_last_node_grid_positions[node.node_id] = current; + } + if (moved && onNodeMoved) + onNodeMoved(); +} + +void NodeGraphWidget::handleRubberBand(bool editor_hovered) { + bool shift = ImGui::IsKeyDown(ImGuiKey_LeftShift); + bool left_down = ImGui::IsMouseDown(ImGuiMouseButton_Left); + bool left_released = ImGui::IsMouseReleased(ImGuiMouseButton_Left); + + // Start rubber-band on Shift + left-click down on empty space + if (left_down && shift && editor_hovered && !m_rubber_band_active) { + m_rubber_band_active = true; + m_rubber_band_start = ImGui::GetMousePos(); + m_rubber_band_end = m_rubber_band_start; + } + + // Update end position while dragging, draw the selection rectangle + if (m_rubber_band_active && left_down) { + m_rubber_band_end = ImGui::GetMousePos(); + ImDrawList *dl = ImGui::GetWindowDrawList(); + ImVec2 a(std::min(m_rubber_band_start.x, m_rubber_band_end.x), + std::min(m_rubber_band_start.y, m_rubber_band_end.y)); + ImVec2 b(std::max(m_rubber_band_start.x, m_rubber_band_end.x), + std::max(m_rubber_band_start.y, m_rubber_band_end.y)); + dl->AddRectFilled(a, b, IM_COL32(100, 200, 255, 32)); + dl->AddRect(a, b, IM_COL32(100, 200, 255, 200)); + } + + // On release, collect enclosed nodes + if (m_rubber_band_active && left_released) { + m_rubber_band_active = false; + m_rubber_band_members.clear(); + + ImVec2 screen_a(std::min(m_rubber_band_start.x, m_rubber_band_end.x), + std::min(m_rubber_band_start.y, m_rubber_band_end.y)); + ImVec2 screen_b(std::max(m_rubber_band_start.x, m_rubber_band_end.x), + std::max(m_rubber_band_start.y, m_rubber_band_end.y)); + + for (const auto &node : m_engine.nodes()) { + if (m_engine.groupIdForNode(node.node_id) != -1) + continue; // skip grouped + auto pos_it = m_node_screen_positions.find(node.node_id); + if (pos_it == m_node_screen_positions.end()) + continue; + ImVec2 pos = pos_it->second; + ImVec2 center = pos + ImVec2(60, 40); // approximate node center + if (center.x >= screen_a.x && center.x <= screen_b.x && center.y >= screen_a.y && + center.y <= screen_b.y) { + m_rubber_band_members.push_back(node.node_id); + } + } + + if (m_rubber_band_members.size() >= 2) { + m_show_create_popup = true; + ImGui::OpenPopup("CreateSubcircuit"); + } + } +} + +void NodeGraphWidget::handleGroupSelection() { + int hovered_node = -1; + if (ImGui::IsMouseClicked(0) && ImNodes::IsNodeHovered(&hovered_node)) { + if (hovered_node >= 50000 && hovered_node < 100000) { + // It's a group + m_engine.setSelectedGroupId(hovered_node); + ImNodes::ClearNodeSelection(); + } else { + // It's a regular node; deselect any group + m_engine.setSelectedGroupId(-1); + } + } + if (ImGui::IsMouseClicked(0)) { + bool editor_hovered = ImNodes::IsEditorHovered(); + int hovered_node = -1; + bool node_hovered = ImNodes::IsNodeHovered(&hovered_node); + int link_id = -1; + bool link_hovered = ImNodes::IsLinkHovered(&link_id); + if (editor_hovered && !node_hovered && !link_hovered) { + m_engine.setSelectedGroupId(-1); + } + } +} diff --git a/node_graph/src/node_graph_widget_tooltips.cpp b/node_graph/src/node_graph_widget_tooltips.cpp new file mode 100644 index 00000000..2ec1d4e8 --- /dev/null +++ b/node_graph/src/node_graph_widget_tooltips.cpp @@ -0,0 +1,145 @@ +#include "imgui.h" +#include "imnodes.h" +#include "node_graph_widget.h" +#include +#include +#include +#include + +namespace { + +void showSpectrumTooltip(const Spectrum &spec, const char *direction) { + ImGui::BeginTooltip(); + + if (spec.frequencies.empty() && spec.tones.empty()) { + ImGui::Text("%s: No signal", direction); + ImGui::EndTooltip(); + return; + } + + int num_tones = static_cast(spec.tones.size()); + if (num_tones > 0) { + double strongest_power = -std::numeric_limits::infinity(); + double strongest_freq = 0.0; + for (const auto &t : spec.tones) { + if (t.power_dBm > strongest_power) { + strongest_power = t.power_dBm; + strongest_freq = t.freq_Hz; + } + } + char buf[128]; + std::snprintf(buf, sizeof(buf), "Tones: %d | Strongest: %.3f MHz @ %.1f dBm", num_tones, + strongest_freq / 1e6, strongest_power); + ImGui::TextUnformatted(buf); + } else { + ImGui::Text("Tones: 0"); + } + + if (!spec.noise_total_W.empty()) { + double sum = 0.0; + for (double n : spec.noise_total_W) + sum += n; + double avg_W = sum / static_cast(spec.noise_total_W.size()); + double avg_dBm_per_Hz = 10.0 * std::log10(avg_W) + 30.0; + ImGui::Text("Noise floor: %.1f dBm/Hz", avg_dBm_per_Hz); + } else { + ImGui::Text("Noise floor: -- dBm/Hz"); + } + + if (!spec.frequencies.empty()) { + double f_min = spec.frequencies.front(); + double f_max = spec.frequencies.back(); + double f_center = (f_min + f_max) / 2.0; + + auto fmt_freq = [](double hz) -> std::string { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.3f MHz", hz / 1e6); + return buf; + }; + ImGui::Text("Freq range: %s - %s (center: %s)", fmt_freq(f_min).c_str(), + fmt_freq(f_max).c_str(), fmt_freq(f_center).c_str()); + } + + ImGui::EndTooltip(); +} + +} // namespace + +void NodeGraphWidget::showPinTooltips() { + int hovered_pin; + if (!ImNodes::IsPinHovered(&hovered_pin)) + return; + + for (const auto &node : m_engine.nodes()) { + const auto *signal = node.signal_node; + if (!signal) + continue; + + for (size_t i = 0; i < node.input_pin_ids.size(); ++i) { + if (node.input_pin_ids[i] != hovered_pin) + continue; + const Spectrum *spec = (i < signal->inputs.size()) ? signal->inputs[i] : nullptr; + if (spec) { + showSpectrumTooltip(*spec, "IN"); + return; + } + } + + for (size_t i = 0; i < node.output_pin_ids.size(); ++i) { + if (node.output_pin_ids[i] != hovered_pin) + continue; + const Spectrum &spec = (i < signal->outputs.size()) ? signal->outputs[i] : Spectrum(); + showSpectrumTooltip(spec, "OUT"); + return; + } + } + + // Boundary pin tooltips (synthesized pins with ids >= 100000) + if (hovered_pin >= 100000) { + auto it = m_synth_pin_to_real_pin.find(hovered_pin); + if (it != m_synth_pin_to_real_pin.end()) { + int real_pin = it->second; + for (const auto &node : m_engine.nodes()) { + for (size_t i = 0; i < node.output_pin_ids.size(); ++i) { + if (node.output_pin_ids[i] == real_pin && node.signal_node) { + if (i < node.signal_node->outputs.size()) { + showSpectrumTooltip(node.signal_node->outputs[i], "OUT"); + return; + } + } + } + for (size_t i = 0; i < node.input_pin_ids.size(); ++i) { + if (node.input_pin_ids[i] == real_pin && node.signal_node) { + const Spectrum *spec = (i < node.signal_node->inputs.size()) + ? node.signal_node->inputs[i] + : nullptr; + if (spec) { + showSpectrumTooltip(*spec, "IN"); + return; + } + } + } + } + } + } +} + +void NodeGraphWidget::showNodeHoverTooltips() { + if (!onNodeHover) + return; + + for (const auto &node : m_engine.nodes()) { + int hovered_node = -1; + if (ImNodes::IsNodeHovered(&hovered_node) && hovered_node == node.node_id) { + std::string summary = onNodeHover(node.node_id); + if (!summary.empty()) { + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::TextUnformatted(summary.c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + break; + } + } +} diff --git a/node_graph/src/schematic_symbols.cpp b/node_graph/src/schematic_symbols.cpp new file mode 100644 index 00000000..4b926958 --- /dev/null +++ b/node_graph/src/schematic_symbols.cpp @@ -0,0 +1,178 @@ +#include "imgui.h" +#include "node_graph_widget.h" +#include + +// ponytail: per-name symbol helpers are static and one-shot. + +namespace { + +static void drawGeneratorSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { + constexpr int N = 16; + constexpr float W = 30.0f, H = 12.0f; + ImVec2 pts[N]; + for (int i = 0; i < N; ++i) { + float t = static_cast(i) / (N - 1); + float x = c.x - W + 2.0f * W * t; + float y = c.y - H * std::sin(2.0f * 3.14159265f * 2.0f * t); + pts[i] = ImVec2(x, y); + } + dl->AddPolyline(pts, N, color, ImDrawFlags_None, 2.0f); +} + +static void drawAmplifierSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { + ImVec2 a(c.x - 20, c.y - 16); + ImVec2 b(c.x - 20, c.y + 16); + ImVec2 d(c.x + 20, c.y); + dl->AddTriangle(a, b, d, color, 2.0f); + dl->AddLine(ImVec2(a.x - 8, c.y - 6), ImVec2(a.x - 8, c.y - 14), color, 2.0f); + dl->AddLine(ImVec2(a.x - 12, c.y - 10), ImVec2(a.x - 4, c.y - 10), color, 2.0f); + dl->AddLine(ImVec2(a.x - 8, c.y + 6), ImVec2(a.x - 8, c.y + 14), color, 2.0f); +} + +static void drawMixerSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { + dl->AddCircle(c, 14.0f, color, 24, 2.0f); + dl->AddLine(ImVec2(c.x - 10, c.y - 10), ImVec2(c.x + 10, c.y + 10), color, 2.0f); + dl->AddLine(ImVec2(c.x - 10, c.y + 10), ImVec2(c.x + 10, c.y - 10), color, 2.0f); +} + +static void drawSplitterSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { + ImVec2 in_pt(c.x - 22, c.y); + ImVec2 mid(c.x, c.y); + ImVec2 out_a(c.x + 22, c.y - 12); + ImVec2 out_b(c.x + 22, c.y + 12); + dl->AddLine(in_pt, mid, color, 2.0f); + dl->AddLine(mid, out_a, color, 2.0f); + dl->AddLine(mid, out_b, color, 2.0f); + dl->AddCircleFilled(in_pt, 2.5f, color); + dl->AddCircleFilled(out_a, 2.5f, color); + dl->AddCircleFilled(out_b, 2.5f, color); +} + +static void drawCombinerSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { + // Reverse of splitter: 2 inputs on left, 1 output on right + ImVec2 in_a(c.x - 22, c.y - 12); + ImVec2 in_b(c.x - 22, c.y + 12); + ImVec2 mid(c.x, c.y); + ImVec2 out_pt(c.x + 22, c.y); + dl->AddLine(in_a, mid, color, 2.0f); + dl->AddLine(in_b, mid, color, 2.0f); + dl->AddLine(mid, out_pt, color, 2.0f); + dl->AddCircleFilled(in_a, 2.5f, color); + dl->AddCircleFilled(in_b, 2.5f, color); + dl->AddCircleFilled(out_pt, 2.5f, color); +} + +static void drawAdcSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { + ImVec2 pts[6] = { + ImVec2(c.x - 24, c.y + 8), ImVec2(c.x - 16, c.y + 8), ImVec2(c.x - 16, c.y - 4), + ImVec2(c.x - 8, c.y - 4), ImVec2(c.x - 8, c.y + 8), ImVec2(c.x + 24, c.y + 8), + }; + dl->AddPolyline(pts, 6, color, ImDrawFlags_None, 2.0f); +} + +static void drawFilterSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { + dl->AddCircle(ImVec2(c.x - 10, c.y), 5.0f, color, 16, 2.0f); + dl->AddCircle(ImVec2(c.x + 10, c.y), 5.0f, color, 16, 2.0f); + dl->AddLine(ImVec2(c.x - 22, c.y - 10), ImVec2(c.x - 22, c.y + 10), color, 2.0f); + dl->AddLine(ImVec2(c.x + 22, c.y - 10), ImVec2(c.x + 22, c.y + 10), color, 2.0f); + dl->AddLine(ImVec2(c.x - 22, c.y), ImVec2(c.x + 22, c.y), color, 2.0f); +} + +static void drawCoaxSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { + dl->AddCircle(c, 16.0f, color, 32, 2.0f); + dl->AddCircle(c, 8.0f, color, 24, 2.0f); +} + +static void drawEqualizerSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { + // Rising/falling slope line + dl->AddLine(ImVec2(c.x - 20, c.y + 8), ImVec2(c.x + 20, c.y - 8), color, 2.0f); + // Small reference markers + dl->AddCircleFilled(ImVec2(c.x - 14, c.y + 4), 2.0f, color); + dl->AddCircleFilled(ImVec2(c.x + 14, c.y - 4), 2.0f, color); +} + +static void drawAttenuatorSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { + // Zigzag resistor-style symbol with "ATT" label + const float x0 = c.x - 15.0f; + const float x1 = c.x + 15.0f; + const float amp = 4.0f; + dl->PathClear(); + dl->PathLineTo(ImVec2(x0, c.y)); + for (int i = 0; i < 4; ++i) { + float x = x0 + (x1 - x0) * (i + 0.5f) / 4.0f; + float yo = (i % 2 == 0) ? -amp : amp; + dl->PathLineTo(ImVec2(x, c.y + yo)); + } + dl->PathLineTo(ImVec2(x1, c.y)); + dl->PathStroke(color, 0, 2.0f); + const char *label = "ATT"; + ImVec2 ts = ImGui::CalcTextSize(label); + dl->AddText(ImVec2(c.x - ts.x * 0.5f, c.y - 14.0f), color, label); +} + +static void drawPfbSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { + for (int i = 0; i < 3; ++i) { + float y_off = (i - 1) * 8.0f; + dl->AddRect(ImVec2(c.x - 20, c.y - 4 + y_off), ImVec2(c.x + 20, c.y + 4 + y_off), color, + 0.0f, ImDrawFlags_None, 2.0f); + } + dl->AddText(ImVec2(c.x - 4, c.y - 18), color, "M"); +} + +static void drawGroupCollapsedSymbol(ImDrawList *dl, ImVec2 c, ImU32 color) { + ImVec2 a(c.x - 18, c.y - 6); + ImVec2 b(c.x, c.y); + ImVec2 d(c.x + 18, c.y + 6); + dl->AddLine(a, b, color, 2.0f); + dl->AddLine(b, d, color, 2.0f); + dl->AddCircleFilled(a, 3.0f, color); + dl->AddCircleFilled(b, 3.0f, color); + dl->AddCircleFilled(d, 3.0f, color); +} + +} // namespace + +void NodeGraphWidget::drawSchematicSymbol(ImDrawList *dl, ImVec2 center, NodeKind kind, + ImU32 color) { + switch (kind) { + case NodeKind::Generator: + drawGeneratorSymbol(dl, center, color); + break; + case NodeKind::Amplifier: + drawAmplifierSymbol(dl, center, color); + break; + case NodeKind::Splitter: + drawSplitterSymbol(dl, center, color); + break; + case NodeKind::Mixer: + drawMixerSymbol(dl, center, color); + break; + case NodeKind::Adc: + drawAdcSymbol(dl, center, color); + break; + case NodeKind::PFB: + drawPfbSymbol(dl, center, color); + break; + case NodeKind::IdealFilter: + drawFilterSymbol(dl, center, color); + break; + case NodeKind::CoaxCable: + drawCoaxSymbol(dl, center, color); + break; + case NodeKind::Equalizer: + drawEqualizerSymbol(dl, center, color); + break; + case NodeKind::Attenuator: + drawAttenuatorSymbol(dl, center, color); + break; + case NodeKind::Combiner: + drawCombinerSymbol(dl, center, color); + break; + case NodeKind::GroupCollapsed: + drawGroupCollapsedSymbol(dl, center, color); + break; + default: + // Future/unknown kinds: draw nothing silently. + break; + } +} diff --git a/pfb_channelizer/CMakeLists.txt b/pfb_channelizer/CMakeLists.txt index 953c74d6..fb93ae85 100644 --- a/pfb_channelizer/CMakeLists.txt +++ b/pfb_channelizer/CMakeLists.txt @@ -9,7 +9,6 @@ target_include_directories(pfb_channelizer_engine PUBLIC ${PROJECT_SOURCE_DIR}/i target_link_libraries(pfb_channelizer_engine PUBLIC - simulator::core simulator::node_graph_engine common )