// 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_internal.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 #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 constexpr float PREVIEW_SCALE = 1.5f; // world units per pixel static constexpr float FINAL_PREVIEW_SCALE = 4.0f; // for World Output node 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 = { // ── Source ────────────────────────────────────────────────────────────── { "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(); } }, { "LayerStrength", "Source", [] { return std::make_unique(); } }, // ── Math ──────────────────────────────────────────────────────────────── { "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(); } }, { "Floor", "Math", [] { return std::make_unique(); } }, { "Ceil", "Math", [] { return std::make_unique(); } }, { "Sqrt", "Math", [] { return std::make_unique(); } }, { "Pow", "Math", [] { return std::make_unique(); } }, { "Square", "Math", [] { return std::make_unique(); } }, { "Abs", "Math", [] { return std::make_unique(); } }, { "Negate", "Math", [] { return std::make_unique(); } }, { "OneMinus", "Math", [] { return std::make_unique(); } }, { "Min", "Math", [] { return std::make_unique(); } }, { "Max", "Math", [] { return std::make_unique(); } }, { "Clamp", "Math", [] { return std::make_unique(); } }, { "Map", "Math", [] { return std::make_unique(-1.0f, 1.0f, 0.0f, 1.0f); } }, // ── Compare ───────────────────────────────────────────────────────────── { "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(); } }, // ── Logic ─────────────────────────────────────────────────────────────── { "And", "Logic", [] { return std::make_unique(); } }, { "Or", "Logic", [] { return std::make_unique(); } }, { "Not", "Logic", [] { return std::make_unique(); } }, { "Xor", "Logic", [] { return std::make_unique(); } }, { "Nand", "Logic", [] { return std::make_unique(); } }, { "Nor", "Logic", [] { return std::make_unique(); } }, { "Xnor", "Logic", [] { return std::make_unique(); } }, // ── Control ───────────────────────────────────────────────────────────── { "Branch", "Control", [] { return std::make_unique(); } }, { "IntBranch", "Control", [] { return std::make_unique(); } }, // ── Query ─────────────────────────────────────────────────────────────── { "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); } }, { "QueryLiquid", "Query", [] { return std::make_unique(8, 4); } }, // ── Noise ─────────────────────────────────────────────────────────────── { "Random", "Noise", [] { return std::make_unique(); } }, { "PerlinNoise", "Noise", [] { return std::make_unique(0.01f); } }, { "SimplexNoise", "Noise", [] { return std::make_unique(0.01f); } }, { "CellularNoise", "Noise", [] { return std::make_unique(0.01f); } }, { "ValueNoise", "Noise", [] { return std::make_unique(0.01f); } }, }; // ───────────────────────────────────────────────────────────────────────────── // GraphTab – all state for one open graph (one browser-style tab) // ───────────────────────────────────────────────────────────────────────────── struct GraphTab { std::string currentFile; Graph graph; std::unordered_map visualNodes; std::vector worldOutputPasses; GLuint worldOutputTex { 0 }; bool worldOutputDirty { true }; bool pendingWorldOutputPos { true }; int previewOriginX { 0 }; int previewOriginY { 0 }; float previewScale { 1.0f }; uint64_t previewSeed { 0 }; ImNodesEditorContext* imnodesCtx { nullptr }; void Init() { worldOutputPasses = { Graph::INVALID_ID }; worldOutputTex = MakePreviewTexture(); imnodesCtx = ImNodes::EditorContextCreate(); } void Free() { for (auto& [id, vn] : visualNodes) vn.FreeTex(); if (worldOutputTex) { glDeleteTextures(1, &worldOutputTex); worldOutputTex = 0; } if (imnodesCtx) { ImNodes::EditorContextFree(imnodesCtx); imnodesCtx = nullptr; } } std::string Label() const { if (currentFile.empty()) return "Untitled"; return std::filesystem::path(currentFile).filename().string(); } }; // ───────────────────────────────────────────────────────────────────────────── // NodeEditorApp // ───────────────────────────────────────────────────────────────────────────── class NodeEditorApp { public: NodeEditorApp() { NewTab(); } ~NodeEditorApp() { for (auto& tab : tabs) tab.Free(); } // ── Public render entry point ───────────────────────────────────────────── void Render() { // Restore last session written by the ini settings handler. if (!pendingLoadRegistry.empty()) { LoadSharedRegistry(pendingLoadRegistry); pendingLoadRegistry.clear(); } if (!pendingLoadFiles.empty()) { for (const auto& f : pendingLoadFiles) { auto& cur = Active(); if (cur.graph.NodeCount() == 0 && cur.currentFile.empty()) Load(f); else { NewTab(); Load(f); } } pendingLoadFiles.clear(); if (pendingActiveTab >= 0 && pendingActiveTab < static_cast(tabs.size())) requestedTab = activeTab = pendingActiveTab; pendingActiveTab = -1; } RenderDirtyPreviews(); DrawMenuBar(); constexpr float kMenuH = 18.0f; constexpr float kTabBarH = 24.0f; const float kContentY = kMenuH + kTabBarH; const float dispW = ImGui::GetIO().DisplaySize.x; const float dispH = ImGui::GetIO().DisplaySize.y; // Tab bar strip ImGui::SetNextWindowPos(ImVec2(0, kMenuH), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(dispW, kTabBarH), ImGuiCond_Always); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4, 3)); ImGui::Begin("##tabbar", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav); DrawTabBar(); ImGui::End(); ImGui::PopStyleVar(); ImGui::SetNextWindowPos(ImVec2(0, kContentY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(200, dispH - kContentY), ImGuiCond_Always); ImGui::Begin("Settings", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoBringToFrontOnFocus); DrawSettingsPanel(); ImGui::End(); ImGui::SetNextWindowPos(ImVec2(200, kContentY), ImGuiCond_Always); ImGui::SetNextWindowSize( ImVec2(dispW - 200, dispH - kContentY), ImGuiCond_Always); ImGui::Begin("Node Canvas", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); DrawNodeCanvas(); ImGui::End(); } private: std::vector tabs; int activeTab { 0 }; int requestedTab { -1 }; // set to trigger programmatic tab switch (one frame) // Shared tile registry — one across all tabs / graph files std::vector sharedRegistry { TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}} }; std::string sharedRegistryPath; // Pending data from ini settings handler std::vector pendingLoadFiles; int pendingActiveTab { -1 }; std::string pendingLoadRegistry; GraphTab& Active() { return tabs[activeTab]; } // ── Tab management ──────────────────────────────────────────────────────── void NewTab() { tabs.emplace_back(); tabs.back().Init(); requestedTab = activeTab = static_cast(tabs.size()) - 1; } void CloseTab(int idx) { tabs[idx].Free(); tabs.erase(tabs.begin() + idx); if (tabs.empty()) { NewTab(); return; } if (activeTab >= static_cast(tabs.size())) activeTab = static_cast(tabs.size()) - 1; requestedTab = activeTab; } void DrawTabBar() { ImGuiTabBarFlags flags = ImGuiTabBarFlags_Reorderable | ImGuiTabBarFlags_AutoSelectNewTabs; if (ImGui::BeginTabBar("##maintabs", flags)) { for (int i = 0; i < static_cast(tabs.size()); ++i) { bool open = true; std::string label = tabs[i].Label() + "##tab" + std::to_string(i); ImGuiTabItemFlags itemFlags = (requestedTab == i) ? ImGuiTabItemFlags_SetSelected : ImGuiTabItemFlags_None; if (ImGui::BeginTabItem(label.c_str(), &open, itemFlags)) { activeTab = i; ImGui::EndTabItem(); } if (!open) { CloseTab(i); break; // vector modified — restart next frame } } if (ImGui::TabItemButton("+", ImGuiTabItemFlags_Trailing | ImGuiTabItemFlags_NoTooltip)) NewTab(); ImGui::EndTabBar(); } requestedTab = -1; } // ── Preview rendering ───────────────────────────────────────────────────── void RenderDirtyPreviews() { auto& tab = Active(); std::array pixels; // Per-node previews (evaluate single node across a grid). for (auto& [id, vn] : tab.visualNodes) { if (!vn.dirty) continue; vn.dirty = false; Node* node = tab.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 = tab.previewOriginX + static_cast(px * tab.previewScale); ctx.worldY = tab.previewOriginY + static_cast((PREVIEW_SIZE - 1 - py) * tab.previewScale); ctx.seed = tab.previewSeed; Value v = tab.graph.Evaluate(id, ctx); int base = (py * PREVIEW_SIZE + px) * 4; ValueToRGBA(v, &sharedRegistry, 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 (tab.worldOutputDirty) { tab.worldOutputDirty = false; std::vector passes; for (auto nodeID : tab.worldOutputPasses) { if (nodeID != Graph::INVALID_ID && tab.graph.GetNode(nodeID)) passes.push_back({ tab.graph, nodeID }); } if (passes.empty()) { pixels.fill(30); for (int i = 3; i < (int)pixels.size(); i += 4) pixels[i] = 255; } else { TileGrid chunk = GenerateChunk(passes, tab.previewOriginX, tab.previewOriginY, PREVIEW_SIZE, PREVIEW_SIZE, tab.previewSeed); for (int py = 0; py < PREVIEW_SIZE; ++py) { for (int px = 0; px < PREVIEW_SIZE; ++px) { int32_t tileID = chunk.Get(tab.previewOriginX + px, tab.previewOriginY + (PREVIEW_SIZE - 1 - py)); int base = (py * PREVIEW_SIZE + px) * 4; Value v = Value::MakeInt(tileID); ValueToRGBA(v, &sharedRegistry, pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]); } } } glBindTexture(GL_TEXTURE_2D, tab.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] : Active().visualNodes) vn.dirty = true; Active().worldOutputDirty = true; } // ── UI drawing ──────────────────────────────────────────────────────────── void DrawMenuBar() { if (!ImGui::BeginMainMenuBar()) return; if (ImGui::BeginMenu("File")) { if (ImGui::MenuItem("New", "Ctrl+N")) { NewTab(); } if (ImGui::MenuItem("Open...", "Ctrl+O")) { OpenFileDialog(); } if (ImGui::MenuItem("Close Tab", "Ctrl+W")) { CloseTab(activeTab); } if (ImGui::MenuItem("Save", "Ctrl+S")) { SaveCurrent(); } if (ImGui::MenuItem("Save As...")) { SaveAsDialog(); } ImGui::EndMenu(); } if (!Active().currentFile.empty()) ImGui::TextDisabled(" %s", Active().currentFile.c_str()); ImGui::EndMainMenuBar(); } void DrawSettingsPanel() { auto& tab = Active(); ImGui::SeparatorText("Preview"); bool changed = false; changed |= ImGui::DragInt("Origin X", &tab.previewOriginX, 1.0f); changed |= ImGui::DragInt("Origin Y", &tab.previewOriginY, 1.0f); changed |= ImGui::DragFloat("Scale", &tab.previewScale, 0.1f, 0.1f, 64.0f, "%.2f wp/px"); int seed32 = static_cast(tab.previewSeed & 0xFFFFFFFFu); if (ImGui::DragInt("Seed", &seed32)) { tab.previewSeed = static_cast(static_cast(seed32)); changed = true; } if (changed) MarkAllDirty(); ImGui::Spacing(); ImGui::SeparatorText("Tile IDs"); // Registry file path (read-only display) { const char* rpath = sharedRegistryPath.empty() ? "(unsaved)" : sharedRegistryPath.c_str(); ImGui::TextDisabled("%s", rpath); } if (ImGui::SmallButton("Open##reg")) OpenRegistryDialog(); ImGui::SameLine(); if (ImGui::SmallButton("Save##reg")) { if (sharedRegistryPath.empty()) SaveRegistryDialog(); else SaveSharedRegistry(sharedRegistryPath); } ImGui::SameLine(); if (ImGui::SmallButton("Save As##reg")) SaveRegistryDialog(); ImGui::Spacing(); int toRemove = -1; for (int i = 0; i < static_cast(sharedRegistry.size()); ++i) { ImGui::PushID(i); TileEntry& entry = sharedRegistry[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) sharedRegistry.erase(sharedRegistry.begin() + toRemove); if (ImGui::SmallButton("+ Add Tile")) { int32_t nextId = sharedRegistry.empty() ? 1 : sharedRegistry.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; sharedRegistry.push_back(entry); } ImGui::Spacing(); ImGui::SeparatorText("World Output"); int connected = 0; for (auto id : tab.worldOutputPasses) if (id != Graph::INVALID_ID) ++connected; ImGui::Text("%d / %zu pass(es) connected", connected, tab.worldOutputPasses.size()); ImGui::Spacing(); ImGui::SeparatorText("Graph"); ImGui::Text("%zu nodes", tab.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() { auto& tab = Active(); // Switch to this tab's imnodes context before any ImNodes:: calls. ImNodes::EditorContextSet(tab.imnodesCtx); // Position World Output node on first frame (or after load with no saved pos). if (tab.pendingWorldOutputPos) { tab.pendingWorldOutputPos = false; ImNodes::SetNodeGridSpacePos(WORLD_OUTPUT_IMNODES_ID, ImVec2(400.0f, 0.0f)); } // IsLinkHovered must be called outside BeginNodeEditor/EndNodeEditor. // s_linkWasHovered carries the previous frame's result so the canvas // right-click menu can be suppressed when the cursor is over a link. static bool s_linkWasHovered = false; ImNodes::BeginNodeEditor(); // Right-click blank canvas → add node menu if (ImGui::IsWindowHovered(ImGuiFocusedFlags_RootAndChildWindows) && ImNodes::IsEditorHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Right) && !ImGui::IsAnyItemHovered() && !s_linkWasHovered) { 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); ImGui::CloseCurrentPopup(); } }; // 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] : tab.visualNodes) DrawNode(vn); // Draw regular graph connections. int linkId = 0; for (auto& [id, vn] : tab.visualNodes) { Node* node = tab.graph.GetNode(id); if (!node) continue; for (int slot = 0; slot < static_cast(node->GetInputCount()); ++slot) { auto src = tab.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(tab.worldOutputPasses.size()); ++i) { if (tab.worldOutputPasses[i] != Graph::INVALID_ID) ImNodes::Link(WORLD_OUTPUT_LINK_BASE + i, OutputPin(tab.worldOutputPasses[i]), WORLD_OUTPUT_PIN_BASE + i); } ImNodes::EndNodeEditor(); // Query link hover state here — must be outside Begin/End editor. int hoveredLink = -1; bool linkIsHovered = ImNodes::IsLinkHovered(&hoveredLink); s_linkWasHovered = linkIsHovered; // ── 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(tab.worldOutputPasses.size())) { // World Output only accepts Int (tile ID) outputs. Node* srcNode = tab.graph.GetNode(PinToNode(regularPin)); if (srcNode && srcNode->GetOutputType() == Type::Int) { tab.worldOutputPasses[passIdx] = PinToNode(regularPin); tab.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 = tab.graph.GetNode(fromNode); Node* dstNode = tab.graph.GetNode(toNode); if (srcNode && dstNode) { auto dstInputTypes = dstNode->GetInputTypes(); if (toSlot < static_cast(dstInputTypes.size()) && srcNode->GetOutputType() == dstInputTypes[toSlot]) { if (tab.graph.Connect(fromNode, toNode, toSlot)) MarkAllDirty(); } } } } done_link:; // ── Handle link deletion ────────────────────────────────────────────── auto destroyLink = [&](int linkId) { if (linkId >= WORLD_OUTPUT_LINK_BASE) { int passIdx = linkId - WORLD_OUTPUT_LINK_BASE; if (passIdx >= 0 && passIdx < static_cast(tab.worldOutputPasses.size())) { tab.worldOutputPasses[passIdx] = Graph::INVALID_ID; tab.worldOutputDirty = true; } } else { // Regular link — re-enumerate to find which one. int idx = 0; bool found = false; for (auto& [id, vn] : tab.visualNodes) { if (found) break; Node* node = tab.graph.GetNode(id); if (!node) continue; for (int slot = 0; slot < static_cast(node->GetInputCount()); ++slot) { if (tab.graph.GetInput(id, slot).has_value()) { if (idx == linkId) { tab.graph.Disconnect(id, slot); MarkAllDirty(); found = true; break; } ++idx; } } } } }; int destroyedLink; if (ImNodes::IsLinkDestroyed(&destroyedLink)) destroyLink(destroyedLink); // ── Right-click a link → disconnect popup ───────────────────────────── static int s_rightClickedLink = -1; if (linkIsHovered && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) { s_rightClickedLink = hoveredLink; ImGui::OpenPopup("##link_ctx"); } if (ImGui::BeginPopup("##link_ctx")) { if (ImGui::MenuItem("Disconnect") && s_rightClickedLink >= 0) { destroyLink(s_rightClickedLink); s_rightClickedLink = -1; } ImGui::EndPopup(); } // ── 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 : tab.worldOutputPasses) if (pass == gid) { pass = Graph::INVALID_ID; tab.worldOutputDirty = true; } tab.graph.RemoveNode(gid); auto it = tab.visualNodes.find(gid); if (it != tab.visualNodes.end()) { it->second.FreeTex(); tab.visualNodes.erase(it); } } MarkAllDirty(); } } } // ── World Output node ───────────────────────────────────────────────────── void DrawWorldOutputNode() { auto& tab = Active(); 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(tab.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 = tab.worldOutputPasses[i]; if (connected != Graph::INVALID_ID) { Node* n = tab.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")) { tab.worldOutputPasses.push_back(Graph::INVALID_ID); } if (tab.worldOutputPasses.size() > 1) { ImGui::SameLine(); if (ImGui::SmallButton("- Pass")) { tab.worldOutputPasses.pop_back(); tab.worldOutputDirty = true; } } ImNodes::EndStaticAttribute(); // Generated chunk preview — always 1 tile per pixel. ImGui::Image( static_cast(static_cast(tab.worldOutputTex)), ImVec2(PREVIEW_SIZE * FINAL_PREVIEW_SCALE, PREVIEW_SIZE * FINAL_PREVIEW_SCALE)); ImNodes::EndNode(); ImNodes::PopColorStyle(); ImNodes::PopColorStyle(); ImNodes::PopColorStyle(); } // ── Regular node ───────────────────────────────────────────────────────── void DrawNode(VisualNode& vn) { auto& tab = Active(); Node* node = tab.graph.GetNode(vn.id); if (!node) return; ImNodes::BeginNode(static_cast(vn.id)); ImNodes::BeginNodeTitleBar(); ImGui::Text("%s", node->GetName().c_str()); ImNodes::EndNodeTitleBar(); // Output pin 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(); // 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_SCALE, PREVIEW_SIZE * PREVIEW_SCALE)); 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); ImGui::SetNextItemWidth(80); return ImGui::DragFloat("##val", &n->value, 0.01f); } static bool DrawIDDropdown(const char* label, NodeEditorApp& app, int& currentID) { auto& reg = app.sharedRegistry; int selIdx = -1; bool changed = false; for (int i = 0; i < static_cast(reg.size()); ++i) if (reg[i].id == currentID) { selIdx = i; break; } const char* preview = selIdx >= 0 ? reg[selIdx].name.c_str() : "???"; ImGui::SetNextItemWidth(80); if (ImGui::BeginCombo(label, preview)) { for (int i = 0; i < static_cast(reg.size()); ++i) { bool sel = (i == selIdx); char label[80]; snprintf(label, sizeof(label), "%s (%d)", reg[i].name.c_str(), reg[i].id); if (ImGui::Selectable(label, sel)) { currentID = reg[i].id; changed = true; } if (sel) ImGui::SetItemDefaultFocus(); } ImGui::EndCombo(); } return changed; } static bool DrawIDParams(NodeEditorApp& app, Node* node) { auto* n = static_cast(node); auto& reg = app.sharedRegistry; bool changed = false; if (reg.empty()) { changed = ImGui::DragInt("##id", &n->tileID, 1, 0, 9999); } else { changed |= DrawIDDropdown("##id", app, n->tileID); } return changed; } static bool DrawQueryTileParams(NodeEditorApp& app, Node* node) { auto* n = static_cast(node); bool changed = false; ImGui::Text("relative offset"); ImGui::PushItemWidth(70); changed |= ImGui::DragInt("##ox", &n->offsetX, 1); ImGui::SameLine(); changed |= ImGui::DragInt("##oy", &n->offsetY, 1); // TODO: it would be best if this is a dropdown of tile ids that the user can choose from ImGui::Text("Tile ID"); changed |= DrawIDDropdown("##eid", app, n->expectedID); ImGui::PopItemWidth(); return changed; } static bool DrawQueryRangeParams(NodeEditorApp& app, Node* node) { auto* n = static_cast(node); bool changed = false; ImGui::PushItemWidth(70); ImGui::Text("min x -> max x"); changed |= ImGui::DragInt("##mnx", &n->minX, 1); ImGui::SameLine(); changed |= ImGui::DragInt("##mxx", &n->maxX, 1); ImGui::Text("min y -> max y"); changed |= ImGui::DragInt("##mny", &n->minY, 1); ImGui::SameLine(); changed |= ImGui::DragInt("##mxy", &n->maxY, 1); changed |= DrawIDDropdown("##rtid", app, n->tileID); ImGui::PopItemWidth(); return changed; } static bool DrawQueryDistanceParams(NodeEditorApp& app, Node* node) { auto* n = static_cast(node); bool changed = false; ImGui::PushItemWidth(70); changed |= DrawIDDropdown("##dtid", app, n->tileID); changed |= ImGui::DragInt("##md", &n->maxDistance, 1, 1, 32); ImGui::PopItemWidth(); return changed; } static bool DrawLiquidNodeParams(NodeEditorApp& /*app*/, Node* node) { auto* n = static_cast(node); bool changed = false; ImGui::PushItemWidth(70); ImGui::Text("max width"); changed |= ImGui::DragInt("##lw", &n->maxWidth, 1, 1, 64); ImGui::Text("max depth"); changed |= ImGui::DragInt("##ld", &n->maxDepth, 1, 1, 64); ImGui::PopItemWidth(); return changed; } static bool DrawMapParams(NodeEditorApp& /*app*/, Node* node) { auto* n = static_cast(node); bool changed = false; ImGui::TextDisabled("in"); ImGui::SetNextItemWidth(70); changed |= ImGui::DragFloat("##mn0", &n->min0, 0.01f, 0.0f, 0.0f, "%.2f"); ImGui::SameLine(0, 3); ImGui::SetNextItemWidth(70); changed |= ImGui::DragFloat("##mx0", &n->max0, 0.01f, 0.0f, 0.0f, "%.2f"); ImGui::TextDisabled("out"); ImGui::SetNextItemWidth(70); changed |= ImGui::DragFloat("##mn1", &n->min1, 0.01f, 0.0f, 0.0f, "%.2f"); ImGui::SameLine(0, 3); ImGui::SetNextItemWidth(70); changed |= ImGui::DragFloat("##mx1", &n->max1, 0.01f, 0.0f, 0.0f, "%.2f"); return changed; } // All four noise nodes share the same single-field UI; a common helper // avoids repeating the widget call four times. static bool DrawFrequencyParam(float& freq) { ImGui::SetNextItemWidth(80); return ImGui::DragFloat("##freq", &freq, 0.0001f, 0.0001f, 10.0f, "%.4f"); } static bool DrawPerlinNoiseParams (NodeEditorApp&, Node* n) { return DrawFrequencyParam(static_cast (n)->frequency); } static bool DrawSimplexNoiseParams (NodeEditorApp&, Node* n) { return DrawFrequencyParam(static_cast (n)->frequency); } static bool DrawCellularNoiseParams(NodeEditorApp&, Node* n) { return DrawFrequencyParam(static_cast(n)->frequency); } static bool DrawValueNoiseParams (NodeEditorApp&, Node* n) { return DrawFrequencyParam(static_cast (n)->frequency); } // ── 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 }, { typeid(LiquidNode), DrawLiquidNodeParams }, { typeid(MapNode), DrawMapParams }, { typeid(PerlinNoiseNode), DrawPerlinNoiseParams }, { typeid(SimplexNoiseNode), DrawSimplexNoiseParams }, { typeid(CellularNoiseNode), DrawCellularNoiseParams }, { typeid(ValueNoiseNode), DrawValueNoiseParams }, }; auto it = kDrawers.find(typeid(*node)); if (it != kDrawers.end() && it->second(*this, node)) MarkAllDirty(); } // ── Graph management ────────────────────────────────────────────────────── Graph::NodeID AddNode(std::unique_ptr node) { auto& tab = Active(); Graph::NodeID id = tab.graph.AddNode(std::move(node)); VisualNode vn; vn.id = id; vn.dirty = true; vn.InitTex(); tab.visualNodes.emplace(id, std::move(vn)); return id; } // ── Serialization ───────────────────────────────────────────────────────── // ── Shared tile registry I/O ────────────────────────────────────────────── void SaveSharedRegistry(const std::string& path) { nlohmann::json j; auto& tiles = j["tiles"] = nlohmann::json::array(); for (auto& t : sharedRegistry) tiles.push_back({ {"id", t.id}, {"name", t.name}, {"color", { t.color[0], t.color[1], t.color[2] }} }); std::ofstream f(path); if (f) { f << j.dump(2); sharedRegistryPath = path; } else fprintf(stderr, "Failed to write registry %s\n", path.c_str()); } void LoadSharedRegistry(const std::string& path) { std::ifstream f(path); if (!f) { fprintf(stderr, "Cannot open registry %s\n", path.c_str()); return; } nlohmann::json j; try { j = nlohmann::json::parse(f); } catch (...) { fprintf(stderr, "JSON parse error in registry %s\n", path.c_str()); return; } if (!j.contains("tiles") || !j["tiles"].is_array()) return; sharedRegistry.clear(); for (auto& t : j["tiles"]) { 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(); } sharedRegistry.push_back(entry); } // Always ensure ID 0 sentinel is present. bool hasEmpty = std::any_of(sharedRegistry.begin(), sharedRegistry.end(), [](const TileEntry& e) { return e.id == 0; }); if (!hasEmpty) sharedRegistry.insert(sharedRegistry.begin(), TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}}); sharedRegistryPath = path; MarkAllDirty(); } void OpenRegistryDialog() { IGFD::FileDialogConfig cfg; cfg.path = sharedRegistryPath.empty() ? "." : std::filesystem::path(sharedRegistryPath).parent_path().string(); ImGuiFileDialog::Instance()->OpenDialog("OpenRegistryDlg", "Open Tile Registry", ".json", cfg); } void SaveRegistryDialog() { IGFD::FileDialogConfig cfg; if (!sharedRegistryPath.empty()) { std::filesystem::path p(sharedRegistryPath); cfg.path = p.parent_path().string(); cfg.fileName = p.filename().string(); } else { cfg.path = "."; cfg.fileName = "tiles.json"; } ImGuiFileDialog::Instance()->OpenDialog("SaveRegistryDlg", "Save Tile Registry", ".json", cfg); } void Save(const std::string& path) { auto& tab = Active(); ImNodes::EditorContextSet(tab.imnodesCtx); nlohmann::json root; root["graph"] = GraphSerializer::ToJson(tab.graph); // Node positions (including World Output). nlohmann::json positions = nlohmann::json::object(); for (auto& [id, vn] : tab.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"] = tab.previewSeed; root["editor"]["previewOriginX"] = tab.previewOriginX; root["editor"]["previewOriginY"] = tab.previewOriginY; root["editor"]["previewScale"] = tab.previewScale; nlohmann::json passes = nlohmann::json::array(); for (auto id : tab.worldOutputPasses) passes.push_back(id); root["editor"]["worldOutputPasses"] = passes; std::ofstream f(path); if (f) { f << root.dump(2); tab.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; } // Reset this tab's graph state in-place. auto& tab = Active(); for (auto& [id, vn] : tab.visualNodes) vn.FreeTex(); tab.visualNodes.clear(); tab.graph = Graph{}; tab.worldOutputPasses = { Graph::INVALID_ID }; tab.worldOutputDirty = true; tab.pendingWorldOutputPos = true; tab.currentFile = ""; tab.graph = std::move(*newGraph); uint32_t maxId = root["graph"].value("nextId", 1u); for (uint32_t id = 1; id < maxId; ++id) { if (tab.graph.GetNode(id)) { VisualNode vn; vn.id = id; vn.dirty = true; vn.InitTex(); tab.visualNodes.emplace(id, std::move(vn)); } } if (root.contains("editor")) { const auto& ed = root["editor"]; tab.previewSeed = ed.value("seed", uint64_t{0}); tab.previewOriginX = ed.value("previewOriginX", 0); tab.previewOriginY = ed.value("previewOriginY", 0); tab.previewScale = ed.value("previewScale", 1.0f); // Note: "tileRegistry" embedded in old .wge files is intentionally // ignored — use the shared tiles.json registry instead. if (ed.contains("worldOutputPasses")) { tab.worldOutputPasses.clear(); for (auto& v : ed["worldOutputPasses"]) tab.worldOutputPasses.push_back(v.get()); if (tab.worldOutputPasses.empty()) tab.worldOutputPasses.push_back(Graph::INVALID_ID); } ImNodes::EditorContextSet(tab.imnodesCtx); 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)); tab.pendingWorldOutputPos = false; } else { ImNodes::SetNodeGridSpacePos(std::stoi(key), ImVec2(x, y)); } } } } tab.currentFile = path; } // ── File dialogs ────────────────────────────────────────────────────────── void OpenFileDialog() { IGFD::FileDialogConfig cfg; const auto& cur = Active(); cfg.path = cur.currentFile.empty() ? "." : std::filesystem::path(cur.currentFile).parent_path().string(); ImGuiFileDialog::Instance()->OpenDialog("OpenFileDlg", "Open Graph", ".json", cfg); } void SaveAsDialog() { IGFD::FileDialogConfig cfg; const auto& cur = Active(); if (!cur.currentFile.empty()) { std::filesystem::path p(cur.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 (Active().currentFile.empty()) SaveAsDialog(); else Save(Active().currentFile); } public: void RenderFileDialogs() { ImVec2 dlgSize(600, 400); if (ImGuiFileDialog::Instance()->Display("OpenFileDlg", ImGuiWindowFlags_NoCollapse, dlgSize)) { if (ImGuiFileDialog::Instance()->IsOk()) { std::string path = ImGuiFileDialog::Instance()->GetFilePathName(); // If file already open, switch to that tab. bool found = false; for (int i = 0; i < static_cast(tabs.size()); ++i) { if (tabs[i].currentFile == path) { requestedTab = activeTab = i; found = true; break; } } if (!found) { // Reuse current tab if it's blank, otherwise open a new tab. auto& cur = Active(); if (cur.graph.NodeCount() > 0 || !cur.currentFile.empty()) NewTab(); Load(path); } } ImGuiFileDialog::Instance()->Close(); } if (ImGuiFileDialog::Instance()->Display("SaveFileDlg", ImGuiWindowFlags_NoCollapse, dlgSize)) { if (ImGuiFileDialog::Instance()->IsOk()) Save(ImGuiFileDialog::Instance()->GetFilePathName()); ImGuiFileDialog::Instance()->Close(); } if (ImGuiFileDialog::Instance()->Display("OpenRegistryDlg", ImGuiWindowFlags_NoCollapse, dlgSize)) { if (ImGuiFileDialog::Instance()->IsOk()) LoadSharedRegistry(ImGuiFileDialog::Instance()->GetFilePathName()); ImGuiFileDialog::Instance()->Close(); } if (ImGuiFileDialog::Instance()->Display("SaveRegistryDlg", ImGuiWindowFlags_NoCollapse, dlgSize)) { if (ImGuiFileDialog::Instance()->IsOk()) SaveSharedRegistry(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)) OpenFileDialog(); if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_N)) NewTab(); if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_W)) CloseTab(activeTab); } // Registers a custom [NodeEditor][Session] block in imgui.ini that persists // all open file paths and the active tab index across sessions. // Must be called after ImGui::CreateContext() and before the first ImGui::NewFrame(). void RegisterSettingsHandler() { ImGuiSettingsHandler h; h.TypeName = "NodeEditor"; h.TypeHash = ImHashStr("NodeEditor"); h.UserData = this; // Called when the [NodeEditor][Session] header is found — return non-null // to accept the entry and route its lines to ReadLineFn. h.ReadOpenFn = [](ImGuiContext*, ImGuiSettingsHandler* handler, const char*) -> void* { return handler->UserData; }; // Called for each key=value line inside the entry. h.ReadLineFn = [](ImGuiContext*, ImGuiSettingsHandler*, void* entry, const char* line) { auto* app = static_cast(entry); if (strncmp(line, "LastFiles=", 10) == 0) { std::istringstream ss(line + 10); std::string file; while (std::getline(ss, file, ';')) if (!file.empty()) app->pendingLoadFiles.push_back(file); } if (strncmp(line, "ActiveTab=", 10) == 0) app->pendingActiveTab = std::atoi(line + 10); if (strncmp(line, "SharedRegistry=", 15) == 0) app->pendingLoadRegistry = line + 15; }; // Called when ImGui writes imgui.ini (periodically and on shutdown). h.WriteAllFn = [](ImGuiContext*, ImGuiSettingsHandler* handler, ImGuiTextBuffer* out_buf) { auto* app = static_cast(handler->UserData); std::string files; for (auto& tab : app->tabs) { if (!tab.currentFile.empty()) { if (!files.empty()) files += ";"; files += tab.currentFile; } } out_buf->appendf("[NodeEditor][Session]\n"); if (!files.empty()) { out_buf->appendf("LastFiles=%s\n", files.c_str()); out_buf->appendf("ActiveTab=%d\n", app->activeTab); } if (!app->sharedRegistryPath.empty()) out_buf->appendf("SharedRegistry=%s\n", app->sharedRegistryPath.c_str()); out_buf->appendf("\n"); }; ImGui::AddSettingsHandler(&h); } }; // ───────────────────────────────────────────────────────────────────────────── // 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; app.RegisterSettingsHandler(); 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; }