// WorldGraph Node Editor // Standalone Dear ImGui + imnodes tool for editing WorldGraph node graphs. // Each node displays a 64×64 preview image of its output, ShaderGraph-style. // The permanent "World Output" node collects generation passes and renders // the final generated chunk using GenerateChunk(). #include #include "imgui.h" #include "imgui_impl_glfw.h" #include "imgui_impl_opengl3.h" #include "imnodes.h" #include "ImGuiFileDialog.h" #include "WorldGraph/WorldGraph.h" #include "WorldGraph/WorldGraphNode.h" #include "WorldGraph/WorldGraphTypes.h" #include "WorldGraph/WorldGraphSerializer.h" #include "WorldGraph/WorldGraphChunk.h" #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace WorldGraph; // ───────────────────────────────────────────────────────────────────────────── // Pin / link ID space // // Regular node output pin → nodeID * 10 + 9 // Regular node input slot S → nodeID * 10 + S (S = 0..8) // // World Output input pin N → WORLD_OUTPUT_PIN_BASE + N // World Output static attr → WORLD_OUTPUT_PIN_BASE + 500 // // Regular link IDs → 0 .. (num regular connections - 1) // World Output link N → WORLD_OUTPUT_LINK_BASE + N // ───────────────────────────────────────────────────────────────────────────── static constexpr int WORLD_OUTPUT_IMNODES_ID = 0; static constexpr int WORLD_OUTPUT_PIN_BASE = 10000; static constexpr int WORLD_OUTPUT_LINK_BASE = 20000; static int OutputPin(Graph::NodeID n) { return static_cast(n) * 10 + 9; } static int InputPin (Graph::NodeID n, int slot) { return static_cast(n) * 10 + slot; } static Graph::NodeID PinToNode(int attr) { return static_cast(attr / 10); } static int PinToSlot(int attr) { return attr % 10; } // 9 = output static bool IsWorldOutputPin(int attr) { return attr >= WORLD_OUTPUT_PIN_BASE && attr < WORLD_OUTPUT_PIN_BASE + 500; } // ───────────────────────────────────────────────────────────────────────────── // Tile registry – maps integer IDs to human-readable names // ───────────────────────────────────────────────────────────────────────────── struct TileEntry { int32_t id { 0 }; std::string name { "Tile" }; float color[3] { 1.0f, 1.0f, 1.0f }; // RGB in [0, 1] }; // ───────────────────────────────────────────────────────────────────────────── // Color helpers // ───────────────────────────────────────────────────────────────────────────── static void HsvToRgb(float h, float s, float v, uint8_t& r, uint8_t& g, uint8_t& b) { float c = v * s; float x = c * (1.0f - std::fabs(std::fmod(h * 6.0f, 2.0f) - 1.0f)); float m = v - c; float r1, g1, b1; int hi = static_cast(h * 6.0f) % 6; switch (hi) { case 0: r1 = c; g1 = x; b1 = 0; break; case 1: r1 = x; g1 = c; b1 = 0; break; case 2: r1 = 0; g1 = c; b1 = x; break; case 3: r1 = 0; g1 = x; b1 = c; break; case 4: r1 = x; g1 = 0; b1 = c; break; default:r1 = c; g1 = 0; b1 = x; break; } r = static_cast((r1 + m) * 255.0f); g = static_cast((g1 + m) * 255.0f); b = static_cast((b1 + m) * 255.0f); } static void ValueToRGBA(const Value& v, const std::vector* registry, uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a) { a = 255; switch (v.type) { case Type::Float: { uint8_t grey = static_cast(std::clamp(v.AsFloat(), 0.0f, 1.0f) * 255.0f); r = g = b = grey; return; } case Type::Bool: { uint8_t c = v.AsBool() ? 255 : 0; r = g = b = c; return; } case Type::Int: break; } // Int path — look up registry first, fall back to hash. int32_t id = v.AsInt(); if (registry) { for (const auto& t : *registry) { if (t.id == id) { r = static_cast(t.color[0] * 255.0f); g = static_cast(t.color[1] * 255.0f); b = static_cast(t.color[2] * 255.0f); return; } } } // Fallback: tile 0 (AIR) = near-black, others get a hashed hue. if (id == 0) { r = g = b = 30; return; } uint32_t h = static_cast(id) * 2654435761u; HsvToRgb((h & 0xFFu) / 255.0f, 0.7f, 0.85f, r, g, b); } // Pin colors by value type. // Float → blue Int → orange Bool → green static ImU32 PinColor(Type t) { switch (t) { case Type::Float: return IM_COL32(100, 150, 220, 255); case Type::Int: return IM_COL32(220, 160, 60, 255); case Type::Bool: return IM_COL32( 80, 200, 100, 255); } return IM_COL32(200, 200, 200, 255); } static ImU32 PinColorHovered(Type t) { switch (t) { case Type::Float: return IM_COL32(130, 180, 255, 255); case Type::Int: return IM_COL32(255, 195, 90, 255); case Type::Bool: return IM_COL32(110, 235, 130, 255); } return IM_COL32(230, 230, 230, 255); } // ───────────────────────────────────────────────────────────────────────────── // VisualNode – wraps a graph node with its preview texture // ───────────────────────────────────────────────────────────────────────────── static constexpr int PREVIEW_SIZE = 64; static GLuint MakePreviewTexture() { GLuint tex = 0; glGenTextures(1, &tex); glBindTexture(GL_TEXTURE_2D, tex); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); std::array buf; buf.fill(40); for (int i = 3; i < (int)buf.size(); i += 4) buf[i] = 255; glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, PREVIEW_SIZE, PREVIEW_SIZE, 0, GL_RGBA, GL_UNSIGNED_BYTE, buf.data()); return tex; } struct VisualNode { Graph::NodeID id { Graph::INVALID_ID }; GLuint tex { 0 }; bool dirty { true }; void InitTex() { tex = MakePreviewTexture(); } void FreeTex() { if (tex) { glDeleteTextures(1, &tex); tex = 0; } } }; // ───────────────────────────────────────────────────────────────────────────── // Node catalogue – all creatable node types // ───────────────────────────────────────────────────────────────────────────── struct NodeMenuItem { const char* label; const char* category; std::function()> create; }; static const std::vector NODE_MENU = { { "Constant", "Source", [] { return std::make_unique(0.0f); } }, { "TileID", "Source", [] { return std::make_unique(1); } }, { "PositionX", "Source", [] { return std::make_unique(); } }, { "PositionY", "Source", [] { return std::make_unique(); } }, { "Add", "Math", [] { return std::make_unique(); } }, { "Subtract", "Math", [] { return std::make_unique(); } }, { "Multiply", "Math", [] { return std::make_unique(); } }, { "Divide", "Math", [] { return std::make_unique(); } }, { "Modulo", "Math", [] { return std::make_unique(); } }, { "Sin", "Math", [] { return std::make_unique(); } }, { "Cos", "Math", [] { return std::make_unique(); } }, { "Less", "Compare", [] { return std::make_unique(); } }, { "Greater", "Compare", [] { return std::make_unique(); } }, { "LessEqual", "Compare", [] { return std::make_unique(); } }, { "GreaterEqual", "Compare", [] { return std::make_unique(); } }, { "Equal", "Compare", [] { return std::make_unique(); } }, { "And", "Logic", [] { return std::make_unique(); } }, { "Or", "Logic", [] { return std::make_unique(); } }, { "Not", "Logic", [] { return std::make_unique(); } }, { "Branch", "Control", [] { return std::make_unique(); } }, { "QueryTile", "Query", [] { return std::make_unique(0, -1, 1); } }, { "QueryRange", "Query", [] { return std::make_unique(-1, -1, 1, 1, 1); } }, { "QueryDistance", "Query", [] { return std::make_unique(1, 4); } }, }; // ───────────────────────────────────────────────────────────────────────────── // NodeEditorApp // ───────────────────────────────────────────────────────────────────────────── class NodeEditorApp { public: NodeEditorApp() { worldOutputTex = MakePreviewTexture(); } ~NodeEditorApp() { for (auto& [id, vn] : visualNodes) vn.FreeTex(); if (worldOutputTex) glDeleteTextures(1, &worldOutputTex); } // ── Preview settings ────────────────────────────────────────────────────── int previewOriginX { 0 }; int previewOriginY { 0 }; float previewScale { 1.0f }; // world units per pixel (per-node previews only) uint64_t previewSeed { 0 }; // ── Public render entry point ───────────────────────────────────────────── void Render() { // Position World Output node on first frame (or after NewGraph). if (pendingWorldOutputPos) { pendingWorldOutputPos = false; ImNodes::SetNodeGridSpacePos(WORLD_OUTPUT_IMNODES_ID, ImVec2(400.0f, 0.0f)); } RenderDirtyPreviews(); DrawMenuBar(); ImGui::SetNextWindowPos(ImVec2(0, 18), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(200, ImGui::GetIO().DisplaySize.y - 18), ImGuiCond_Always); ImGui::Begin("Settings", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoBringToFrontOnFocus); DrawSettingsPanel(); ImGui::End(); ImGui::SetNextWindowPos(ImVec2(200, 18), ImGuiCond_Always); ImGui::SetNextWindowSize( ImVec2(ImGui::GetIO().DisplaySize.x - 200, ImGui::GetIO().DisplaySize.y - 18), ImGuiCond_Always); ImGui::Begin("Node Canvas", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); DrawNodeCanvas(); ImGui::End(); } private: Graph graph; std::unordered_map visualNodes; std::string currentFile; std::vector tileRegistry { TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}} }; // ── World Output node state ─────────────────────────────────────────────── // Each entry is the graph node ID connected to that pass slot (INVALID_ID = empty). std::vector worldOutputPasses { Graph::INVALID_ID }; GLuint worldOutputTex { 0 }; bool worldOutputDirty { true }; // Deferred first-frame positioning of the World Output node. bool pendingWorldOutputPos { true }; // ── Preview rendering ───────────────────────────────────────────────────── void RenderDirtyPreviews() { std::array pixels; // Per-node previews (evaluate single node across a grid). for (auto& [id, vn] : visualNodes) { if (!vn.dirty) continue; vn.dirty = false; Node* node = graph.GetNode(id); if (!node) continue; for (int py = 0; py < PREVIEW_SIZE; ++py) { for (int px = 0; px < PREVIEW_SIZE; ++px) { EvalContext ctx; ctx.worldX = previewOriginX + static_cast(px * previewScale); ctx.worldY = previewOriginY + static_cast(py * previewScale); ctx.seed = previewSeed; Value v = graph.Evaluate(id, ctx); int base = (py * PREVIEW_SIZE + px) * 4; ValueToRGBA(v, &tileRegistry, pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]); } } glBindTexture(GL_TEXTURE_2D, vn.tex); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, PREVIEW_SIZE, PREVIEW_SIZE, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); } // World Output preview — runs GenerateChunk with all connected passes. if (worldOutputDirty) { worldOutputDirty = false; std::vector passes; for (auto nodeID : worldOutputPasses) { if (nodeID != Graph::INVALID_ID && graph.GetNode(nodeID)) passes.push_back({ graph, nodeID }); } if (passes.empty()) { pixels.fill(30); for (int i = 3; i < (int)pixels.size(); i += 4) pixels[i] = 255; } else { // Each pixel = 1 world tile starting at previewOriginX/Y. TileGrid chunk = GenerateChunk(passes, previewOriginX, previewOriginY, PREVIEW_SIZE, PREVIEW_SIZE, previewSeed); for (int py = 0; py < PREVIEW_SIZE; ++py) { for (int px = 0; px < PREVIEW_SIZE; ++px) { int32_t tileID = chunk.Get(previewOriginX + px, previewOriginY + py); int base = (py * PREVIEW_SIZE + px) * 4; Value v = Value::MakeInt(tileID); ValueToRGBA(v, &tileRegistry, pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]); } } } glBindTexture(GL_TEXTURE_2D, worldOutputTex); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, PREVIEW_SIZE, PREVIEW_SIZE, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); } } void MarkAllDirty() { for (auto& [id, vn] : visualNodes) vn.dirty = true; worldOutputDirty = true; } // ── UI drawing ──────────────────────────────────────────────────────────── void DrawMenuBar() { if (!ImGui::BeginMainMenuBar()) return; if (ImGui::BeginMenu("File")) { if (ImGui::MenuItem("New")) { NewGraph(); } if (ImGui::MenuItem("Open...", "Ctrl+O")) { OpenFileDialog(); } if (ImGui::MenuItem("Save", "Ctrl+S")) { SaveCurrent(); } if (ImGui::MenuItem("Save As...")) { SaveAsDialog(); } ImGui::EndMenu(); } if (!currentFile.empty()) ImGui::TextDisabled(" %s", currentFile.c_str()); ImGui::EndMainMenuBar(); } void DrawSettingsPanel() { ImGui::SeparatorText("Preview"); bool changed = false; changed |= ImGui::DragInt("Origin X", &previewOriginX, 1.0f); changed |= ImGui::DragInt("Origin Y", &previewOriginY, 1.0f); changed |= ImGui::DragFloat("Scale", &previewScale, 0.1f, 0.1f, 64.0f, "%.2f wp/px"); int seed32 = static_cast(previewSeed & 0xFFFFFFFFu); if (ImGui::DragInt("Seed", &seed32)) { previewSeed = static_cast(static_cast(seed32)); changed = true; } if (changed) MarkAllDirty(); ImGui::Spacing(); ImGui::SeparatorText("Tile IDs"); int toRemove = -1; for (int i = 0; i < static_cast(tileRegistry.size()); ++i) { ImGui::PushID(i); TileEntry& entry = tileRegistry[i]; // Color button — opens a popup picker. char cpopup[32]; snprintf(cpopup, sizeof(cpopup), "##cpick%d", i); if (ImGui::ColorButton(cpopup, ImVec4(entry.color[0], entry.color[1], entry.color[2], 1.0f), ImGuiColorEditFlags_NoTooltip | ImGuiColorEditFlags_NoBorder, ImVec2(18, 18))) { ImGui::OpenPopup(cpopup); } if (ImGui::BeginPopup(cpopup)) { if (ImGui::ColorPicker3("##cp", entry.color, ImGuiColorEditFlags_NoLabel | ImGuiColorEditFlags_NoSidePreview)) MarkAllDirty(); ImGui::EndPopup(); } ImGui::SameLine(0, 3); // ID field (narrow) — locked for the reserved Empty entry ImGui::BeginDisabled(entry.id == 0); ImGui::SetNextItemWidth(32); ImGui::DragInt("##tid", &entry.id, 1, 0, 9999); ImGui::EndDisabled(); ImGui::SameLine(0, 3); // Name field (fills remaining width minus remove button) ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 20); char buf[64]; strncpy(buf, entry.name.c_str(), sizeof(buf) - 1); buf[sizeof(buf) - 1] = '\0'; if (ImGui::InputText("##tname", buf, sizeof(buf))) entry.name = buf; ImGui::SameLine(0, 3); // ID 0 "Empty" is permanent — show a disabled remove button ImGui::BeginDisabled(entry.id == 0); if (ImGui::SmallButton("x")) toRemove = i; ImGui::EndDisabled(); ImGui::PopID(); } if (toRemove >= 0) tileRegistry.erase(tileRegistry.begin() + toRemove); if (ImGui::SmallButton("+ Add Tile")) { int32_t nextId = tileRegistry.empty() ? 1 : tileRegistry.back().id + 1; TileEntry entry; entry.id = nextId; entry.name = "Tile"; // Seed the default color from the hash so new tiles start distinct. uint8_t hr, hg, hb; uint32_t h = static_cast(nextId) * 2654435761u; HsvToRgb((h & 0xFFu) / 255.0f, 0.7f, 0.85f, hr, hg, hb); entry.color[0] = hr / 255.0f; entry.color[1] = hg / 255.0f; entry.color[2] = hb / 255.0f; tileRegistry.push_back(entry); } ImGui::Spacing(); ImGui::SeparatorText("World Output"); int connected = 0; for (auto id : worldOutputPasses) if (id != Graph::INVALID_ID) ++connected; ImGui::Text("%d / %zu pass(es) connected", connected, worldOutputPasses.size()); ImGui::Spacing(); ImGui::SeparatorText("Graph"); ImGui::Text("%zu nodes", graph.NodeCount()); ImGui::Spacing(); ImGui::SeparatorText("Help"); ImGui::TextWrapped( "Right-click canvas to add nodes.\n" "Drag an output pin into a World Output pass slot to register a generation pass.\n" "Delete key removes selected nodes.\n" "Scale controls per-node previews only; World Output always shows 1 tile/pixel.\n" "\nQuery nodes preview blank (no prev-pass data in per-node preview)." ); } void DrawNodeCanvas() { ImNodes::BeginNodeEditor(); // Right-click blank canvas → add node menu if (ImGui::IsWindowHovered(ImGuiFocusedFlags_RootAndChildWindows) && ImNodes::IsEditorHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Right) && !ImGui::IsAnyItemHovered()) { ImGui::OpenPopup("##add_node_menu"); } ImGui::SetNextWindowSize(ImVec2(220, 300), ImGuiCond_Always); if (ImGui::BeginPopup("##add_node_menu")) { ImVec2 spawnPos = ImGui::GetMousePosOnOpeningCurrentPopup(); static char filterBuf[128] = ""; if (ImGui::IsWindowAppearing()) { ImGui::SetKeyboardFocusHere(); filterBuf[0] = '\0'; } ImGui::SetNextItemWidth(-1); ImGui::InputText("##filter", filterBuf, sizeof(filterBuf)); ImGui::Separator(); const bool filtering = filterBuf[0] != '\0'; auto spawnItem = [&](const NodeMenuItem& item) { if (ImGui::MenuItem(item.label)) { Graph::NodeID newId = AddNode(item.create()); ImNodes::SetNodeScreenSpacePos(static_cast(newId), spawnPos); } }; // Scrollable list region — fills the remaining space below the filter. ImGui::BeginChild("##node_list", ImVec2(0, 0), false); if (filtering) { // Case-insensitive flat list — matches label or category name. std::string needle = filterBuf; for (char& c : needle) c = static_cast(std::tolower(static_cast(c))); for (const auto& item : NODE_MENU) { auto contains = [&](const char* s) { std::string hay = s; for (char& c : hay) c = static_cast(std::tolower(static_cast(c))); return hay.find(needle) != std::string::npos; }; if (contains(item.label) || contains(item.category)) spawnItem(item); } } else { // Tree view grouped by category, all expanded by default. const char* curCat = nullptr; bool catOpen = false; for (const auto& item : NODE_MENU) { if (!curCat || strcmp(curCat, item.category) != 0) { if (curCat && catOpen) ImGui::TreePop(); catOpen = ImGui::TreeNodeEx(item.category, ImGuiTreeNodeFlags_DefaultOpen); curCat = item.category; } if (catOpen) spawnItem(item); } if (curCat && catOpen) ImGui::TreePop(); } ImGui::EndChild(); ImGui::EndPopup(); } // Draw the permanent World Output node first (so it's rendered behind others). DrawWorldOutputNode(); // Draw all regular nodes. for (auto& [id, vn] : visualNodes) DrawNode(vn); // Draw regular graph connections. int linkId = 0; for (auto& [id, vn] : visualNodes) { Node* node = graph.GetNode(id); if (!node) continue; for (int slot = 0; slot < static_cast(node->GetInputCount()); ++slot) { auto src = graph.GetInput(id, slot); if (src.has_value()) ImNodes::Link(linkId++, OutputPin(*src), InputPin(id, slot)); } } // Draw World Output pass connections (fixed link IDs avoid re-enumeration issues). for (int i = 0; i < static_cast(worldOutputPasses.size()); ++i) { if (worldOutputPasses[i] != Graph::INVALID_ID) ImNodes::Link(WORLD_OUTPUT_LINK_BASE + i, OutputPin(worldOutputPasses[i]), WORLD_OUTPUT_PIN_BASE + i); } ImNodes::EndNodeEditor(); // ── Handle new connections ──────────────────────────────────────────── int fromAttr, toAttr; if (ImNodes::IsLinkCreated(&fromAttr, &toAttr)) { bool fromIsWO = IsWorldOutputPin(fromAttr); bool toIsWO = IsWorldOutputPin(toAttr); if (toIsWO || fromIsWO) { // One end is a World Output pass slot; the other must be a regular output. int woPin = toIsWO ? toAttr : fromAttr; int regularPin = toIsWO ? fromAttr : toAttr; if (PinToSlot(regularPin) == 9) { // must be an output pin int passIdx = woPin - WORLD_OUTPUT_PIN_BASE; if (passIdx >= 0 && passIdx < static_cast(worldOutputPasses.size())) { // World Output only accepts Int (tile ID) outputs. Node* srcNode = graph.GetNode(PinToNode(regularPin)); if (srcNode && srcNode->GetOutputType() == Type::Int) { worldOutputPasses[passIdx] = PinToNode(regularPin); worldOutputDirty = true; } } } } else { // Regular node-to-node connection. Graph::NodeID fromNode, toNode; int toSlot; if (PinToSlot(fromAttr) == 9 && PinToSlot(toAttr) != 9) { fromNode = PinToNode(fromAttr); toNode = PinToNode(toAttr); toSlot = PinToSlot(toAttr); } else if (PinToSlot(toAttr) == 9 && PinToSlot(fromAttr) != 9) { fromNode = PinToNode(toAttr); toNode = PinToNode(fromAttr); toSlot = PinToSlot(fromAttr); } else { goto done_link; } // Only connect when output type matches the destination input type. Node* srcNode = graph.GetNode(fromNode); Node* dstNode = graph.GetNode(toNode); if (srcNode && dstNode) { auto dstInputTypes = dstNode->GetInputTypes(); if (toSlot < static_cast(dstInputTypes.size()) && srcNode->GetOutputType() == dstInputTypes[toSlot]) { if (graph.Connect(fromNode, toNode, toSlot)) MarkAllDirty(); } } } } done_link:; // ── Handle link deletion ────────────────────────────────────────────── int destroyedLink; if (ImNodes::IsLinkDestroyed(&destroyedLink)) { if (destroyedLink >= WORLD_OUTPUT_LINK_BASE) { // World Output link int passIdx = destroyedLink - WORLD_OUTPUT_LINK_BASE; if (passIdx >= 0 && passIdx < static_cast(worldOutputPasses.size())) { worldOutputPasses[passIdx] = Graph::INVALID_ID; worldOutputDirty = true; } } else { // Regular link — re-enumerate to find which one. int idx = 0; bool found = false; for (auto& [id, vn] : visualNodes) { if (found) break; Node* node = graph.GetNode(id); if (!node) continue; for (int slot = 0; slot < static_cast(node->GetInputCount()); ++slot) { if (graph.GetInput(id, slot).has_value()) { if (idx == destroyedLink) { graph.Disconnect(id, slot); MarkAllDirty(); found = true; break; } ++idx; } } } } } // ── Delete selected nodes ───────────────────────────────────────────── if (ImGui::IsKeyPressed(ImGuiKey_Delete)) { int count = ImNodes::NumSelectedNodes(); if (count > 0) { std::vector selected(count); ImNodes::GetSelectedNodes(selected.data()); for (int sid : selected) { if (sid == WORLD_OUTPUT_IMNODES_ID) continue; // permanent node Graph::NodeID gid = static_cast(sid); // Clear any World Output pass that referenced this node. for (auto& pass : worldOutputPasses) if (pass == gid) { pass = Graph::INVALID_ID; worldOutputDirty = true; } graph.RemoveNode(gid); auto it = visualNodes.find(gid); if (it != visualNodes.end()) { it->second.FreeTex(); visualNodes.erase(it); } } MarkAllDirty(); } } } // ── World Output node ───────────────────────────────────────────────────── void DrawWorldOutputNode() { ImNodes::PushColorStyle(ImNodesCol_TitleBar, IM_COL32( 30, 80, 160, 255)); ImNodes::PushColorStyle(ImNodesCol_TitleBarHovered, IM_COL32( 45, 100, 190, 255)); ImNodes::PushColorStyle(ImNodesCol_TitleBarSelected, IM_COL32( 60, 120, 210, 255)); ImNodes::BeginNode(WORLD_OUTPUT_IMNODES_ID); ImNodes::BeginNodeTitleBar(); ImGui::Text("World Output"); ImNodes::EndNodeTitleBar(); // One input pin per generation pass — Int only. for (int i = 0; i < static_cast(worldOutputPasses.size()); ++i) { ImNodes::PushColorStyle(ImNodesCol_Pin, PinColor(Type::Int)); ImNodes::PushColorStyle(ImNodesCol_PinHovered, PinColorHovered(Type::Int)); ImNodes::BeginInputAttribute(WORLD_OUTPUT_PIN_BASE + i); Graph::NodeID connected = worldOutputPasses[i]; if (connected != Graph::INVALID_ID) { Node* n = graph.GetNode(connected); ImGui::Text("Pass %d (%s)", i, n ? n->GetName().c_str() : "?"); } else { ImGui::TextDisabled("Pass %d (empty)", i); } ImNodes::EndInputAttribute(); ImNodes::PopColorStyle(); ImNodes::PopColorStyle(); } // Add / remove pass buttons. ImNodes::BeginStaticAttribute(WORLD_OUTPUT_PIN_BASE + 500); if (ImGui::SmallButton("+ Pass")) { worldOutputPasses.push_back(Graph::INVALID_ID); } if (worldOutputPasses.size() > 1) { ImGui::SameLine(); if (ImGui::SmallButton("- Pass")) { worldOutputPasses.pop_back(); worldOutputDirty = true; } } ImNodes::EndStaticAttribute(); // Generated chunk preview — always 1 tile per pixel. ImGui::Image( static_cast(static_cast(worldOutputTex)), ImVec2(PREVIEW_SIZE, PREVIEW_SIZE)); ImNodes::EndNode(); ImNodes::PopColorStyle(); ImNodes::PopColorStyle(); ImNodes::PopColorStyle(); } // ── Regular node ───────────────────────────────────────────────────────── void DrawNode(VisualNode& vn) { Node* node = graph.GetNode(vn.id); if (!node) return; ImNodes::BeginNode(static_cast(vn.id)); ImNodes::BeginNodeTitleBar(); ImGui::Text("%s", node->GetName().c_str()); ImNodes::EndNodeTitleBar(); // Input pins. auto inputTypes = node->GetInputTypes(); for (int slot = 0; slot < static_cast(inputTypes.size()); ++slot) { ImNodes::PushColorStyle(ImNodesCol_Pin, PinColor(inputTypes[slot])); ImNodes::PushColorStyle(ImNodesCol_PinHovered, PinColorHovered(inputTypes[slot])); ImNodes::BeginInputAttribute(InputPin(vn.id, slot)); const char* t = inputTypes[slot] == Type::Float ? "f" : inputTypes[slot] == Type::Int ? "i" : "b"; ImGui::TextDisabled("[%s] in%d", t, slot); ImNodes::EndInputAttribute(); ImNodes::PopColorStyle(); ImNodes::PopColorStyle(); } DrawNodeParams(vn.id, node); ImGui::Image( static_cast(static_cast(vn.tex)), ImVec2(PREVIEW_SIZE, PREVIEW_SIZE)); ImNodes::PushColorStyle(ImNodesCol_Pin, PinColor(node->GetOutputType())); ImNodes::PushColorStyle(ImNodesCol_PinHovered, PinColorHovered(node->GetOutputType())); ImNodes::BeginOutputAttribute(OutputPin(vn.id)); const char* outType = node->GetOutputType() == Type::Float ? "Float" : node->GetOutputType() == Type::Int ? "Int" : "Bool"; ImGui::Text("out (%s) →", outType); ImNodes::EndOutputAttribute(); ImNodes::PopColorStyle(); ImNodes::PopColorStyle(); ImNodes::EndNode(); } // ── Per-type param drawers ──────────────────────────────────────────────── // Each function receives the app (for instance state like tileRegistry) and // the node, and returns true when any value changed. // Static member functions can access private members through the app ref. // // To support a new node type: // 1. Add a Draw*Params function here. // 2. Register it in the kDrawers table in DrawNodeParams below. static bool DrawConstantParams(NodeEditorApp& /*app*/, Node* node) { auto* n = static_cast(node); return ImGui::DragFloat("##val", &n->value, 0.01f); } static bool DrawIDParams(NodeEditorApp& app, Node* node) { auto* n = static_cast(node); bool changed = false; if (app.tileRegistry.empty()) { changed = ImGui::DragInt("##id", &n->tileID, 1, 0, 9999); } else { int selIdx = -1; for (int i = 0; i < static_cast(app.tileRegistry.size()); ++i) if (app.tileRegistry[i].id == n->tileID) { selIdx = i; break; } const char* preview = selIdx >= 0 ? app.tileRegistry[selIdx].name.c_str() : "???"; ImGui::SetNextItemWidth(130); if (ImGui::BeginCombo("##tile", preview)) { for (int i = 0; i < static_cast(app.tileRegistry.size()); ++i) { bool sel = (i == selIdx); char label[80]; snprintf(label, sizeof(label), "%s (%d)", app.tileRegistry[i].name.c_str(), app.tileRegistry[i].id); if (ImGui::Selectable(label, sel)) { n->tileID = app.tileRegistry[i].id; changed = true; } if (sel) ImGui::SetItemDefaultFocus(); } ImGui::EndCombo(); } } return changed; } static bool DrawQueryTileParams(NodeEditorApp& /*app*/, Node* node) { auto* n = static_cast(node); bool changed = false; changed |= ImGui::DragInt("##ox", &n->offsetX, 1); ImGui::SameLine(); changed |= ImGui::DragInt("##oy", &n->offsetY, 1); changed |= ImGui::DragInt("##eid", &n->expectedID, 1, 0, 1024); return changed; } static bool DrawQueryRangeParams(NodeEditorApp& /*app*/, Node* node) { auto* n = static_cast(node); bool changed = false; changed |= ImGui::DragInt("##mnx", &n->minX, 1); ImGui::SameLine(); changed |= ImGui::DragInt("##mxx", &n->maxX, 1); changed |= ImGui::DragInt("##mny", &n->minY, 1); ImGui::SameLine(); changed |= ImGui::DragInt("##mxy", &n->maxY, 1); changed |= ImGui::DragInt("##rtid", &n->tileID, 1, 0, 1024); return changed; } static bool DrawQueryDistanceParams(NodeEditorApp& /*app*/, Node* node) { auto* n = static_cast(node); bool changed = false; changed |= ImGui::DragInt("##dtid", &n->tileID, 1, 0, 1024); changed |= ImGui::DragInt("##md", &n->maxDistance, 1, 1, 32); return changed; } // ── Param dispatcher ────────────────────────────────────────────────────── void DrawNodeParams(Graph::NodeID /*id*/, Node* node) { using DrawFn = bool(*)(NodeEditorApp&, Node*); static const std::unordered_map kDrawers = { { typeid(ConstantNode), DrawConstantParams }, { typeid(IDNode), DrawIDParams }, { typeid(QueryTileNode), DrawQueryTileParams }, { typeid(QueryRangeNode), DrawQueryRangeParams }, { typeid(QueryDistanceNode), DrawQueryDistanceParams }, }; auto it = kDrawers.find(typeid(*node)); if (it != kDrawers.end() && it->second(*this, node)) MarkAllDirty(); } // ── Graph management ────────────────────────────────────────────────────── Graph::NodeID AddNode(std::unique_ptr node) { Graph::NodeID id = graph.AddNode(std::move(node)); VisualNode vn; vn.id = id; vn.dirty = true; vn.InitTex(); visualNodes.emplace(id, std::move(vn)); return id; } void NewGraph() { for (auto& [id, vn] : visualNodes) vn.FreeTex(); visualNodes.clear(); graph = Graph{}; worldOutputPasses = { Graph::INVALID_ID }; worldOutputDirty = true; pendingWorldOutputPos = true; currentFile = ""; } // ── Serialization ───────────────────────────────────────────────────────── void Save(const std::string& path) { nlohmann::json root; root["graph"] = GraphSerializer::ToJson(graph); // Node positions (including World Output). nlohmann::json positions = nlohmann::json::object(); for (auto& [id, vn] : visualNodes) { ImVec2 pos = ImNodes::GetNodeGridSpacePos(static_cast(id)); positions[std::to_string(id)] = { pos.x, pos.y }; } { ImVec2 pos = ImNodes::GetNodeGridSpacePos(WORLD_OUTPUT_IMNODES_ID); positions["__worldOutput"] = { pos.x, pos.y }; } root["editor"]["nodePositions"] = positions; root["editor"]["seed"] = previewSeed; root["editor"]["previewOriginX"] = previewOriginX; root["editor"]["previewOriginY"] = previewOriginY; root["editor"]["previewScale"] = previewScale; nlohmann::json passes = nlohmann::json::array(); for (auto id : worldOutputPasses) passes.push_back(id); root["editor"]["worldOutputPasses"] = passes; nlohmann::json tiles = nlohmann::json::array(); for (auto& t : tileRegistry) tiles.push_back({ {"id", t.id}, {"name", t.name}, {"color", { t.color[0], t.color[1], t.color[2] }} }); root["editor"]["tileRegistry"] = tiles; std::ofstream f(path); if (f) { f << root.dump(2); currentFile = path; } else fprintf(stderr, "Failed to write %s\n", path.c_str()); } void Load(const std::string& path) { std::ifstream f(path); if (!f) { fprintf(stderr, "Cannot open %s\n", path.c_str()); return; } nlohmann::json root; try { root = nlohmann::json::parse(f); } catch (...) { fprintf(stderr, "JSON parse error in %s\n", path.c_str()); return; } auto newGraph = GraphSerializer::FromJson(root.value("graph", nlohmann::json::object())); if (!newGraph) { fprintf(stderr, "Invalid graph in %s\n", path.c_str()); return; } NewGraph(); graph = std::move(*newGraph); uint32_t maxId = root["graph"].value("nextId", 1u); for (uint32_t id = 1; id < maxId; ++id) { if (graph.GetNode(id)) { VisualNode vn; vn.id = id; vn.dirty = true; vn.InitTex(); visualNodes.emplace(id, std::move(vn)); } } if (root.contains("editor")) { const auto& ed = root["editor"]; previewSeed = ed.value("seed", uint64_t{0}); previewOriginX = ed.value("previewOriginX", 0); previewOriginY = ed.value("previewOriginY", 0); previewScale = ed.value("previewScale", 1.0f); if (ed.contains("tileRegistry")) { tileRegistry.clear(); for (auto& t : ed["tileRegistry"]) { TileEntry entry; entry.id = t.value("id", 0); entry.name = t.value("name", std::string("Tile")); if (t.contains("color") && t["color"].size() == 3) { entry.color[0] = t["color"][0].get(); entry.color[1] = t["color"][1].get(); entry.color[2] = t["color"][2].get(); } tileRegistry.push_back(entry); } // Always guarantee the Empty (ID 0) sentinel is present. bool hasEmpty = std::any_of(tileRegistry.begin(), tileRegistry.end(), [](const TileEntry& e) { return e.id == 0; }); if (!hasEmpty) tileRegistry.insert(tileRegistry.begin(), TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}}); } if (ed.contains("worldOutputPasses")) { worldOutputPasses.clear(); for (auto& v : ed["worldOutputPasses"]) worldOutputPasses.push_back(v.get()); if (worldOutputPasses.empty()) worldOutputPasses.push_back(Graph::INVALID_ID); } if (ed.contains("nodePositions")) { for (auto& [key, val] : ed["nodePositions"].items()) { float x = val[0].get(); float y = val[1].get(); if (key == "__worldOutput") { ImNodes::SetNodeGridSpacePos(WORLD_OUTPUT_IMNODES_ID, ImVec2(x, y)); pendingWorldOutputPos = false; } else { ImNodes::SetNodeGridSpacePos(std::stoi(key), ImVec2(x, y)); } } } } currentFile = path; } // ── File dialogs ────────────────────────────────────────────────────────── void OpenFileDialog() { IGFD::FileDialogConfig cfg; cfg.path = currentFile.empty() ? "." : std::filesystem::path(currentFile).parent_path().string(); ImGuiFileDialog::Instance()->OpenDialog("OpenFileDlg", "Open Graph", ".json", cfg); } void SaveAsDialog() { IGFD::FileDialogConfig cfg; if (!currentFile.empty()) { std::filesystem::path p(currentFile); cfg.path = p.parent_path().string(); cfg.fileName = p.filename().string(); } else { cfg.path = "."; cfg.fileName = "graph.json"; } ImGuiFileDialog::Instance()->OpenDialog("SaveFileDlg", "Save Graph", ".json", cfg); } void SaveCurrent() { if (currentFile.empty()) SaveAsDialog(); else Save(currentFile); } public: void RenderFileDialogs() { ImVec2 dlgSize(600, 400); if (ImGuiFileDialog::Instance()->Display("OpenFileDlg", ImGuiWindowFlags_NoCollapse, dlgSize)) { if (ImGuiFileDialog::Instance()->IsOk()) Load(ImGuiFileDialog::Instance()->GetFilePathName()); ImGuiFileDialog::Instance()->Close(); } if (ImGuiFileDialog::Instance()->Display("SaveFileDlg", ImGuiWindowFlags_NoCollapse, dlgSize)) { if (ImGuiFileDialog::Instance()->IsOk()) Save(ImGuiFileDialog::Instance()->GetFilePathName()); ImGuiFileDialog::Instance()->Close(); } } void HandleKeyboardShortcuts() { ImGuiIO& io = ImGui::GetIO(); if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_S)) SaveCurrent(); if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_O)) ImGui::OpenPopup("##open_path"); } }; // ───────────────────────────────────────────────────────────────────────────── // Main / render loop // ───────────────────────────────────────────────────────────────────────────── static void GlfwErrorCallback(int error, const char* desc) { fprintf(stderr, "GLFW error %d: %s\n", error, desc); } int main() { glfwSetErrorCallback(GlfwErrorCallback); if (!glfwInit()) return 1; glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); #ifdef __APPLE__ glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); #endif GLFWwindow* window = glfwCreateWindow(1280, 720, "WorldGraph Node Editor", nullptr, nullptr); if (!window) { glfwTerminate(); return 1; } glfwMakeContextCurrent(window); glfwSwapInterval(1); IMGUI_CHECKVERSION(); ImGui::CreateContext(); ImNodes::CreateContext(); ImGuiIO& io = ImGui::GetIO(); io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; ImGui::StyleColorsDark(); ImNodes::StyleColorsDark(); ImNodes::GetStyle().NodePadding = ImVec2(8, 8); ImGui_ImplGlfw_InitForOpenGL(window, true); ImGui_ImplOpenGL3_Init("#version 330"); NodeEditorApp app; while (!glfwWindowShouldClose(window)) { glfwPollEvents(); ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplGlfw_NewFrame(); ImGui::NewFrame(); app.HandleKeyboardShortcuts(); app.Render(); app.RenderFileDialogs(); ImGui::Render(); int dispW, dispH; glfwGetFramebufferSize(window, &dispW, &dispH); glViewport(0, 0, dispW, dispH); glClearColor(0.15f, 0.15f, 0.15f, 1.0f); 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; }