Files
fn_registry/cpp/functions/gfx/dag_node_editor.cpp
T
egutierrez b093c898a8 docs(issues): marcar 0025 y 0026 como completados + WIP master
Wave 1 de parallel-fix-issues integrada a master:
- 0025: text_editor_cpp_core + file_watcher_cpp_core
- 0026: gl_texture_load_cpp_gfx (vendor: stb_image v2.30)

Ademas se commitea WIP previo de master que estaba sin commitear (cambios
en shaders_lab, dag_*, framework, tokens, kpi_card, gl_loader.md, etc.)
para dejar HEAD buildable.

Notas:
- Algunos deps del gallery (button.cpp, toolbar.cpp, modal_dialog.cpp...)
  siguen UNTRACKED — gating con FN_BUILD_GALLERY=ON (default OFF) para
  que master build (sin flag) no los necesite.
- Build OK con y sin flag. fn index registra 904 functions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:14:15 +02:00

851 lines
38 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "gfx/dag_node_editor.h"
#include "gfx/dag_catalog.h"
#include "gfx/dag_node_previews.h"
#include "imgui.h"
#include "imgui_node_editor.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <functional>
#include <queue>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace ed = ax::NodeEditor;
namespace fn::gfx {
static constexpr int MAX_NODES = 16;
static ed::EditorContext* s_ctx = nullptr;
static uint32_t s_next_uid = 1;
static std::unordered_set<uint32_t> s_positioned;
// Real pin positions in canvas space, captured during node draw and consulted
// by the splice hit-test. Without this, a cable's hit zone is offset from the
// visible cable whenever node height ≠ pin row height (e.g. preview open).
static std::unordered_map<uintptr_t, ImVec2> s_pin_canvas_pos;
// ── ID encoding ──────────────────────────────────────────────────────────────
// node id = editor_uid
// output pin id = (editor_uid << 8) | 0
// input pin id = (editor_uid << 8) | (slot + 1) slot 0..3
// link id = (from_node_uid << 20) | (to_node_uid << 8) | slot
static uintptr_t node_id(uint32_t uid) {
return static_cast<uintptr_t>(uid);
}
static uintptr_t output_pin_id(uint32_t uid) {
return (static_cast<uintptr_t>(uid) << 8) | 0u;
}
static uintptr_t input_pin_id(uint32_t uid, int slot) {
return (static_cast<uintptr_t>(uid) << 8) | static_cast<uintptr_t>(slot + 1);
}
static uintptr_t link_id(uint32_t from_uid, uint32_t to_uid, int slot) {
return (static_cast<uintptr_t>(from_uid) << 20)
| (static_cast<uintptr_t>(to_uid) << 8)
| static_cast<uintptr_t>(slot);
}
// Decode pin id back
static bool is_output_pin(uintptr_t id) { return (id & 0xFF) == 0; }
static uint32_t uid_from_pin(uintptr_t id) { return static_cast<uint32_t>(id >> 8); }
static int slot_from_input_pin(uintptr_t id) { return static_cast<int>(id & 0xFF) - 1; }
// Closest distance from point p to the segment [a, b] (canvas space).
static float dist_point_to_segment(ImVec2 p, ImVec2 a, ImVec2 b) {
float abx = b.x - a.x, aby = b.y - a.y;
float apx = p.x - a.x, apy = p.y - a.y;
float ab2 = abx * abx + aby * aby;
if (ab2 <= 1e-6f) return std::sqrt(apx * apx + apy * apy);
float t = (apx * abx + apy * aby) / ab2;
t = std::max(0.0f, std::min(1.0f, t));
float qx = a.x + t * abx, qy = a.y + t * aby;
float dx = p.x - qx, dy = p.y - qy;
return std::sqrt(dx * dx + dy * dy);
}
// Same horizontal-bias control offset imgui-node-editor uses for its links.
static inline float bezier_ctrl(float dx) {
return std::max(40.0f, std::abs(dx) * 0.5f);
}
// Closest distance from point p to a cubic bezier curve, sampled as 24 chords.
static float dist_point_to_bezier(ImVec2 p, ImVec2 p0, ImVec2 p1, ImVec2 p2, ImVec2 p3) {
constexpr int N = 24;
float best = 1e30f;
ImVec2 prev = p0;
for (int i = 1; i <= N; ++i) {
float t = static_cast<float>(i) / static_cast<float>(N);
float u = 1.0f - t;
float b0 = u * u * u;
float b1 = 3.0f * u * u * t;
float b2 = 3.0f * u * t * t;
float b3 = t * t * t;
ImVec2 pt(b0 * p0.x + b1 * p1.x + b2 * p2.x + b3 * p3.x,
b0 * p0.y + b1 * p1.y + b2 * p2.y + b3 * p3.y);
float d = dist_point_to_segment(p, prev, pt);
if (d < best) best = d;
prev = pt;
}
return best;
}
static int find_by_uid(const std::vector<DagStep>& p, uint32_t uid) {
for (int i = 0; i < static_cast<int>(p.size()); ++i) {
if (p[static_cast<size_t>(i)].editor_uid == uid) return i;
}
return -1;
}
static int find_by_id(const std::vector<DagStep>& p, const std::string& id) {
for (int i = 0; i < static_cast<int>(p.size()); ++i) {
if (p[static_cast<size_t>(i)].id == id) return i;
}
return -1;
}
static ImVec4 kind_color(DagKind kind) {
switch (kind) {
case DagKind::Gen: return ImVec4(0.25f, 0.55f, 0.90f, 1.0f);
case DagKind::Op: return ImVec4(0.65f, 0.40f, 0.90f, 1.0f);
case DagKind::Blend: return ImVec4(0.90f, 0.65f, 0.15f, 1.0f);
case DagKind::Output: return ImVec4(0.85f, 0.25f, 0.25f, 1.0f);
}
return ImVec4(1, 1, 1, 1);
}
static constexpr float PIN_RADIUS = 14.0f; // big grabbable target
static constexpr float PIN_DIAMETER = PIN_RADIUS * 2.0f;
static constexpr float CONTROL_WIDTH = 220.0f;
static constexpr float COL_GAP = 14.0f; // input ↔ controls ↔ output gap
static constexpr float CABLE_THICK = 3.5f;
static const ImVec4 PIN_COLOR = ImVec4(0.78f, 0.78f, 0.82f, 1.0f);
static const ImVec4 PIN_BORDER = ImVec4(0.20f, 0.20f, 0.22f, 1.0f);
static const ImVec4 SPLICE_COLOR = ImVec4(1.00f, 0.82f, 0.18f, 1.0f); // golden preview cable
enum class PinSide { Input, Output };
// Draws a filled circle straddling the node's edge (half outside, half inside)
// and reserves only the inside half (PIN_RADIUS × PIN_DIAMETER) so the layout
// stays compact. The full circle is set as the pin's hit rect via ed::PinRect
// so the user can grab the protruding half.
static void draw_pin_circle(PinSide side) {
ImVec2 cursor = ImGui::GetCursorScreenPos();
float center_x = (side == PinSide::Input) ? cursor.x : cursor.x + PIN_RADIUS;
ImVec2 center(center_x, cursor.y + PIN_RADIUS);
ed::PinRect(ImVec2(center.x - PIN_RADIUS, center.y - PIN_RADIUS),
ImVec2(center.x + PIN_RADIUS, center.y + PIN_RADIUS));
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddCircleFilled(center, PIN_RADIUS, ImGui::ColorConvertFloat4ToU32(PIN_COLOR));
dl->AddCircle(center, PIN_RADIUS, ImGui::ColorConvertFloat4ToU32(PIN_BORDER), 0, 2.0f);
ImGui::Dummy(ImVec2(PIN_RADIUS, PIN_DIAMETER));
}
// Topological sort using Kahn's algorithm.
// Returns false if a cycle is detected (should not happen — BeginCreate rejects cycles).
static bool topo_sort(std::vector<DagStep>& pipeline) {
int n = static_cast<int>(pipeline.size());
if (n == 0) return true;
// Build adjacency: edge from src -> dst if dst.source_ids[k] == src.id
std::unordered_map<std::string, int> id_to_idx;
for (int i = 0; i < n; ++i) id_to_idx[pipeline[static_cast<size_t>(i)].id] = i;
std::vector<int> in_degree(static_cast<size_t>(n), 0);
std::vector<std::vector<int>> adj(static_cast<size_t>(n));
for (int i = 0; i < n; ++i) {
const DagStep& s = pipeline[static_cast<size_t>(i)];
const DagNodeDef* def = dag_find(s.name);
int ni = def ? def->num_inputs : 0;
for (int k = 0; k < ni; ++k) {
const std::string& sid = s.source_ids[static_cast<size_t>(k)];
if (sid.empty()) continue;
auto it = id_to_idx.find(sid);
if (it == id_to_idx.end()) continue;
int src = it->second;
adj[static_cast<size_t>(src)].push_back(i);
in_degree[static_cast<size_t>(i)]++;
}
}
std::queue<int> q;
for (int i = 0; i < n; ++i) {
if (in_degree[static_cast<size_t>(i)] == 0) q.push(i);
}
std::vector<int> order;
order.reserve(static_cast<size_t>(n));
while (!q.empty()) {
int u = q.front(); q.pop();
order.push_back(u);
for (int v : adj[static_cast<size_t>(u)]) {
if (--in_degree[static_cast<size_t>(v)] == 0) q.push(v);
}
}
if (static_cast<int>(order.size()) != n) return false; // cycle
std::vector<DagStep> sorted;
sorted.reserve(static_cast<size_t>(n));
for (int idx : order) sorted.push_back(pipeline[static_cast<size_t>(idx)]);
// Force Output nodes to the back so all their dependencies live at smaller
// indices (compiler and cycle validator search strictly before the target).
std::stable_partition(sorted.begin(), sorted.end(), [](const DagStep& s) {
const DagNodeDef* d = dag_find(s.name);
return !(d && d->kind == DagKind::Output);
});
pipeline = std::move(sorted);
return true;
}
bool dag_node_editor(std::vector<DagStep>& pipeline) {
bool changed = false;
// Ensure UIDs are assigned (e.g. for nodes created before this editor was active)
for (auto& step : pipeline) {
if (step.editor_uid == 0) {
step.editor_uid = s_next_uid++;
}
}
// Create context on first call
if (!s_ctx) {
ed::Config cfg;
cfg.SettingsFile = nullptr; // no disk persistence
s_ctx = ed::CreateEditor(&cfg);
}
// Palette drop: detect without a capturing target (an InvisibleButton would
// steal clicks from the node editor). Observe the active drag-drop payload
// and, if the mouse is over this window and the user releases LMB, queue an
// add at that canvas position.
static std::string s_pending_add_name;
static ImVec2 s_pending_add_pos(0, 0);
static bool s_pending_add = false;
const bool window_hovered = ImGui::IsWindowHovered(
ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem);
if (window_hovered) {
if (const ImGuiPayload* p = ImGui::GetDragDropPayload()) {
if (p->IsDataType("DAG_NODE_TYPE") && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
s_pending_add_name.assign(static_cast<const char*>(p->Data),
static_cast<size_t>(p->DataSize));
s_pending_add_pos = ImGui::GetMousePos();
s_pending_add = true;
}
}
}
ed::SetCurrentEditor(s_ctx);
ed::Begin("dag_editor", ImVec2(0.0f, 0.0f));
if (s_pending_add) {
const DagNodeDef* def = dag_find(s_pending_add_name);
if (def) {
ImVec2 drop = ed::ScreenToCanvas(s_pending_add_pos);
// ── Priority 1: drop on an existing cable → splice (src → new → dst).
// Only valid if the new def actually has an input pin (Op / Blend).
int splice_src_idx = -1, splice_dst_idx = -1, splice_slot = -1;
if (def->num_inputs >= 1 && def->kind != DagKind::Output) {
constexpr float HIT_THRESH = 14.0f; // canvas px
float best_d = HIT_THRESH;
for (size_t i = 0; i < pipeline.size(); ++i) {
const DagStep& dst = pipeline[i];
const DagNodeDef* dd = dag_find(dst.name);
if (!dd) continue;
for (int k = 0; k < dd->num_inputs; ++k) {
const std::string& sid = dst.source_ids[static_cast<size_t>(k)];
if (sid.empty()) continue;
int src_idx = find_by_id(pipeline, sid);
if (src_idx < 0) continue;
const DagStep& src = pipeline[static_cast<size_t>(src_idx)];
auto out_it = s_pin_canvas_pos.find(output_pin_id(src.editor_uid));
auto in_it = s_pin_canvas_pos.find(input_pin_id(dst.editor_uid, k));
if (out_it == s_pin_canvas_pos.end() ||
in_it == s_pin_canvas_pos.end()) continue;
ImVec2 A = out_it->second;
ImVec2 B = in_it->second;
float ctrl = bezier_ctrl(B.x - A.x);
ImVec2 P1(A.x + ctrl, A.y);
ImVec2 P2(B.x - ctrl, B.y);
float d = dist_point_to_bezier(drop, A, P1, P2, B);
if (d < best_d) {
best_d = d;
splice_src_idx = src_idx;
splice_dst_idx = static_cast<int>(i);
splice_slot = k;
}
}
}
}
// ── Priority 2: drop on an existing node of the same kind → replace.
int hit_idx = -1;
if (splice_dst_idx < 0) {
for (size_t i = 0; i < pipeline.size(); ++i) {
auto npos = ed::GetNodePosition(ed::NodeId(node_id(pipeline[i].editor_uid)));
auto nsz = ed::GetNodeSize (ed::NodeId(node_id(pipeline[i].editor_uid)));
if (drop.x >= npos.x && drop.x <= npos.x + nsz.x &&
drop.y >= npos.y && drop.y <= npos.y + nsz.y) {
hit_idx = static_cast<int>(i);
break;
}
}
}
const DagNodeDef* hit_def = (hit_idx >= 0)
? dag_find(pipeline[static_cast<size_t>(hit_idx)].name) : nullptr;
if (splice_dst_idx >= 0 && static_cast<int>(pipeline.size()) < MAX_NODES) {
// Splice: build the new node wired to the existing source, then
// rewire the existing destination's input to point to it.
const std::string src_id = pipeline[static_cast<size_t>(splice_src_idx)].id;
const std::string dst_id = pipeline[static_cast<size_t>(splice_dst_idx)].id;
uint32_t uid = s_next_uid++;
DagStep step;
step.id = "n" + std::to_string(uid);
step.name = def->name;
step.params = def->param_defaults;
step.editor_uid = uid;
step.editor_pos_x = drop.x;
step.editor_pos_y = drop.y;
step.source_ids[0] = src_id; // wire src → new
auto insert_it = pipeline.end();
for (auto it = pipeline.begin(); it != pipeline.end(); ++it) {
const DagNodeDef* d = dag_find(it->name);
if (d && d->kind == DagKind::Output) { insert_it = it; break; }
}
pipeline.insert(insert_it, step);
// Re-find dst by id (insertion may have shifted indices) and
// rewire its slot to the new node.
int dst_now = find_by_id(pipeline, dst_id);
if (dst_now >= 0) {
pipeline[static_cast<size_t>(dst_now)].source_ids[static_cast<size_t>(splice_slot)] = step.id;
}
changed = true;
} else if (hit_def && hit_def->kind == def->kind && def->kind != DagKind::Output) {
// Replace path: same-kind node hit. Keep id, editor_uid, pos,
// source_ids, preview_open. Reset params + clear stale input
// slots beyond the new def's input count.
DagStep& tgt = pipeline[static_cast<size_t>(hit_idx)];
tgt.name = def->name;
tgt.params = def->param_defaults;
for (int k = def->num_inputs; k < 4; ++k) {
tgt.source_ids[static_cast<size_t>(k)].clear();
}
changed = true;
} else if (static_cast<int>(pipeline.size()) < MAX_NODES) {
// Add path: brand-new node, inserted before Output so the sink stays last.
uint32_t uid = s_next_uid++;
DagStep step;
step.id = "n" + std::to_string(uid);
step.name = def->name;
step.params = def->param_defaults;
step.editor_uid = uid;
step.editor_pos_x = drop.x;
step.editor_pos_y = drop.y;
auto insert_it = pipeline.end();
for (auto it = pipeline.begin(); it != pipeline.end(); ++it) {
const DagNodeDef* d = dag_find(it->name);
if (d && d->kind == DagKind::Output) { insert_it = it; break; }
}
pipeline.insert(insert_it, step);
changed = true;
}
}
s_pending_add = false;
}
// ── Live splice candidate detection ─────────────────────────────────────
// The user can splice into a cable in two ways:
// (a) drag a node from the palette → ImGui drag-drop payload.
// (b) drag an existing node by its body → tracked via mouse-down on a node.
// In both cases, while the drag is active we hit-test against existing
// cables and remember the candidate so:
// 1. the link-drawing pass below paints it in SPLICE_COLOR (preview).
// 2. the release handler farther down rewires the graph.
static uint32_t s_drag_existing_uid = 0;
// Start tracking an existing-node drag when the user mouse-down on a node
// body (not on a pin, not on the Output sink, must have at least one input).
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ed::PinId hp = ed::GetHoveredPin();
ed::NodeId hn = ed::GetHoveredNode();
if (hp.Get() == 0 && hn.Get() != 0) {
uint32_t uid = static_cast<uint32_t>(hn.Get());
int idx = find_by_uid(pipeline, uid);
if (idx >= 0) {
const DagNodeDef* d = dag_find(pipeline[static_cast<size_t>(idx)].name);
if (d && d->num_inputs >= 1 && d->kind != DagKind::Output) {
s_drag_existing_uid = uid;
}
}
}
}
// Resolve which definition (if any) is the active splice candidate.
const DagNodeDef* candidate_def = nullptr;
const std::string* exclude_node_id = nullptr;
int exclude_node_idx = -1;
if (const ImGuiPayload* p = ImGui::GetDragDropPayload()) {
if (p->IsDataType("DAG_NODE_TYPE")) {
std::string drag_name(static_cast<const char*>(p->Data),
static_cast<size_t>(p->DataSize));
candidate_def = dag_find(drag_name);
}
}
if (!candidate_def && s_drag_existing_uid != 0) {
exclude_node_idx = find_by_uid(pipeline, s_drag_existing_uid);
if (exclude_node_idx >= 0) {
candidate_def = dag_find(pipeline[static_cast<size_t>(exclude_node_idx)].name);
exclude_node_id = &pipeline[static_cast<size_t>(exclude_node_idx)].id;
}
}
// Hit-test cables against current mouse position (canvas space).
uint32_t splice_hl_from_uid = 0;
uint32_t splice_hl_to_uid = 0;
int splice_hl_slot = -1;
if (candidate_def && candidate_def->num_inputs >= 1
&& candidate_def->kind != DagKind::Output) {
ImVec2 cur = ed::ScreenToCanvas(ImGui::GetMousePos());
constexpr float HIT_THRESH = 16.0f;
float best_d = HIT_THRESH;
for (size_t i = 0; i < pipeline.size(); ++i) {
const DagStep& dst = pipeline[i];
// Skip cables that touch the moving node (its own in/out edges).
if (exclude_node_id && dst.id == *exclude_node_id) continue;
const DagNodeDef* dd = dag_find(dst.name);
if (!dd) continue;
for (int k = 0; k < dd->num_inputs; ++k) {
const std::string& sid = dst.source_ids[static_cast<size_t>(k)];
if (sid.empty()) continue;
if (exclude_node_id && sid == *exclude_node_id) continue;
int src_idx = find_by_id(pipeline, sid);
if (src_idx < 0) continue;
const DagStep& src = pipeline[static_cast<size_t>(src_idx)];
auto out_it = s_pin_canvas_pos.find(output_pin_id(src.editor_uid));
auto in_it = s_pin_canvas_pos.find(input_pin_id(dst.editor_uid, k));
if (out_it == s_pin_canvas_pos.end() ||
in_it == s_pin_canvas_pos.end()) continue;
ImVec2 A = out_it->second;
ImVec2 B = in_it->second;
float ctrl = bezier_ctrl(B.x - A.x);
ImVec2 P1(A.x + ctrl, A.y);
ImVec2 P2(B.x - ctrl, B.y);
float d = dist_point_to_bezier(cur, A, P1, P2, B);
if (d < best_d) {
best_d = d;
splice_hl_from_uid = src.editor_uid;
splice_hl_to_uid = dst.editor_uid;
splice_hl_slot = k;
}
}
}
}
// Release handler for the existing-node-drag splice. Palette splice goes
// through s_pending_add above; this branch handles "drag node body onto cable".
if (ImGui::IsMouseReleased(ImGuiMouseButton_Left) && s_drag_existing_uid != 0) {
uint32_t moving_uid = s_drag_existing_uid;
s_drag_existing_uid = 0;
if (splice_hl_to_uid != 0) {
int mv_idx = find_by_uid(pipeline, moving_uid);
int src_idx = find_by_uid(pipeline, splice_hl_from_uid);
int dst_idx = find_by_uid(pipeline, splice_hl_to_uid);
if (mv_idx >= 0 && src_idx >= 0 && dst_idx >= 0) {
const std::string moving_id = pipeline[static_cast<size_t>(mv_idx)].id;
const std::string src_id = pipeline[static_cast<size_t>(src_idx)].id;
// Detach moving node from any existing consumer.
for (auto& s : pipeline) {
for (auto& sid : s.source_ids) {
if (sid == moving_id) sid.clear();
}
}
pipeline[static_cast<size_t>(mv_idx)].source_ids[0] = src_id;
pipeline[static_cast<size_t>(dst_idx)].source_ids[static_cast<size_t>(splice_hl_slot)] = moving_id;
changed = true;
}
}
}
// ── Draw nodes ───────────────────────────────────────────────────────────
for (int i = 0; i < static_cast<int>(pipeline.size()); ++i) {
DagStep& step = pipeline[static_cast<size_t>(i)];
const DagNodeDef* def = dag_find(step.name);
if (!def) continue;
ImVec4 col = kind_color(def->kind);
// Initial position only — after that, the editor owns the node's position
// and user drags must not be overwritten.
if (s_positioned.find(step.editor_uid) == s_positioned.end()) {
if (step.editor_pos_x == 0.0f && step.editor_pos_y == 0.0f) {
step.editor_pos_x = 50.0f + static_cast<float>(i) * 320.0f;
step.editor_pos_y = 100.0f;
}
ed::SetNodePosition(ed::NodeId(node_id(step.editor_uid)),
ImVec2(step.editor_pos_x, step.editor_pos_y));
s_positioned.insert(step.editor_uid);
}
// Zero lateral padding so the input/output pin circles sit flush
// with the node's left and right edges.
ed::PushStyleVar(ed::StyleVar_NodePadding, ImVec4(0, 12, 0, 12));
ed::BeginNode(ed::NodeId(node_id(step.editor_uid)));
// Header (with horizontal padding so the title doesn't touch the edge)
ImGui::Dummy(ImVec2(8, 0));
ImGui::SameLine(0, 0);
ImGui::PushStyleColor(ImGuiCol_Text, col);
ImGui::TextUnformatted(def->label.c_str());
ImGui::PopStyleColor();
ImGui::Dummy(ImVec2(0, 4));
int ni = def->num_inputs;
bool has_output_pin = (def->kind != DagKind::Output);
// ── Three-column layout: inputs · controls · output ──────────
ImGui::BeginGroup(); // inputs column (left edge)
if (ni == 0) {
ImGui::Dummy(ImVec2(PIN_RADIUS, PIN_DIAMETER));
}
for (int k = 0; k < ni; ++k) {
ed::BeginPin(ed::PinId(input_pin_id(step.editor_uid, k)), ed::PinKind::Input);
ed::PinPivotAlignment(ImVec2(0.0f, 0.5f));
ed::PinPivotSize(ImVec2(0, 0));
ImVec2 cur_screen = ImGui::GetCursorScreenPos();
ImVec2 center_screen(cur_screen.x, cur_screen.y + PIN_RADIUS);
s_pin_canvas_pos[input_pin_id(step.editor_uid, k)] =
ed::ScreenToCanvas(center_screen);
draw_pin_circle(PinSide::Input);
ed::EndPin();
}
ImGui::EndGroup();
ImGui::SameLine(0, COL_GAP); // gap between pin column and controls
ImGui::BeginGroup(); // controls column (centre, with internal padding)
ImGui::PushID(static_cast<int>(step.editor_uid));
if (def->controls.empty() && def->kind != DagKind::Output) {
ImGui::Dummy(ImVec2(CONTROL_WIDTH * 0.5f, PIN_DIAMETER));
}
for (size_t ci = 0; ci < def->controls.size(); ++ci) {
const DagControl& ctrl = def->controls[ci];
ImGui::SetNextItemWidth(CONTROL_WIDTH);
char uid_lbl[64];
std::snprintf(uid_lbl, sizeof(uid_lbl), "%s##%u%zu", ctrl.label.c_str(), step.editor_uid, ci);
int pcount = static_cast<int>(step.params.size());
if (ctrl.kind == DagControl::Kind::Slider) {
int pidx = ctrl.param_idx[0];
if (pidx >= 0 && pidx < pcount) {
ImGui::SliderFloat(uid_lbl, &step.params[static_cast<size_t>(pidx)], ctrl.min, ctrl.max);
}
} else if (ctrl.kind == DagControl::Kind::XY) {
int px = ctrl.param_idx[0], py = ctrl.param_idx[1];
if (px >= 0 && px < pcount && py >= 0 && py < pcount && py == px + 1) {
ImGui::SliderFloat2(uid_lbl, &step.params[static_cast<size_t>(px)], ctrl.min, ctrl.max);
}
} else if (ctrl.kind == DagControl::Kind::Color) {
int pr = ctrl.param_idx[0];
if (pr >= 0 && pr + 2 < pcount) {
ImGui::TextUnformatted(ctrl.label.c_str());
ImGui::SameLine();
ed::Suspend();
ImGui::ColorEdit3(uid_lbl, &step.params[static_cast<size_t>(pr)],
ImGuiColorEditFlags_NoInputs |
ImGuiColorEditFlags_NoLabel |
ImGuiColorEditFlags_AlphaBar);
ed::Resume();
}
}
}
// Per-node preview thumbnail (off by default).
if (def->kind != DagKind::Output) {
char btn_lbl[64];
std::snprintf(btn_lbl, sizeof(btn_lbl), "%s preview##pv%u",
step.preview_open ? "[-]" : "[+]", step.editor_uid);
if (ImGui::SmallButton(btn_lbl)) {
step.preview_open = !step.preview_open;
}
if (step.preview_open) {
unsigned tex = dag_preview_texture(step.editor_uid);
if (tex != 0) {
ImGui::Image(static_cast<ImTextureID>(static_cast<intptr_t>(tex)),
ImVec2(96, 64), ImVec2(0, 1), ImVec2(1, 0));
} else {
ImGui::Dummy(ImVec2(96, 64));
}
}
}
ImGui::PopID();
ImGui::EndGroup();
ImGui::SameLine(0, COL_GAP); // gap between controls and output pin
ImGui::BeginGroup(); // output column (right edge)
if (has_output_pin) {
ed::BeginPin(ed::PinId(output_pin_id(step.editor_uid)), ed::PinKind::Output);
ed::PinPivotAlignment(ImVec2(1.0f, 0.5f));
ed::PinPivotSize(ImVec2(0, 0));
ImVec2 cur_screen = ImGui::GetCursorScreenPos();
ImVec2 center_screen(cur_screen.x + PIN_RADIUS, cur_screen.y + PIN_RADIUS);
s_pin_canvas_pos[output_pin_id(step.editor_uid)] =
ed::ScreenToCanvas(center_screen);
draw_pin_circle(PinSide::Output);
ed::EndPin();
} else {
ImGui::Dummy(ImVec2(PIN_RADIUS, PIN_DIAMETER));
}
ImGui::EndGroup();
ed::EndNode();
ed::PopStyleVar();
}
// ── Draw existing links ──────────────────────────────────────────────────
for (int i = 0; i < static_cast<int>(pipeline.size()); ++i) {
const DagStep& step = pipeline[static_cast<size_t>(i)];
const DagNodeDef* def = dag_find(step.name);
if (!def) continue;
for (int k = 0; k < def->num_inputs; ++k) {
const std::string& sid = step.source_ids[static_cast<size_t>(k)];
if (sid.empty()) continue;
int src_idx = find_by_id(pipeline, sid);
if (src_idx < 0) continue;
const DagStep& src_step = pipeline[static_cast<size_t>(src_idx)];
const bool is_splice_preview =
(src_step.editor_uid == splice_hl_from_uid &&
step.editor_uid == splice_hl_to_uid &&
k == splice_hl_slot);
ImVec4 link_col = is_splice_preview ? SPLICE_COLOR : PIN_COLOR;
float link_thick = is_splice_preview ? CABLE_THICK + 2.0f : CABLE_THICK;
ed::Link(ed::LinkId(link_id(src_step.editor_uid, step.editor_uid, k)),
ed::PinId(output_pin_id(src_step.editor_uid)),
ed::PinId(input_pin_id(step.editor_uid, k)),
link_col, link_thick);
}
}
// ── Right-click on a link deletes it directly ──────────────────────────
{
ed::Suspend();
ed::LinkId ctx_lid;
if (ed::ShowLinkContextMenu(&ctx_lid)) {
uintptr_t raw = ctx_lid.Get();
uint32_t to_uid = static_cast<uint32_t>((raw >> 8) & 0xFFFu);
int slot = static_cast<int>(raw & 0xFFu);
int to_idx = find_by_uid(pipeline, to_uid);
if (to_idx >= 0 && slot >= 0 && slot < 4) {
pipeline[static_cast<size_t>(to_idx)].source_ids[static_cast<size_t>(slot)].clear();
changed = true;
}
}
ed::Resume();
}
// ── Double right-click on a node deletes it (Output is protected) ──────
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Right)) {
ed::NodeId hovered = ed::GetHoveredNode();
uint32_t uid = static_cast<uint32_t>(hovered.Get());
if (uid != 0) {
int idx = find_by_uid(pipeline, uid);
if (idx >= 0) {
const DagNodeDef* d = dag_find(pipeline[static_cast<size_t>(idx)].name);
if (d && d->kind != DagKind::Output) {
const std::string del_id = pipeline[static_cast<size_t>(idx)].id;
for (auto& s : pipeline) {
for (auto& sid : s.source_ids) {
if (sid == del_id) sid.clear();
}
}
pipeline.erase(pipeline.begin() + idx);
changed = true;
}
}
}
}
// ── Right-click on a pin clears all connections of that pin ────────────
{
ed::Suspend();
ed::PinId ctx_pid;
if (ed::ShowPinContextMenu(&ctx_pid)) {
uintptr_t raw = ctx_pid.Get();
uint32_t uid = uid_from_pin(raw);
if (is_output_pin(raw)) {
// Output pin: clear every source_ids entry pointing to this node
int idx = find_by_uid(pipeline, uid);
if (idx >= 0) {
const std::string source_id = pipeline[static_cast<size_t>(idx)].id;
for (auto& s : pipeline) {
for (auto& sid : s.source_ids) {
if (sid == source_id) { sid.clear(); changed = true; }
}
}
}
} else {
// Input pin: clear that single slot
int slot = slot_from_input_pin(raw);
int idx = find_by_uid(pipeline, uid);
if (idx >= 0 && slot >= 0 && slot < 4) {
auto& sid = pipeline[static_cast<size_t>(idx)].source_ids[static_cast<size_t>(slot)];
if (!sid.empty()) { sid.clear(); changed = true; }
}
}
}
ed::Resume();
}
// ── Handle link creation ─────────────────────────────────────────────────
if (ed::BeginCreate()) {
ed::PinId start_pin, end_pin;
if (ed::QueryNewLink(&start_pin, &end_pin)) {
uintptr_t sp = start_pin.Get();
uintptr_t ep = end_pin.Get();
// Normalise: start must be output, end must be input
if (!is_output_pin(sp) && is_output_pin(ep)) {
std::swap(sp, ep);
}
bool valid = is_output_pin(sp) && !is_output_pin(ep);
if (valid) {
uint32_t from_uid = uid_from_pin(sp);
uint32_t to_uid = uid_from_pin(ep);
int slot = slot_from_input_pin(ep);
int from_idx = find_by_uid(pipeline, from_uid);
int to_idx = find_by_uid(pipeline, to_uid);
// Real cycle check: would the new edge from->to introduce a path
// from `from` back to itself? It does iff `from` already (transitively)
// depends on `to`. We walk source_ids of `from` and reject if we
// ever hit `to`. Vector index order is irrelevant — topo_sort runs
// at end-of-frame and reorders the pipeline.
bool cycle = false;
if (from_uid == to_uid) {
cycle = true;
} else if (from_idx >= 0 && to_idx >= 0) {
const std::string to_id = pipeline[static_cast<size_t>(to_idx)].id;
std::function<bool(const std::string&)> depends_on_to;
depends_on_to = [&](const std::string& node_id) -> bool {
if (node_id == to_id) return true;
int idx = -1;
for (int i = 0; i < static_cast<int>(pipeline.size()); ++i) {
if (pipeline[static_cast<size_t>(i)].id == node_id) { idx = i; break; }
}
if (idx < 0) return false;
const DagStep& s = pipeline[static_cast<size_t>(idx)];
const DagNodeDef* d = dag_find(s.name);
int n = d ? d->num_inputs : 0;
for (int k = 0; k < n; ++k) {
const std::string& sid = s.source_ids[static_cast<size_t>(k)];
if (!sid.empty() && depends_on_to(sid)) return true;
}
return false;
};
cycle = depends_on_to(pipeline[static_cast<size_t>(from_idx)].id);
}
if (cycle) {
ed::RejectNewItem(ImVec4(1, 0.3f, 0.3f, 1));
valid = false;
}
if (valid && from_idx >= 0 && to_idx >= 0 && slot >= 0) {
if (ed::AcceptNewItem()) {
pipeline[static_cast<size_t>(to_idx)].source_ids[static_cast<size_t>(slot)] =
pipeline[static_cast<size_t>(from_idx)].id;
changed = true;
}
}
} else {
ed::RejectNewItem(ImVec4(0.5f, 0.5f, 0.5f, 1));
}
}
}
ed::EndCreate();
// ── Handle deletion ──────────────────────────────────────────────────────
if (ed::BeginDelete()) {
ed::LinkId del_lid;
while (ed::QueryDeletedLink(&del_lid)) {
if (ed::AcceptDeletedItem()) {
uintptr_t raw = del_lid.Get();
uint32_t to_uid = static_cast<uint32_t>((raw >> 8) & 0xFFF);
int slot = static_cast<int>(raw & 0xFF);
int to_idx = find_by_uid(pipeline, to_uid);
if (to_idx >= 0 && slot < 4) {
pipeline[static_cast<size_t>(to_idx)].source_ids[static_cast<size_t>(slot)].clear();
changed = true;
}
}
}
ed::NodeId del_nid;
while (ed::QueryDeletedNode(&del_nid)) {
uint32_t uid = static_cast<uint32_t>(del_nid.Get());
int idx = find_by_uid(pipeline, uid);
// Refuse to delete the Output node (it's the sink)
const DagNodeDef* ddef = (idx >= 0) ? dag_find(pipeline[static_cast<size_t>(idx)].name) : nullptr;
if (ddef && ddef->kind == DagKind::Output) {
ed::RejectDeletedItem();
continue;
}
if (ed::AcceptDeletedItem()) {
if (idx >= 0) {
const std::string& del_step_id = pipeline[static_cast<size_t>(idx)].id;
for (auto& step : pipeline) {
for (auto& sid : step.source_ids) {
if (sid == del_step_id) sid.clear();
}
}
pipeline.erase(pipeline.begin() + idx);
changed = true;
}
}
}
}
ed::EndDelete();
// ── Save node positions back to steps ────────────────────────────────────
for (auto& step : pipeline) {
auto pos = ed::GetNodePosition(ed::NodeId(node_id(step.editor_uid)));
step.editor_pos_x = pos.x;
step.editor_pos_y = pos.y;
}
ed::End();
ed::SetCurrentEditor(nullptr);
// ── Topological sort after topology change ───────────────────────────────
if (changed) {
if (!topo_sort(pipeline)) {
// Cycle detected — should not happen given BeginCreate validation
// but log a warning and leave order as-is
std::fprintf(stderr, "dag_node_editor: cycle detected, skipping topo sort\n");
}
}
return changed;
}
void dag_node_editor_destroy() {
if (s_ctx) {
ed::DestroyEditor(s_ctx);
s_ctx = nullptr;
}
}
} // namespace fn::gfx