Files
fn_registry/cpp/functions/gfx/dag_node_editor.cpp
T
egutierrez ac65791663 feat(shaders_lab): per-node preview thumbnails + double-rclick delete node
- DagStep: preview_open flag (default false).
- dag_compile: emit `uniform int u_preview_target` and a series of
  early-return branches at the start of fragColor selection. -1 (default)
  falls through to the real Output-driven fragColor.
- dag_node_previews (new fn): per-node FBO keyed by editor_uid, lazy
  created. Renders each node with preview_open=true to its FBO by
  setting u_preview_target = step index. Texture exposed via
  dag_preview_texture(uid) for ImGui::Image.
- dag_node_editor: small toggle button "[+] preview"/"[-] preview" in
  each non-Output node; when open, ImGui::Image(96x64, V-flipped).
- dag_node_editor: double right-click on hovered node deletes it
  (Output is protected).
- main.cpp: dag_previews_render after Canvas DAG; dag_previews_destroy
  on shutdown.

Single GL program drives both the canvas and all thumbnails — moving
sliders never recompiles, only the topology change does.

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

578 lines
24 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 <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;
// ── 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; }
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 = 9.0f;
static constexpr float PIN_DIAMETER = PIN_RADIUS * 2.0f;
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);
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, 1.5f);
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 && static_cast<int>(pipeline.size()) < MAX_NODES) {
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;
ImVec2 canvas_pos = ed::ScreenToCanvas(s_pending_add_pos);
step.editor_pos_x = canvas_pos.x;
step.editor_pos_y = canvas_pos.y;
// Insert before the Output node so the Output stays at the back;
// otherwise new nodes can never be wired into it (compiler and
// cycle check only search indices strictly before the target).
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;
}
// ── 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) * 220.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, 8, 0, 8));
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));
draw_pin_circle(PinSide::Input);
ed::EndPin();
}
ImGui::EndGroup();
ImGui::SameLine(0, 8); // 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(60, PIN_DIAMETER));
}
for (size_t ci = 0; ci < def->controls.size(); ++ci) {
const DagControl& ctrl = def->controls[ci];
ImGui::SetNextItemWidth(150.0f);
char uid_lbl[64];
std::snprintf(uid_lbl, sizeof(uid_lbl), "%s##%u%zu", ctrl.label.c_str(), step.editor_uid, ci);
if (ctrl.kind == DagControl::Kind::Slider) {
int pidx = ctrl.param_idx[0];
if (pidx >= 0 && pidx < 4) {
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 < 4 && py >= 0 && py < 4 && 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 < 4) {
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, 8); // 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));
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)];
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)),
PIN_COLOR, 2.5f);
}
}
// ── 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