// World Graph Node Editor // A standalone Dear ImGui + imnodes tool for editing WorldGraph node graphs. // Graphs are saved/loaded as Flecs JSON (graph.json in the working directory). #include #include "imgui.h" #include "imgui_impl_glfw.h" #include "imgui_impl_opengl3.h" #include "imnodes.h" #include "flecs.h" #include "Components/WorldGraph.hpp" #include "Types/WorldGraph/WorldGraph.h" #include #include #include #include #include #include #include // ─── Editor state ───────────────────────────────────────────────────────────── struct EditorNode { int nodeId; // imnodes node ID flecs::entity entity; VisualNodeKind kind; bool isOutput; }; struct EditorLink { int linkId; int srcAttrId; // output attribute of source node int dstAttrId; // input attribute (pin slot) of destination node }; // Attribute ID encoding: // node's output pin = nodeId * 10 + 9 // node's input pin N = nodeId * 10 + N (N = 0,1,2) static int OutputAttr(int nodeId) { return nodeId * 10 + 9; } static int InputAttr (int nodeId, int slot) { return nodeId * 10 + slot; } static int AttrToNode(int attr) { return attr / 10; } static int AttrToSlot(int attr) { return attr % 10; } // 9 = output // ─── Kind metadata ──────────────────────────────────────────────────────────── struct KindInfo { const char* name; int inputCount; bool hasParamConstant; bool hasParamNoise; bool hasParamIsTile; bool hasParamTileDistance; }; static const KindInfo KindInfoTable[] = { // name ins const noise isTile tileDist {"Add", 2, 0, 0, 0, 0}, {"Subtract", 2, 0, 0, 0, 0}, {"Multiply", 2, 0, 0, 0, 0}, {"Divide", 2, 0, 0, 0, 0}, {"Modulo", 2, 0, 0, 0, 0}, {"Pow", 2, 0, 0, 0, 0}, {"Max", 2, 0, 0, 0, 0}, {"Min", 2, 0, 0, 0, 0}, {"Negate", 1, 0, 0, 0, 0}, {"Abs", 1, 0, 0, 0, 0}, {"Ceil", 1, 0, 0, 0, 0}, {"Floor", 1, 0, 0, 0, 0}, {"Sin", 1, 0, 0, 0, 0}, {"Cos", 1, 0, 0, 0, 0}, {"Tan", 1, 0, 0, 0, 0}, {"Exp", 1, 0, 0, 0, 0}, {"Log", 1, 0, 0, 0, 0}, {"Square", 1, 0, 0, 0, 0}, {"Round", 1, 0, 0, 0, 0}, {"OneMinus", 1, 0, 0, 0, 0}, {"Lerp", 3, 0, 0, 0, 0}, {"Clamp", 3, 0, 0, 0, 0}, {"Equal", 2, 0, 0, 0, 0}, {"Smaller", 2, 0, 0, 0, 0}, {"Greater", 2, 0, 0, 0, 0}, {"SmallerEqual", 2, 0, 0, 0, 0}, {"GreaterEqual", 2, 0, 0, 0, 0}, {"And", 2, 0, 0, 0, 0}, {"Or", 2, 0, 0, 0, 0}, {"Branch", 3, 0, 0, 0, 0}, {"Simplex", 0, 0, 1, 0, 0}, {"OpenSimplex", 0, 0, 1, 0, 0}, {"Perlin", 0, 0, 1, 0, 0}, {"Value", 0, 0, 1, 0, 0}, {"ValueCubic", 0, 0, 1, 0, 0}, {"Constant", 0, 1, 0, 0, 0}, {"IsTile", 0, 0, 0, 1, 0}, {"TileDistance", 0, 0, 0, 0, 1}, }; static const KindInfo& GetKindInfo(VisualNodeKind k) { return KindInfoTable[static_cast(k)]; } // ─── Application ───────────────────────────────────────────────────────────── class NodeEditorApp { public: NodeEditorApp() { Flecs_WorldGraph(World); GraphRoot = World.entity("Graph"); } // ── Graph I/O ───────────────────────────────────────────────────────────── void Save(const char* path) { // Sync editor positions back to components before saving for (auto& n : Nodes) { ImVec2 pos = ImNodes::GetNodeGridSpacePos(n.nodeId); n.entity.set({pos.x, pos.y}); } char* json = ecs_world_to_json(World.c_ptr(), nullptr); std::ofstream f(path); f << json; ecs_os_free(json); printf("Saved to %s\n", path); } void Load(const char* path) { std::ifstream f(path); if (!f) { printf("Could not open %s\n", path); return; } std::ostringstream ss; ss << f.rdbuf(); std::string json = ss.str(); ecs_world_from_json(World.c_ptr(), json.c_str(), nullptr); RebuildEditorState(); printf("Loaded from %s\n", path); } // ── Rebuild editor state from Flecs world ───────────────────────────────── void RebuildEditorState() { Nodes.clear(); Links.clear(); NextId = 1; // Map entity → nodeId std::unordered_map entityToNode; World.query_builder() .with(flecs::ChildOf, GraphRoot) .build() .each([&](flecs::entity e, VisualNodeType& t) { int nid = NextId++; entityToNode[e.id()] = nid; Nodes.push_back({ nid, e, t.kind, e.has() }); // Restore canvas position const VisualNodePos* pos = e.try_get(); if (pos) ImNodes::SetNodeGridSpacePos(nid, ImVec2{pos->x, pos->y}); }); // Rebuild links for (auto& n : Nodes) { auto addLink = [&](int slot, flecs::entity src) { if (!src) return; auto it = entityToNode.find(src.id()); if (it == entityToNode.end()) return; int srcNodeId = it->second; Links.push_back({ NextId++, OutputAttr(srcNodeId), InputAttr(n.nodeId, slot) }); }; addLink(0, n.entity.target()); addLink(1, n.entity.target()); addLink(2, n.entity.target()); } } // ── Add a new node ──────────────────────────────────────────────────────── void AddNode(VisualNodeKind kind) { static int nameCounter = 0; char name[64]; snprintf(name, sizeof(name), "%s_%d", GetKindInfo(kind).name, nameCounter++); flecs::entity e = World.entity(name) .child_of(GraphRoot) .set({ kind }) .set({ 100.f, 100.f }); // Add default parameters if (GetKindInfo(kind).hasParamConstant) e.set({ 0.f }); if (GetKindInfo(kind).hasParamNoise) e.set({ 0.01f }); if (GetKindInfo(kind).hasParamIsTile) e.set({ 0, 0, TileType::Air }); if (GetKindInfo(kind).hasParamTileDistance)e.set({ 1, TileType::Air }); int nid = NextId++; Nodes.push_back({ nid, e, kind, false }); ImNodes::SetNodeGridSpacePos(nid, { 100.f, 100.f }); } // ── Render one frame ────────────────────────────────────────────────────── void Render() { // ── Menu bar ───────────────────────────────────────────────────────── if (ImGui::BeginMainMenuBar()) { if (ImGui::BeginMenu("File")) { if (ImGui::MenuItem("Save", "Ctrl+S")) Save("graph.json"); if (ImGui::MenuItem("Load", "Ctrl+O")) Load("graph.json"); ImGui::EndMenu(); } if (ImGui::BeginMenu("Add Node")) { for (int i = 0; i <= (int)VisualNodeKind::TileDistance; ++i) { if (ImGui::MenuItem(KindInfoTable[i].name)) AddNode(static_cast(i)); } ImGui::EndMenu(); } ImGui::EndMainMenuBar(); } // ── Full-screen canvas ──────────────────────────────────────────────── ImGuiIO& io = ImGui::GetIO(); ImGui::SetNextWindowPos({0, ImGui::GetFrameHeight()}); ImGui::SetNextWindowSize({io.DisplaySize.x, io.DisplaySize.y - ImGui::GetFrameHeight()}); ImGui::Begin("##canvas", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoMove); ImNodes::BeginNodeEditor(); // ── Draw nodes ──────────────────────────────────────────────────────── for (auto& n : Nodes) { const KindInfo& info = GetKindInfo(n.kind); if (n.isOutput) ImNodes::PushColorStyle(ImNodesCol_TitleBar, IM_COL32(150, 80, 20, 255)); ImNodes::BeginNode(n.nodeId); ImNodes::BeginNodeTitleBar(); ImGui::TextUnformatted(info.name); if (n.isOutput) ImGui::SameLine(); if (n.isOutput) ImGui::TextUnformatted(" [OUT]"); ImNodes::EndNodeTitleBar(); // Input pins static const char* slotLabels[] = { "In 0", "In 1", "In 2" }; for (int s = 0; s < info.inputCount; ++s) { ImNodes::BeginInputAttribute(InputAttr(n.nodeId, s)); ImGui::TextUnformatted(slotLabels[s]); ImNodes::EndInputAttribute(); } // Inline parameters ImGui::PushItemWidth(120.f); if (info.hasParamConstant) { auto* p = n.entity.try_get_mut(); ImGui::DragFloat("##val", &p->value, 0.01f); } if (info.hasParamNoise) { auto* p = n.entity.try_get_mut(); ImGui::DragFloat("Freq##", &p->frequency, 0.001f, 0.0001f, 10.f); } if (info.hasParamIsTile) { auto* p = n.entity.try_get_mut(); ImGui::DragInt("RelX##", reinterpret_cast(&p->relativeX), 1.f, -4, 4); ImGui::DragInt("RelY##", reinterpret_cast(&p->relativeY), 1.f, -4, 4); int t = static_cast(p->tileType); if (ImGui::Combo("Type##", &t, "Air\0Filler\0Liquid\0Ore\0NPC\0Plant\0")) p->tileType = static_cast(t); } if (info.hasParamTileDistance) { auto* p = n.entity.try_get_mut(); int r = p->range; if (ImGui::SliderInt("Range##", &r, 1, 3)) p->range = static_cast(r); int t = static_cast(p->tileType); if (ImGui::Combo("Type##td", &t, "Air\0Filler\0Liquid\0Ore\0NPC\0Plant\0")) p->tileType = static_cast(t); } ImGui::PopItemWidth(); // Output pin ImNodes::BeginOutputAttribute(OutputAttr(n.nodeId)); ImGui::TextUnformatted("Out"); ImNodes::EndOutputAttribute(); ImNodes::EndNode(); if (n.isOutput) ImNodes::PopColorStyle(); } // ── Draw links ──────────────────────────────────────────────────────── for (auto& l : Links) ImNodes::Link(l.linkId, l.srcAttrId, l.dstAttrId); ImNodes::EndNodeEditor(); // ── Handle new link ─────────────────────────────────────────────────── { int startAttr, endAttr; if (ImNodes::IsLinkCreated(&startAttr, &endAttr)) { // Normalise: output attr should be startAttr if (AttrToSlot(startAttr) != 9) std::swap(startAttr, endAttr); if (AttrToSlot(startAttr) == 9 && AttrToSlot(endAttr) != 9) { int srcNodeId = AttrToNode(startAttr); int dstNodeId = AttrToNode(endAttr); int slot = AttrToSlot(endAttr); auto srcIt = std::find_if(Nodes.begin(), Nodes.end(), [&](auto& n){ return n.nodeId == srcNodeId; }); auto dstIt = std::find_if(Nodes.begin(), Nodes.end(), [&](auto& n){ return n.nodeId == dstNodeId; }); if (srcIt != Nodes.end() && dstIt != Nodes.end()) { flecs::entity src = srcIt->entity; flecs::entity dst = dstIt->entity; // Remove existing connection on this slot if (slot == 0) dst.remove(flecs::Wildcard); if (slot == 1) dst.remove(flecs::Wildcard); if (slot == 2) dst.remove(flecs::Wildcard); // Add new connection if (slot == 0) dst.add(src); if (slot == 1) dst.add(src); if (slot == 2) dst.add(src); Links.push_back({ NextId++, startAttr, endAttr }); } } } } // ── Handle link deletion ────────────────────────────────────────────── { int linkId; if (ImNodes::IsLinkDestroyed(&linkId)) { auto it = std::find_if(Links.begin(), Links.end(), [&](auto& l){ return l.linkId == linkId; }); if (it != Links.end()) { int dstNodeId = AttrToNode(it->dstAttrId); int slot = AttrToSlot(it->dstAttrId); auto dstIt = std::find_if(Nodes.begin(), Nodes.end(), [&](auto& n){ return n.nodeId == dstNodeId; }); if (dstIt != Nodes.end()) { if (slot == 0) dstIt->entity.remove(flecs::Wildcard); if (slot == 1) dstIt->entity.remove(flecs::Wildcard); if (slot == 2) dstIt->entity.remove(flecs::Wildcard); } Links.erase(it); } } } // ── Context menu: mark output / delete ─────────────────────────────── { int hoveredNode = -1; if (ImNodes::IsNodeHovered(&hoveredNode) && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { ContextMenuNodeId = hoveredNode; ImGui::OpenPopup("node_ctx"); } if (ImGui::BeginPopup("node_ctx")) { int nodeId = ContextMenuNodeId; auto it = std::find_if(Nodes.begin(), Nodes.end(), [&](auto& n){ return n.nodeId == nodeId; }); if (it != Nodes.end()) { if (ImGui::MenuItem("Set as Output")) { for (auto& n : Nodes) { n.isOutput = false; n.entity.remove(); } it->isOutput = true; it->entity.add(); } if (ImGui::MenuItem("Delete Node")) { // Remove all links connected to this node Links.erase(std::remove_if(Links.begin(), Links.end(), [&](auto& l) { return AttrToNode(l.srcAttrId) == nodeId || AttrToNode(l.dstAttrId) == nodeId; }), Links.end()); it->entity.destruct(); Nodes.erase(it); } } ImGui::EndPopup(); } } ImGui::End(); } flecs::world World; flecs::entity GraphRoot; std::vector Nodes; std::vector Links; int NextId = 1; int ContextMenuNodeId = -1; }; // ─── GLFW error callback ────────────────────────────────────────────────────── static void GLFWErrorCallback(int error, const char* desc) { fprintf(stderr, "GLFW error %d: %s\n", error, desc); } // ─── main ───────────────────────────────────────────────────────────────────── int main() { glfwSetErrorCallback(GLFWErrorCallback); if (!glfwInit()) return 1; const char* glsl_version = "#version 330"; glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); GLFWwindow* window = glfwCreateWindow(1280, 720, "World Graph Editor", nullptr, nullptr); if (!window) { glfwTerminate(); return 1; } glfwMakeContextCurrent(window); glfwSwapInterval(1); IMGUI_CHECKVERSION(); ImGui::CreateContext(); ImNodes::CreateContext(); ImGui::StyleColorsDark(); ImNodes::StyleColorsDark(); ImGui_ImplGlfw_InitForOpenGL(window, true); ImGui_ImplOpenGL3_Init(glsl_version); NodeEditorApp app; while (!glfwWindowShouldClose(window)) { glfwPollEvents(); ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplGlfw_NewFrame(); ImGui::NewFrame(); // Ctrl+S / Ctrl+O shortcuts if (ImGui::IsKeyDown(ImGuiKey_LeftCtrl) || ImGui::IsKeyDown(ImGuiKey_RightCtrl)) { if (ImGui::IsKeyPressed(ImGuiKey_S)) app.Save("graph.json"); if (ImGui::IsKeyPressed(ImGuiKey_O)) app.Load("graph.json"); } app.Render(); ImGui::Render(); int w, h; glfwGetFramebufferSize(window, &w, &h); glViewport(0, 0, w, h); glClearColor(0.15f, 0.15f, 0.15f, 1.f); glClear(GL_COLOR_BUFFER_BIT); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); glfwSwapBuffers(window); } ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplGlfw_Shutdown(); ImNodes::DestroyContext(); ImGui::DestroyContext(); glfwDestroyWindow(window); glfwTerminate(); return 0; }