feat(shaders_lab): visual node editor (imgui-node-editor) + multi-source
- cpp/vendor/imgui-node-editor: vendorized thedmd/imgui-node-editor v0.9.4 - cpp/functions/gfx/dag_node_editor: new visual pipeline editor replacing dag_panel - DagStep: source_ids[4] + editor_pos + editor_uid (multi-input support) - DagNodeDef: num_inputs explicit; circle reclassified as Op - dag_compile: N inputs per node, topological ordering preserved - main: use node editor; destroy on shutdown - patch imgui_extra_math.inl: guard operator*(float, ImVec2) for imgui >= 18955 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
#include "gfx/dag_node_editor.h"
|
||||
#include "gfx/dag_catalog.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_node_editor.h"
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <queue>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#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;
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
return ImVec4(1, 1, 1, 1);
|
||||
}
|
||||
|
||||
// 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)]);
|
||||
pipeline = std::move(sorted);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add node popup toolbar — rendered OUTSIDE ed::Begin
|
||||
static bool draw_add_toolbar(std::vector<DagStep>& pipeline) {
|
||||
bool changed = false;
|
||||
int sz = static_cast<int>(pipeline.size());
|
||||
if (ImGui::Button("+ Add Node")) {
|
||||
ImGui::OpenPopup("ne_add_popup");
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::Text("%d/%d", sz, MAX_NODES);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Clear") && !pipeline.empty()) {
|
||||
ImGui::OpenPopup("ne_clear_confirm");
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Fit")) {
|
||||
if (s_ctx) {
|
||||
ed::SetCurrentEditor(s_ctx);
|
||||
ed::NavigateToContent(0.0f);
|
||||
ed::SetCurrentEditor(nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
if (ImGui::BeginPopupModal("ne_clear_confirm", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
ImGui::Text("Vaciar el pipeline?");
|
||||
if (ImGui::Button("Si")) {
|
||||
pipeline.clear();
|
||||
changed = true;
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("No")) { ImGui::CloseCurrentPopup(); }
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
if (ImGui::BeginPopup("ne_add_popup")) {
|
||||
const char* kind_names[] = { "Gen", "Op", "Blend" };
|
||||
DagKind kinds[] = { DagKind::Gen, DagKind::Op, DagKind::Blend };
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
if (ImGui::BeginMenu(kind_names[k])) {
|
||||
for (const auto& def : dag_catalog()) {
|
||||
if (def.kind != kinds[k]) continue;
|
||||
if (ImGui::MenuItem(def.label.c_str())) {
|
||||
if (sz < MAX_NODES) {
|
||||
DagStep step;
|
||||
step.id = "n" + std::to_string(s_next_uid);
|
||||
step.name = def.name;
|
||||
step.params = def.param_defaults;
|
||||
step.editor_uid = s_next_uid++;
|
||||
// stagger position so nodes don't stack
|
||||
step.editor_pos_x = 50.0f + static_cast<float>(sz) * 220.0f;
|
||||
step.editor_pos_y = 100.0f;
|
||||
pipeline.push_back(step);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
changed |= draw_add_toolbar(pipeline);
|
||||
ImGui::Separator();
|
||||
|
||||
ed::SetCurrentEditor(s_ctx);
|
||||
ed::Begin("dag_editor", ImVec2(0.0f, 0.0f));
|
||||
|
||||
// ── 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);
|
||||
|
||||
ed::BeginNode(ed::NodeId(node_id(step.editor_uid)));
|
||||
|
||||
// Header
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, col);
|
||||
ImGui::TextUnformatted(def->label.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::Dummy(ImVec2(0, 2));
|
||||
|
||||
// Input pins (left column) + controls (middle) + output pin (right)
|
||||
// We use a simple layout: input pins vertically, then controls, then output pin
|
||||
int ni = def->num_inputs;
|
||||
|
||||
// Input pins
|
||||
for (int k = 0; k < ni; ++k) {
|
||||
ed::BeginPin(ed::PinId(input_pin_id(step.editor_uid, k)), ed::PinKind::Input);
|
||||
char lbl[16];
|
||||
std::snprintf(lbl, sizeof(lbl), "-> in%d", k);
|
||||
ImGui::TextUnformatted(lbl);
|
||||
ed::EndPin();
|
||||
}
|
||||
|
||||
if (ni == 0) {
|
||||
// Gen: no inputs, push a small placeholder so layout is consistent
|
||||
ImGui::Dummy(ImVec2(4, 4));
|
||||
}
|
||||
|
||||
// Controls
|
||||
ImGui::PushID(static_cast<int>(step.editor_uid));
|
||||
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) {
|
||||
ImGui::ColorEdit3(uid_lbl, &step.params[static_cast<size_t>(pr)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::PopID();
|
||||
|
||||
// Output pin
|
||||
ImGui::Dummy(ImVec2(0, 2));
|
||||
ed::BeginPin(ed::PinId(output_pin_id(step.editor_uid)), ed::PinKind::Output);
|
||||
ImGui::Text("out ->");
|
||||
ed::EndPin();
|
||||
|
||||
ed::EndNode();
|
||||
|
||||
// Set initial position if not yet placed (both zero = first time)
|
||||
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));
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
uint32_t src_uid = pipeline[static_cast<size_t>(src_idx)].editor_uid;
|
||||
ed::Link(ed::LinkId(link_id(src_uid, step.editor_uid, k)),
|
||||
ed::PinId(output_pin_id(src_uid)),
|
||||
ed::PinId(input_pin_id(step.editor_uid, k)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
// Reject cycles: in topo-ordered list, from must come before to.
|
||||
// We also reject self-loops.
|
||||
if (from_uid == to_uid) {
|
||||
ed::RejectNewItem(ImVec4(1, 0, 0, 1));
|
||||
valid = false;
|
||||
} else if (from_idx >= to_idx) {
|
||||
// Would create a back-edge; reject
|
||||
ed::RejectNewItem(ImVec4(1, 0.5f, 0, 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)) {
|
||||
if (ed::AcceptDeletedItem()) {
|
||||
uint32_t uid = static_cast<uint32_t>(del_nid.Get());
|
||||
int idx = find_by_uid(pipeline, uid);
|
||||
if (idx >= 0) {
|
||||
const std::string& del_step_id = pipeline[static_cast<size_t>(idx)].id;
|
||||
// Clear any source_ids pointing to the deleted node
|
||||
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
|
||||
Reference in New Issue
Block a user