feat(viz): graph_layouts (radial/hierarchical/fixed) + viewport multi-select+lasso (issue 0049i)

Phase 1 — graph_layouts:
- New module cpp/functions/viz/graph_layouts.{h,cpp,md} v1.0.0
- layout_grid, layout_circular, layout_random (migrated from graph_force_layout.cpp)
- layout_radial: BFS rings from root, hop k -> circle of radius k*ring_spacing
- layout_hierarchical: Sugiyama-style heuristic (longest-path levels + barycenter ordering)
- layout_fixed: no-op
- All respect NF_PINNED. graph_layout_circular/grid kept as deprecated wrappers.

Phase 2-3 — graph_viewport v1.2.0:
- Multi-selection via state.selection (vector<int>); NF_SELECTED kept in sync
- Lasso: Shift+Drag on empty area; AABB hit-test on release
- Drag of N-selection: all selected pinned + moved by mouse delta
- Ctrl+click toggle, Esc clears selection
- Right-click on node -> on_context_menu callback
- Double-click on node -> on_double_click callback
- Helpers exposed: graph_viewport_clear/add_to/toggle/is_selected (own TU for tests)

Phase 4 — tests:
- test_graph_layouts: 12 cases / 364 assertions covering geometry, pin, edges
- test_graph_viewport: 5 cases for selection helpers (pure logic, no GL)

Phase 5 — demo (primitives_gallery):
- Layout combo (force/grid/circular/radial/hierarchical/fixed) + Apply button
- Right-click popup with Pin/Unpin/Add-to-selection
- Status overlay shows [N selected] when selection non-empty
- Updated golden images

Issue moved to dev/issues/completed/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 23:42:31 +02:00
parent d09b35b533
commit 4a0750445c
24 changed files with 1187 additions and 92 deletions
+17
View File
@@ -64,6 +64,7 @@ add_fn_test(test_icon_button test_icon_button.cpp)
add_fn_test(test_graph_pack_rgba8 test_graph_pack_rgba8.cpp)
add_fn_test(test_graph_should_pause test_graph_should_pause.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_force_layout.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_layouts.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp)
# --- Issue 0049d — vertex pulling edge buffer (logica solo, sin GL) --------
@@ -80,6 +81,21 @@ add_fn_test(test_graph_sources test_graph_sources.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp)
target_link_libraries(test_graph_sources PRIVATE SQLite::SQLite3)
# --- Issue 0049i — graph_layouts (radial / hierarchical / fixed / etc) -----
add_fn_test(test_graph_layouts test_graph_layouts.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_layouts.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp)
# --- Issue 0049i — graph_viewport selection helpers (logica pura sin GL) ---
# Solo cubre graph_viewport_selection.cpp; el widget completo se prueba en
# primitives_gallery + golden image diff. graph_viewport.h incluye ImVec2,
# por eso anadimos cpp/vendor/imgui al include path.
add_fn_test(test_graph_viewport test_graph_viewport.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_viewport_selection.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp)
target_include_directories(test_graph_viewport PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../vendor/imgui)
# --- Issue 0049h — graph_force_layout_gpu (compute + spatial hash) ----------
# El test crea una ventana GLFW oculta a 4.3 core; si glfwInit/window/context
# fallan (CI sin DISPLAY, Mesa sin compute), el test SKIPea. Linkamos contra
@@ -87,6 +103,7 @@ target_link_libraries(test_graph_sources PRIVATE SQLite::SQLite3)
add_fn_test(test_graph_force_layout_gpu test_graph_force_layout_gpu.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_force_layout_gpu.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_force_layout.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_layouts.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/viz/graph_types.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../functions/gfx/gl_loader.cpp)
if(WIN32)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

+246
View File
@@ -0,0 +1,246 @@
// Unit tests for graph_layouts (issue 0049i).
// Cubre los seis layouts estaticos: grid, circular, random, radial,
// hierarchical, fixed. Verifica:
// - Centrado y geometria basica de los layouts geometricos.
// - NF_PINNED se respeta (nodos pinned no se mueven).
// - radial coloca el root en (0,0) y los hops en anillos correctos.
// - hierarchical asigna niveles por longest-path.
// - fixed es no-op.
#define CATCH_CONFIG_MAIN
#include "catch_amalgamated.hpp"
#include "viz/graph_layouts.h"
#include "viz/graph_types.h"
#include <cmath>
#include <vector>
namespace {
// Helper: construir un GraphData a partir de vectores
struct TestGraph {
std::vector<GraphNode> nodes;
std::vector<GraphEdge> edges;
GraphData data{};
void make(int N) {
nodes.clear();
edges.clear();
for (int i = 0; i < N; ++i) {
nodes.push_back(graph_node(0.0f, 0.0f));
}
sync();
}
void add_edge(uint32_t s, uint32_t t) {
edges.push_back(graph_edge(s, t));
sync();
}
void sync() {
data.nodes = nodes.data();
data.node_count = (int)nodes.size();
data.node_capacity = (int)nodes.capacity();
data.edges = edges.data();
data.edge_count = (int)edges.size();
data.edge_capacity = (int)edges.capacity();
data.types = nullptr; data.type_count = 0;
data.rel_types = nullptr; data.rel_type_count = 0;
}
};
} // namespace
TEST_CASE("layout_grid respects pin and centers", "[viz][layouts][grid]") {
TestGraph g; g.make(9);
// Pin nodo 0 con posicion deliberadamente fuera de la cuadricula.
g.nodes[0].x = 999.0f;
g.nodes[0].y = 999.0f;
g.nodes[0].flags |= NF_PINNED;
g.sync();
graph::layout_grid(g.data, 10.0f);
// Pinned no se ha movido
REQUIRE(g.nodes[0].x == Catch::Approx(999.0f));
REQUIRE(g.nodes[0].y == Catch::Approx(999.0f));
// Resto: 9 nodos con cols=3, rows=3 -> centro en (0,0)
// El nodo 4 esta en (col=1, row=1) -> exactamente (0,0)
REQUIRE(g.nodes[4].x == Catch::Approx(0.0f));
REQUIRE(g.nodes[4].y == Catch::Approx(0.0f));
// Velocidades a cero
REQUIRE(g.nodes[1].vx == 0.0f);
REQUIRE(g.nodes[1].vy == 0.0f);
}
TEST_CASE("layout_circular places nodes on circle", "[viz][layouts][circular]") {
TestGraph g; g.make(8);
graph::layout_circular(g.data, 50.0f);
for (int i = 0; i < 8; ++i) {
float r = std::sqrt(g.nodes[i].x * g.nodes[i].x +
g.nodes[i].y * g.nodes[i].y);
REQUIRE(r == Catch::Approx(50.0f).margin(1e-3f));
}
}
TEST_CASE("layout_circular respects NF_PINNED", "[viz][layouts][circular]") {
TestGraph g; g.make(4);
g.nodes[2].x = 7.0f;
g.nodes[2].y = -3.0f;
g.nodes[2].flags |= NF_PINNED;
g.sync();
graph::layout_circular(g.data, 100.0f);
REQUIRE(g.nodes[2].x == Catch::Approx(7.0f));
REQUIRE(g.nodes[2].y == Catch::Approx(-3.0f));
}
TEST_CASE("layout_random keeps positions in [-spread, spread]", "[viz][layouts][random]") {
TestGraph g; g.make(50);
graph::layout_random(g.data, 25.0f);
for (int i = 0; i < g.data.node_count; ++i) {
REQUIRE(g.nodes[i].x >= -25.0f);
REQUIRE(g.nodes[i].x <= 25.0f);
REQUIRE(g.nodes[i].y >= -25.0f);
REQUIRE(g.nodes[i].y <= 25.0f);
REQUIRE(g.nodes[i].vx == 0.0f);
REQUIRE(g.nodes[i].vy == 0.0f);
}
}
TEST_CASE("layout_radial root at center, neighbors on first ring", "[viz][layouts][radial]") {
// Estrella de 5 puntas: nodo 0 -> nodos 1..5
TestGraph g; g.make(6);
for (int i = 1; i <= 5; ++i) g.add_edge(0, i);
graph::layout_radial(g.data, /*root=*/0, /*ring=*/100.0f);
// root en (0,0)
REQUIRE(g.nodes[0].x == Catch::Approx(0.0f).margin(1e-3f));
REQUIRE(g.nodes[0].y == Catch::Approx(0.0f).margin(1e-3f));
// Vecinos a distancia ~100
for (int i = 1; i <= 5; ++i) {
float r = std::sqrt(g.nodes[i].x * g.nodes[i].x +
g.nodes[i].y * g.nodes[i].y);
REQUIRE(r == Catch::Approx(100.0f).margin(1e-3f));
}
}
TEST_CASE("layout_radial places hop2 on second ring", "[viz][layouts][radial]") {
// Cadena: 0 -> 1 -> 2 -> 3
TestGraph g; g.make(4);
g.add_edge(0, 1);
g.add_edge(1, 2);
g.add_edge(2, 3);
graph::layout_radial(g.data, 0, 50.0f);
auto radius = [&](int i) {
return std::sqrt(g.nodes[i].x * g.nodes[i].x +
g.nodes[i].y * g.nodes[i].y);
};
REQUIRE(radius(0) == Catch::Approx(0.0f).margin(1e-3f));
REQUIRE(radius(1) == Catch::Approx(50.0f).margin(1e-3f));
REQUIRE(radius(2) == Catch::Approx(100.0f).margin(1e-3f));
REQUIRE(radius(3) == Catch::Approx(150.0f).margin(1e-3f));
}
TEST_CASE("layout_radial respects pin", "[viz][layouts][radial]") {
TestGraph g; g.make(3);
g.add_edge(0, 1);
g.add_edge(0, 2);
g.nodes[1].x = 42.0f; g.nodes[1].y = 7.0f;
g.nodes[1].flags |= NF_PINNED;
g.sync();
graph::layout_radial(g.data, 0, 50.0f);
REQUIRE(g.nodes[1].x == Catch::Approx(42.0f));
REQUIRE(g.nodes[1].y == Catch::Approx(7.0f));
}
TEST_CASE("layout_hierarchical levels by longest path (LR)", "[viz][layouts][hierarchical]") {
// DAG: 0 -> 1, 0 -> 2, 1 -> 3, 2 -> 3 (diamond)
// longest-path levels: 0=0, 1=1, 2=1, 3=2
TestGraph g; g.make(4);
g.add_edge(0, 1);
g.add_edge(0, 2);
g.add_edge(1, 3);
g.add_edge(2, 3);
graph::layout_hierarchical(g.data, /*direction=*/0,
/*layer=*/100.0f,
/*node=*/40.0f);
// Nivel 0 (nodo 0) en x=0
REQUIRE(g.nodes[0].x == Catch::Approx(0.0f).margin(1e-3f));
// Nivel 1 (nodos 1,2) en x=100
REQUIRE(g.nodes[1].x == Catch::Approx(100.0f).margin(1e-3f));
REQUIRE(g.nodes[2].x == Catch::Approx(100.0f).margin(1e-3f));
// Nivel 2 (nodo 3) en x=200
REQUIRE(g.nodes[3].x == Catch::Approx(200.0f).margin(1e-3f));
}
TEST_CASE("layout_hierarchical TB swaps axes", "[viz][layouts][hierarchical]") {
TestGraph g; g.make(3);
g.add_edge(0, 1);
g.add_edge(1, 2);
graph::layout_hierarchical(g.data, /*direction=*/2,
/*layer=*/50.0f,
/*node=*/30.0f);
// TB: y crece con el nivel, x es transverse
REQUIRE(g.nodes[0].y == Catch::Approx(0.0f).margin(1e-3f));
REQUIRE(g.nodes[1].y == Catch::Approx(50.0f).margin(1e-3f));
REQUIRE(g.nodes[2].y == Catch::Approx(100.0f).margin(1e-3f));
}
TEST_CASE("layout_hierarchical respects pin", "[viz][layouts][hierarchical]") {
TestGraph g; g.make(3);
g.add_edge(0, 1);
g.add_edge(1, 2);
g.nodes[1].x = -999.0f; g.nodes[1].y = 555.0f;
g.nodes[1].flags |= NF_PINNED;
g.sync();
graph::layout_hierarchical(g.data, 0, 100.0f, 40.0f);
REQUIRE(g.nodes[1].x == Catch::Approx(-999.0f));
REQUIRE(g.nodes[1].y == Catch::Approx(555.0f));
}
TEST_CASE("layout_fixed is a no-op", "[viz][layouts][fixed]") {
TestGraph g; g.make(5);
for (int i = 0; i < 5; ++i) {
g.nodes[i].x = (float)(i * 10);
g.nodes[i].y = (float)(i * -3);
g.nodes[i].vx = 0.5f;
g.nodes[i].vy = -0.5f;
}
g.sync();
graph::layout_fixed(g.data);
for (int i = 0; i < 5; ++i) {
REQUIRE(g.nodes[i].x == Catch::Approx((float)(i * 10)));
REQUIRE(g.nodes[i].y == Catch::Approx((float)(i * -3)));
REQUIRE(g.nodes[i].vx == Catch::Approx(0.5f));
REQUIRE(g.nodes[i].vy == Catch::Approx(-0.5f));
}
}
TEST_CASE("layouts handle empty graph", "[viz][layouts][empty]") {
TestGraph g; g.make(0);
REQUIRE_NOTHROW(graph::layout_grid(g.data, 10.0f));
REQUIRE_NOTHROW(graph::layout_circular(g.data, 10.0f));
REQUIRE_NOTHROW(graph::layout_random(g.data, 10.0f));
REQUIRE_NOTHROW(graph::layout_radial(g.data, 0, 10.0f));
REQUIRE_NOTHROW(graph::layout_hierarchical(g.data, 0, 10.0f, 10.0f));
REQUIRE_NOTHROW(graph::layout_fixed(g.data));
}
+104
View File
@@ -0,0 +1,104 @@
// Smoke tests for graph_viewport (issue 0049i).
// El widget completo necesita un contexto OpenGL + ImGui — eso vive en el
// demo y en el test visual. Aqui solo cubrimos los helpers de seleccion
// (logica pura sobre GraphData / GraphViewportState) que no dependen de GL
// ni de ImGui frame state.
#define CATCH_CONFIG_MAIN
#include "catch_amalgamated.hpp"
#include "viz/graph_viewport.h"
#include "viz/graph_types.h"
#include <vector>
namespace {
struct TG {
std::vector<GraphNode> nodes;
GraphData data{};
void make(int N) {
nodes.clear();
for (int i = 0; i < N; ++i) nodes.push_back(graph_node());
data.nodes = nodes.data();
data.node_count = (int)nodes.size();
data.node_capacity = (int)nodes.capacity();
data.edges = nullptr; data.edge_count = 0; data.edge_capacity = 0;
data.types = nullptr; data.type_count = 0;
data.rel_types = nullptr; data.rel_type_count = 0;
}
};
} // namespace
TEST_CASE("selection: add sets NF_SELECTED and selected_node", "[viewport][selection]") {
TG g; g.make(5);
GraphViewportState st;
graph_viewport_add_to_selection(g.data, st, 2);
REQUIRE(st.selection.size() == 1);
REQUIRE(st.selection[0] == 2);
REQUIRE(st.selected_node == 2);
REQUIRE((g.nodes[2].flags & NF_SELECTED) != 0);
// Anadir el mismo no duplica
graph_viewport_add_to_selection(g.data, st, 2);
REQUIRE(st.selection.size() == 1);
graph_viewport_add_to_selection(g.data, st, 4);
REQUIRE(st.selection.size() == 2);
REQUIRE(st.selected_node == 4);
}
TEST_CASE("selection: clear unsets all NF_SELECTED", "[viewport][selection]") {
TG g; g.make(3);
GraphViewportState st;
graph_viewport_add_to_selection(g.data, st, 0);
graph_viewport_add_to_selection(g.data, st, 1);
graph_viewport_add_to_selection(g.data, st, 2);
graph_viewport_clear_selection(g.data, st);
REQUIRE(st.selection.empty());
REQUIRE(st.selected_node == -1);
for (int i = 0; i < 3; ++i) {
REQUIRE((g.nodes[i].flags & NF_SELECTED) == 0);
}
}
TEST_CASE("selection: toggle adds then removes", "[viewport][selection]") {
TG g; g.make(3);
GraphViewportState st;
graph_viewport_toggle_selection(g.data, st, 1);
REQUIRE(graph_viewport_is_selected(st, 1));
REQUIRE((g.nodes[1].flags & NF_SELECTED) != 0);
graph_viewport_toggle_selection(g.data, st, 1);
REQUIRE_FALSE(graph_viewport_is_selected(st, 1));
REQUIRE((g.nodes[1].flags & NF_SELECTED) == 0);
REQUIRE(st.selected_node == -1);
}
TEST_CASE("selection: out-of-range indices are ignored", "[viewport][selection]") {
TG g; g.make(2);
GraphViewportState st;
graph_viewport_add_to_selection(g.data, st, -1);
graph_viewport_add_to_selection(g.data, st, 5);
graph_viewport_toggle_selection(g.data, st, -3);
REQUIRE(st.selection.empty());
REQUIRE(st.selected_node == -1);
}
TEST_CASE("selection: is_selected reports membership", "[viewport][selection]") {
TG g; g.make(4);
GraphViewportState st;
graph_viewport_add_to_selection(g.data, st, 0);
graph_viewport_add_to_selection(g.data, st, 3);
REQUIRE(graph_viewport_is_selected(st, 0));
REQUIRE_FALSE(graph_viewport_is_selected(st, 1));
REQUIRE_FALSE(graph_viewport_is_selected(st, 2));
REQUIRE(graph_viewport_is_selected(st, 3));
}