Files
factory-hole-core/tools/node-editor/main.cpp
2026-02-20 22:50:05 +09:00

493 lines
19 KiB
C++

// 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 <GLFW/glfw3.h>
#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 <cstdio>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include <unordered_map>
#include <algorithm>
// ─── 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<int>(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<VisualNodePos>({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<uint64_t, int> entityToNode;
World.query_builder<VisualNodeType>()
.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<VisualNodeOutput>() });
// Restore canvas position
const VisualNodePos* pos = e.try_get<VisualNodePos>();
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<InputPin0>());
addLink(1, n.entity.target<InputPin1>());
addLink(2, n.entity.target<InputPin2>());
}
}
// ── 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<VisualNodeType>({ kind })
.set<VisualNodePos>({ 100.f, 100.f });
// Add default parameters
if (GetKindInfo(kind).hasParamConstant) e.set<NodeParam_Constant>({ 0.f });
if (GetKindInfo(kind).hasParamNoise) e.set<NodeParam_Noise>({ 0.01f });
if (GetKindInfo(kind).hasParamIsTile) e.set<NodeParam_IsTile>({ 0, 0, TileType::Air });
if (GetKindInfo(kind).hasParamTileDistance)e.set<NodeParam_TileDistance>({ 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<VisualNodeKind>(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<NodeParam_Constant>();
ImGui::DragFloat("##val", &p->value, 0.01f);
}
if (info.hasParamNoise)
{
auto* p = n.entity.try_get_mut<NodeParam_Noise>();
ImGui::DragFloat("Freq##", &p->frequency, 0.001f, 0.0001f, 10.f);
}
if (info.hasParamIsTile)
{
auto* p = n.entity.try_get_mut<NodeParam_IsTile>();
ImGui::DragInt("RelX##", reinterpret_cast<int*>(&p->relativeX), 1.f, -4, 4);
ImGui::DragInt("RelY##", reinterpret_cast<int*>(&p->relativeY), 1.f, -4, 4);
int t = static_cast<int>(p->tileType);
if (ImGui::Combo("Type##", &t, "Air\0Filler\0Liquid\0Ore\0NPC\0Plant\0"))
p->tileType = static_cast<TileType>(t);
}
if (info.hasParamTileDistance)
{
auto* p = n.entity.try_get_mut<NodeParam_TileDistance>();
int r = p->range;
if (ImGui::SliderInt("Range##", &r, 1, 3)) p->range = static_cast<int8_t>(r);
int t = static_cast<int>(p->tileType);
if (ImGui::Combo("Type##td", &t, "Air\0Filler\0Liquid\0Ore\0NPC\0Plant\0"))
p->tileType = static_cast<TileType>(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<InputPin0>(flecs::Wildcard);
if (slot == 1) dst.remove<InputPin1>(flecs::Wildcard);
if (slot == 2) dst.remove<InputPin2>(flecs::Wildcard);
// Add new connection
if (slot == 0) dst.add<InputPin0>(src);
if (slot == 1) dst.add<InputPin1>(src);
if (slot == 2) dst.add<InputPin2>(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<InputPin0>(flecs::Wildcard);
if (slot == 1) dstIt->entity.remove<InputPin1>(flecs::Wildcard);
if (slot == 2) dstIt->entity.remove<InputPin2>(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<VisualNodeOutput>(); }
it->isOutput = true;
it->entity.add<VisualNodeOutput>();
}
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<EditorNode> Nodes;
std::vector<EditorLink> 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;
}