1460 lines
63 KiB
C++
1460 lines
63 KiB
C++
// 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 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>(); } },
|
||
// ── 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>(); } },
|
||
{ "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); } },
|
||
// ── Noise ───────────────────────────────────────────────────────────────
|
||
{ "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<TileEntry> tileRegistry;
|
||
|
||
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()
|
||
{
|
||
tileRegistry = { TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}} };
|
||
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 (!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)
|
||
|
||
// Pending data from ini settings handler
|
||
std::vector<std::string> pendingLoadFiles;
|
||
int pendingActiveTab { -1 };
|
||
|
||
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>(py * tab.previewScale);
|
||
ctx.seed = tab.previewSeed;
|
||
|
||
Value v = tab.graph.Evaluate(id, ctx);
|
||
int base = (py * PREVIEW_SIZE + px) * 4;
|
||
ValueToRGBA(v, &tab.tileRegistry,
|
||
pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]);
|
||
}
|
||
}
|
||
|
||
glBindTexture(GL_TEXTURE_2D, vn.tex);
|
||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, PREVIEW_SIZE, PREVIEW_SIZE,
|
||
0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
|
||
}
|
||
|
||
// World Output preview — runs GenerateChunk with all connected passes.
|
||
if (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 + py);
|
||
int base = (py * PREVIEW_SIZE + px) * 4;
|
||
Value v = Value::MakeInt(tileID);
|
||
ValueToRGBA(v, &tab.tileRegistry,
|
||
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");
|
||
|
||
int toRemove = -1;
|
||
for (int i = 0; i < static_cast<int>(tab.tileRegistry.size()); ++i) {
|
||
ImGui::PushID(i);
|
||
TileEntry& entry = tab.tileRegistry[i];
|
||
|
||
// Color button — opens a popup picker.
|
||
char cpopup[32];
|
||
snprintf(cpopup, sizeof(cpopup), "##cpick%d", i);
|
||
if (ImGui::ColorButton(cpopup,
|
||
ImVec4(entry.color[0], entry.color[1], entry.color[2], 1.0f),
|
||
ImGuiColorEditFlags_NoTooltip | ImGuiColorEditFlags_NoBorder,
|
||
ImVec2(18, 18))) {
|
||
ImGui::OpenPopup(cpopup);
|
||
}
|
||
if (ImGui::BeginPopup(cpopup)) {
|
||
if (ImGui::ColorPicker3("##cp", entry.color,
|
||
ImGuiColorEditFlags_NoLabel | ImGuiColorEditFlags_NoSidePreview))
|
||
MarkAllDirty();
|
||
ImGui::EndPopup();
|
||
}
|
||
ImGui::SameLine(0, 3);
|
||
|
||
// ID field (narrow) — locked for the reserved Empty entry
|
||
ImGui::BeginDisabled(entry.id == 0);
|
||
ImGui::SetNextItemWidth(32);
|
||
ImGui::DragInt("##tid", &entry.id, 1, 0, 9999);
|
||
ImGui::EndDisabled();
|
||
ImGui::SameLine(0, 3);
|
||
|
||
// Name field (fills remaining width minus remove button)
|
||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 20);
|
||
char buf[64];
|
||
strncpy(buf, entry.name.c_str(), sizeof(buf) - 1);
|
||
buf[sizeof(buf) - 1] = '\0';
|
||
if (ImGui::InputText("##tname", buf, sizeof(buf)))
|
||
entry.name = buf;
|
||
ImGui::SameLine(0, 3);
|
||
|
||
// ID 0 "Empty" is permanent — show a disabled remove button
|
||
ImGui::BeginDisabled(entry.id == 0);
|
||
if (ImGui::SmallButton("x"))
|
||
toRemove = i;
|
||
ImGui::EndDisabled();
|
||
|
||
ImGui::PopID();
|
||
}
|
||
if (toRemove >= 0)
|
||
tab.tileRegistry.erase(tab.tileRegistry.begin() + toRemove);
|
||
|
||
if (ImGui::SmallButton("+ Add Tile")) {
|
||
int32_t nextId = tab.tileRegistry.empty() ? 1
|
||
: tab.tileRegistry.back().id + 1;
|
||
TileEntry entry;
|
||
entry.id = nextId;
|
||
entry.name = "Tile";
|
||
// Seed the default color from the hash so new tiles start distinct.
|
||
uint8_t hr, hg, hb;
|
||
uint32_t h = static_cast<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;
|
||
tab.tileRegistry.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));
|
||
}
|
||
|
||
ImNodes::BeginNodeEditor();
|
||
|
||
// Right-click blank canvas → add node menu
|
||
if (ImGui::IsWindowHovered(ImGuiFocusedFlags_RootAndChildWindows) &&
|
||
ImNodes::IsEditorHovered() &&
|
||
ImGui::IsMouseReleased(ImGuiMouseButton_Right) &&
|
||
!ImGui::IsAnyItemHovered())
|
||
{
|
||
ImGui::OpenPopup("##add_node_menu");
|
||
}
|
||
|
||
ImGui::SetNextWindowSize(ImVec2(220, 300), ImGuiCond_Always);
|
||
if (ImGui::BeginPopup("##add_node_menu")) {
|
||
ImVec2 spawnPos = ImGui::GetMousePosOnOpeningCurrentPopup();
|
||
|
||
static char filterBuf[128] = "";
|
||
if (ImGui::IsWindowAppearing()) {
|
||
ImGui::SetKeyboardFocusHere();
|
||
filterBuf[0] = '\0';
|
||
}
|
||
ImGui::SetNextItemWidth(-1);
|
||
ImGui::InputText("##filter", filterBuf, sizeof(filterBuf));
|
||
ImGui::Separator();
|
||
|
||
const bool filtering = filterBuf[0] != '\0';
|
||
|
||
auto spawnItem = [&](const NodeMenuItem& item) {
|
||
if (ImGui::MenuItem(item.label)) {
|
||
Graph::NodeID newId = AddNode(item.create());
|
||
ImNodes::SetNodeScreenSpacePos(static_cast<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();
|
||
|
||
// ── 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 ──────────────────────────────────────────────
|
||
|
||
int destroyedLink;
|
||
if (ImNodes::IsLinkDestroyed(&destroyedLink)) {
|
||
if (destroyedLink >= WORLD_OUTPUT_LINK_BASE) {
|
||
// World Output link
|
||
int passIdx = destroyedLink - WORLD_OUTPUT_LINK_BASE;
|
||
if (passIdx >= 0 && passIdx < static_cast<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 == destroyedLink) {
|
||
tab.graph.Disconnect(id, slot);
|
||
MarkAllDirty();
|
||
found = true;
|
||
break;
|
||
}
|
||
++idx;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Delete selected nodes ─────────────────────────────────────────────
|
||
|
||
if (ImGui::IsKeyPressed(ImGuiKey_Delete)) {
|
||
int count = ImNodes::NumSelectedNodes();
|
||
if (count > 0) {
|
||
std::vector<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 * PREVIEW_SCALE, PREVIEW_SIZE * 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 DrawIDParams(NodeEditorApp& app, Node* node)
|
||
{
|
||
auto* n = static_cast<IDNode*>(node);
|
||
auto& reg = app.Active().tileRegistry;
|
||
bool changed = false;
|
||
if (reg.empty()) {
|
||
changed = ImGui::DragInt("##id", &n->tileID, 1, 0, 9999);
|
||
} else {
|
||
int selIdx = -1;
|
||
for (int i = 0; i < static_cast<int>(reg.size()); ++i)
|
||
if (reg[i].id == n->tileID) { selIdx = i; break; }
|
||
const char* preview = selIdx >= 0 ? reg[selIdx].name.c_str() : "???";
|
||
ImGui::SetNextItemWidth(80);
|
||
if (ImGui::BeginCombo("##tile", 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)) {
|
||
n->tileID = reg[i].id;
|
||
changed = true;
|
||
}
|
||
if (sel) ImGui::SetItemDefaultFocus();
|
||
}
|
||
ImGui::EndCombo();
|
||
}
|
||
}
|
||
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 |= ImGui::DragInt("##eid", &n->expectedID, 1, 0, 1024);
|
||
ImGui::PopItemWidth();
|
||
return changed;
|
||
}
|
||
|
||
static bool DrawQueryRangeParams(NodeEditorApp& /*app*/, Node* node)
|
||
{
|
||
auto* n = static_cast<QueryRangeNode*>(node);
|
||
bool changed = false;
|
||
ImGui::PushItemWidth(70);
|
||
changed |= ImGui::DragInt("##mnx", &n->minX, 1);
|
||
ImGui::SameLine();
|
||
changed |= ImGui::DragInt("##mxx", &n->maxX, 1);
|
||
changed |= ImGui::DragInt("##mny", &n->minY, 1);
|
||
ImGui::SameLine();
|
||
changed |= ImGui::DragInt("##mxy", &n->maxY, 1);
|
||
changed |= ImGui::DragInt("##rtid", &n->tileID, 1, 0, 1024);
|
||
ImGui::PopItemWidth();
|
||
return changed;
|
||
}
|
||
|
||
static bool DrawQueryDistanceParams(NodeEditorApp& /*app*/, Node* node)
|
||
{
|
||
auto* n = static_cast<QueryDistanceNode*>(node);
|
||
bool changed = false;
|
||
ImGui::PushItemWidth(70);
|
||
changed |= ImGui::DragInt("##dtid", &n->tileID, 1, 0, 1024);
|
||
changed |= ImGui::DragInt("##md", &n->maxDistance, 1, 1, 32);
|
||
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(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 ─────────────────────────────────────────────────────────
|
||
|
||
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;
|
||
|
||
nlohmann::json tiles = nlohmann::json::array();
|
||
for (auto& t : tab.tileRegistry)
|
||
tiles.push_back({ {"id", t.id}, {"name", t.name},
|
||
{"color", { t.color[0], t.color[1], t.color[2] }} });
|
||
root["editor"]["tileRegistry"] = tiles;
|
||
|
||
std::ofstream f(path);
|
||
if (f) { f << root.dump(2); 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.tileRegistry = { TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}} };
|
||
|
||
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);
|
||
|
||
if (ed.contains("tileRegistry")) {
|
||
tab.tileRegistry.clear();
|
||
for (auto& t : ed["tileRegistry"]) {
|
||
TileEntry entry;
|
||
entry.id = t.value("id", 0);
|
||
entry.name = t.value("name", std::string("Tile"));
|
||
if (t.contains("color") && t["color"].size() == 3) {
|
||
entry.color[0] = t["color"][0].get<float>();
|
||
entry.color[1] = t["color"][1].get<float>();
|
||
entry.color[2] = t["color"][2].get<float>();
|
||
}
|
||
tab.tileRegistry.push_back(entry);
|
||
}
|
||
// Always guarantee the Empty (ID 0) sentinel is present.
|
||
bool hasEmpty = std::any_of(tab.tileRegistry.begin(), tab.tileRegistry.end(),
|
||
[](const TileEntry& e) { return e.id == 0; });
|
||
if (!hasEmpty)
|
||
tab.tileRegistry.insert(tab.tileRegistry.begin(),
|
||
TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}});
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
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);
|
||
};
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
if (!files.empty()) {
|
||
out_buf->appendf("[NodeEditor][Session]\n");
|
||
out_buf->appendf("LastFiles=%s\n", files.c_str());
|
||
out_buf->appendf("ActiveTab=%d\n", app->activeTab);
|
||
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;
|
||
}
|