#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 #include #include #include #include #include #include #include #include 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 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 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(uid); } static uintptr_t output_pin_id(uint32_t uid) { return (static_cast(uid) << 8) | 0u; } static uintptr_t input_pin_id(uint32_t uid, int slot) { return (static_cast(uid) << 8) | static_cast(slot + 1); } static uintptr_t link_id(uint32_t from_uid, uint32_t to_uid, int slot) { return (static_cast(from_uid) << 20) | (static_cast(to_uid) << 8) | static_cast(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(id >> 8); } static int slot_from_input_pin(uintptr_t id) { return static_cast(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(i) / static_cast(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& p, uint32_t uid) { for (int i = 0; i < static_cast(p.size()); ++i) { if (p[static_cast(i)].editor_uid == uid) return i; } return -1; } static int find_by_id(const std::vector& p, const std::string& id) { for (int i = 0; i < static_cast(p.size()); ++i) { if (p[static_cast(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& pipeline) { int n = static_cast(pipeline.size()); if (n == 0) return true; // Build adjacency: edge from src -> dst if dst.source_ids[k] == src.id std::unordered_map id_to_idx; for (int i = 0; i < n; ++i) id_to_idx[pipeline[static_cast(i)].id] = i; std::vector in_degree(static_cast(n), 0); std::vector> adj(static_cast(n)); for (int i = 0; i < n; ++i) { const DagStep& s = pipeline[static_cast(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(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(src)].push_back(i); in_degree[static_cast(i)]++; } } std::queue q; for (int i = 0; i < n; ++i) { if (in_degree[static_cast(i)] == 0) q.push(i); } std::vector order; order.reserve(static_cast(n)); while (!q.empty()) { int u = q.front(); q.pop(); order.push_back(u); for (int v : adj[static_cast(u)]) { if (--in_degree[static_cast(v)] == 0) q.push(v); } } if (static_cast(order.size()) != n) return false; // cycle std::vector sorted; sorted.reserve(static_cast(n)); for (int idx : order) sorted.push_back(pipeline[static_cast(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& 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(p->Data), static_cast(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(k)]; if (sid.empty()) continue; int src_idx = find_by_id(pipeline, sid); if (src_idx < 0) continue; const DagStep& src = pipeline[static_cast(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(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(i); break; } } } const DagNodeDef* hit_def = (hit_idx >= 0) ? dag_find(pipeline[static_cast(hit_idx)].name) : nullptr; if (splice_dst_idx >= 0 && static_cast(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(splice_src_idx)].id; const std::string dst_id = pipeline[static_cast(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(dst_now)].source_ids[static_cast(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(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(k)].clear(); } changed = true; } else if (static_cast(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(hn.Get()); int idx = find_by_uid(pipeline, uid); if (idx >= 0) { const DagNodeDef* d = dag_find(pipeline[static_cast(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(p->Data), static_cast(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(exclude_node_idx)].name); exclude_node_id = &pipeline[static_cast(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(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(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(mv_idx)].id; const std::string src_id = pipeline[static_cast(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(mv_idx)].source_ids[0] = src_id; pipeline[static_cast(dst_idx)].source_ids[static_cast(splice_hl_slot)] = moving_id; changed = true; } } } // ── Draw nodes ─────────────────────────────────────────────────────────── for (int i = 0; i < static_cast(pipeline.size()); ++i) { DagStep& step = pipeline[static_cast(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(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(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(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(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(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(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(static_cast(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(pipeline.size()); ++i) { const DagStep& step = pipeline[static_cast(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(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(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((raw >> 8) & 0xFFFu); int slot = static_cast(raw & 0xFFu); int to_idx = find_by_uid(pipeline, to_uid); if (to_idx >= 0 && slot >= 0 && slot < 4) { pipeline[static_cast(to_idx)].source_ids[static_cast(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(hovered.Get()); if (uid != 0) { int idx = find_by_uid(pipeline, uid); if (idx >= 0) { const DagNodeDef* d = dag_find(pipeline[static_cast(idx)].name); if (d && d->kind != DagKind::Output) { const std::string del_id = pipeline[static_cast(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(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(idx)].source_ids[static_cast(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(to_idx)].id; std::function 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(pipeline.size()); ++i) { if (pipeline[static_cast(i)].id == node_id) { idx = i; break; } } if (idx < 0) return false; const DagStep& s = pipeline[static_cast(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(k)]; if (!sid.empty() && depends_on_to(sid)) return true; } return false; }; cycle = depends_on_to(pipeline[static_cast(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(to_idx)].source_ids[static_cast(slot)] = pipeline[static_cast(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((raw >> 8) & 0xFFF); int slot = static_cast(raw & 0xFF); int to_idx = find_by_uid(pipeline, to_uid); if (to_idx >= 0 && slot < 4) { pipeline[static_cast(to_idx)].source_ids[static_cast(slot)].clear(); changed = true; } } } ed::NodeId del_nid; while (ed::QueryDeletedNode(&del_nid)) { uint32_t uid = static_cast(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(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(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