Files
factory-hole-core/tools/node-editor/main.cpp
2026-02-24 17:22:41 +09:00

1606 lines
69 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <GLFW/glfw3.h>
#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 <nlohmann/json.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <memory>
#include <sstream>
#include <string>
#include <typeindex>
#include <unordered_map>
#include <vector>
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<int>(n) * 10 + 9; }
static int InputPin (Graph::NodeID n, int slot) { return static_cast<int>(n) * 10 + slot; }
static Graph::NodeID PinToNode(int attr) { return static_cast<Graph::NodeID>(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<int>(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<uint8_t>((r1 + m) * 255.0f);
g = static_cast<uint8_t>((g1 + m) * 255.0f);
b = static_cast<uint8_t>((b1 + m) * 255.0f);
}
static void ValueToRGBA(const Value& v,
const std::vector<TileEntry>* 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<uint8_t>(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<uint8_t>(t.color[0] * 255.0f);
g = static_cast<uint8_t>(t.color[1] * 255.0f);
b = static_cast<uint8_t>(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<uint32_t>(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<uint8_t, PREVIEW_SIZE * PREVIEW_SIZE * 4> 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<std::unique_ptr<Node>()> create;
};
static const std::vector<NodeMenuItem> NODE_MENU = {
// ── Source ──────────────────────────────────────────────────────────────
{ "Constant", "Source", [] { return std::make_unique<ConstantNode>(0.0f); } },
{ "TileID", "Source", [] { return std::make_unique<IDNode>(1); } },
{ "PositionX", "Source", [] { return std::make_unique<PositionXNode>(); } },
{ "PositionY", "Source", [] { return std::make_unique<PositionYNode>(); } },
{ "LayerStrength", "Source", [] { return std::make_unique<LayerStrengthNode>(); } },
// ── Math ────────────────────────────────────────────────────────────────
{ "Add", "Math", [] { return std::make_unique<AddNode>(); } },
{ "Subtract", "Math", [] { return std::make_unique<SubtractNode>(); } },
{ "Multiply", "Math", [] { return std::make_unique<MultiplyNode>(); } },
{ "Divide", "Math", [] { return std::make_unique<DivideNode>(); } },
{ "Modulo", "Math", [] { return std::make_unique<ModuloNode>(); } },
{ "Sin", "Math", [] { return std::make_unique<SinNode>(); } },
{ "Cos", "Math", [] { return std::make_unique<CosNode>(); } },
{ "Floor", "Math", [] { return std::make_unique<FloorNode>(); } },
{ "Ceil", "Math", [] { return std::make_unique<CeilNode>(); } },
{ "Sqrt", "Math", [] { return std::make_unique<SqrtNode>(); } },
{ "Pow", "Math", [] { return std::make_unique<PowNode>(); } },
{ "Square", "Math", [] { return std::make_unique<SquareNode>(); } },
{ "Abs", "Math", [] { return std::make_unique<AbsNode>(); } },
{ "Negate", "Math", [] { return std::make_unique<NegateNode>(); } },
{ "OneMinus", "Math", [] { return std::make_unique<OneMinusNode>(); } },
{ "Min", "Math", [] { return std::make_unique<MinNode>(); } },
{ "Max", "Math", [] { return std::make_unique<MaxNode>(); } },
{ "Clamp", "Math", [] { return std::make_unique<ClampNode>(); } },
{ "Map", "Math", [] { return std::make_unique<MapNode>(-1.0f, 1.0f, 0.0f, 1.0f); } },
// ── Compare ─────────────────────────────────────────────────────────────
{ "Less", "Compare", [] { return std::make_unique<LessNode>(); } },
{ "Greater", "Compare", [] { return std::make_unique<GreaterNode>(); } },
{ "LessEqual", "Compare", [] { return std::make_unique<LessEqualNode>(); } },
{ "GreaterEqual", "Compare", [] { return std::make_unique<GreaterEqualNode>(); } },
{ "Equal", "Compare", [] { return std::make_unique<EqualNode>(); } },
// ── Logic ───────────────────────────────────────────────────────────────
{ "And", "Logic", [] { return std::make_unique<AndNode>(); } },
{ "Or", "Logic", [] { return std::make_unique<OrNode>(); } },
{ "Not", "Logic", [] { return std::make_unique<NotNode>(); } },
{ "Xor", "Logic", [] { return std::make_unique<XorNode>(); } },
{ "Nand", "Logic", [] { return std::make_unique<NandNode>(); } },
{ "Nor", "Logic", [] { return std::make_unique<NorNode>(); } },
{ "Xnor", "Logic", [] { return std::make_unique<XnorNode>(); } },
// ── Control ─────────────────────────────────────────────────────────────
{ "Branch", "Control", [] { return std::make_unique<BranchNode>(); } },
{ "IntBranch", "Control", [] { return std::make_unique<IntBranchNode>(); } },
// ── Query ───────────────────────────────────────────────────────────────
{ "QueryTile", "Query", [] { return std::make_unique<QueryTileNode>(0, -1, 1); } },
{ "QueryRange", "Query", [] { return std::make_unique<QueryRangeNode>(-1, -1, 1, 1, 1); } },
{ "QueryDistance", "Query", [] { return std::make_unique<QueryDistanceNode>(1, 4); } },
{ "QueryLiquid", "Query", [] { return std::make_unique<LiquidNode>(8, 4); } },
// ── Noise ───────────────────────────────────────────────────────────────
{ "Random", "Noise", [] { return std::make_unique<RandomNode>(); } },
{ "PerlinNoise", "Noise", [] { return std::make_unique<PerlinNoiseNode>(0.01f); } },
{ "SimplexNoise", "Noise", [] { return std::make_unique<SimplexNoiseNode>(0.01f); } },
{ "CellularNoise", "Noise", [] { return std::make_unique<CellularNoiseNode>(0.01f); } },
{ "ValueNoise", "Noise", [] { return std::make_unique<ValueNoiseNode>(0.01f); } },
};
// ─────────────────────────────────────────────────────────────────────────────
// GraphTab all state for one open graph (one browser-style tab)
// ─────────────────────────────────────────────────────────────────────────────
struct GraphTab {
std::string currentFile;
Graph graph;
std::unordered_map<Graph::NodeID, VisualNode> visualNodes;
std::vector<Graph::NodeID> 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<int>(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<GraphTab> 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<TileEntry> 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<std::string> 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<int>(tabs.size()) - 1;
}
void CloseTab(int idx)
{
tabs[idx].Free();
tabs.erase(tabs.begin() + idx);
if (tabs.empty()) { NewTab(); return; }
if (activeTab >= static_cast<int>(tabs.size()))
activeTab = static_cast<int>(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<int>(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<uint8_t, PREVIEW_SIZE * PREVIEW_SIZE * 4> 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<int>(px * tab.previewScale);
ctx.worldY = tab.previewOriginY + static_cast<int>((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<GenerationPass> 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<int>(tab.previewSeed & 0xFFFFFFFFu);
if (ImGui::DragInt("Seed", &seed32)) {
tab.previewSeed = static_cast<uint64_t>(static_cast<uint32_t>(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<int>(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<uint32_t>(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<int>(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<char>(std::tolower(static_cast<unsigned char>(c)));
for (const auto& item : NODE_MENU) {
auto contains = [&](const char* s) {
std::string hay = s;
for (char& c : hay) c = static_cast<char>(std::tolower(static_cast<unsigned char>(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<int>(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<int>(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<int>(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<int>(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<int>(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<int>(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<int> selected(count);
ImNodes::GetSelectedNodes(selected.data());
for (int sid : selected) {
if (sid == WORLD_OUTPUT_IMNODES_ID) continue; // permanent node
Graph::NodeID gid = static_cast<Graph::NodeID>(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<int>(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<ImTextureID>(static_cast<uint64_t>(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<int>(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<int>(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<ImTextureID>(static_cast<uint64_t>(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<ConstantNode*>(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<int>(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<int>(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<IDNode*>(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<QueryTileNode*>(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<QueryRangeNode*>(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<QueryDistanceNode*>(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<LiquidNode*>(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<MapNode*>(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<PerlinNoiseNode*> (n)->frequency); }
static bool DrawSimplexNoiseParams (NodeEditorApp&, Node* n) { return DrawFrequencyParam(static_cast<SimplexNoiseNode*> (n)->frequency); }
static bool DrawCellularNoiseParams(NodeEditorApp&, Node* n) { return DrawFrequencyParam(static_cast<CellularNoiseNode*>(n)->frequency); }
static bool DrawValueNoiseParams (NodeEditorApp&, Node* n) { return DrawFrequencyParam(static_cast<ValueNoiseNode*> (n)->frequency); }
// ── Param dispatcher ──────────────────────────────────────────────────────
void DrawNodeParams(Graph::NodeID /*id*/, Node* node)
{
using DrawFn = bool(*)(NodeEditorApp&, Node*);
static const std::unordered_map<std::type_index, DrawFn> 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> 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<float>();
entry.color[1] = t["color"][1].get<float>();
entry.color[2] = t["color"][2].get<float>();
}
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<int>(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<Graph::NodeID>());
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>();
float y = val[1].get<float>();
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<int>(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<NodeEditorApp*>(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<NodeEditorApp*>(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;
}