From 3d4453b9ea19ec9f3ac89dd7165ec4997064df51 Mon Sep 17 00:00:00 2001 From: Connor Date: Sun, 22 Feb 2026 15:19:54 +0900 Subject: [PATCH] imgui implementation --- CMakeLists.txt | 30 +- graph.json | 43 ++ imgui.ini | 36 + include/WorldGraph/README.md | 213 ++++++ tools/node-editor/CMakeLists.txt | 65 ++ tools/node-editor/README.md | 143 ++++ tools/node-editor/main.cpp | 1185 ++++++++++++++++++++++++++++++ 7 files changed, 1708 insertions(+), 7 deletions(-) create mode 100644 graph.json create mode 100644 imgui.ini create mode 100644 include/WorldGraph/README.md create mode 100644 tools/node-editor/CMakeLists.txt create mode 100644 tools/node-editor/README.md create mode 100644 tools/node-editor/main.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 4faa921..573811b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,10 +53,16 @@ FetchContent_Declare( # ---- Frontend dependencies (used by tools/) ---------------------------------- -# SDL2 – install via your system package manager, e.g.: -# sudo apt install libsdl2-dev (Debian/Ubuntu) -# brew install sdl2 (macOS) -find_package(SDL2 QUIET) +# GLFW – windowing for the node-editor tool +FetchContent_Declare( + glfw + GIT_REPOSITORY https://github.com/glfw/glfw.git + GIT_TAG 3.4 + GIT_SHALLOW TRUE +) +set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE) +set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) # Dear ImGui FetchContent_Declare( @@ -74,9 +80,15 @@ FetchContent_Declare( GIT_SHALLOW TRUE ) -# Make available what we need right now. -# imgui / imnodes / SDL2 / FastNoise2 are deferred until tools/ is built. -FetchContent_MakeAvailable(flecs doctest glm nlohmann_json) +# ImGuiFileDialog – file browser widget for the node-editor tool +FetchContent_Declare( + ImGuiFileDialog + GIT_REPOSITORY https://github.com/aiekick/ImGuiFileDialog.git + GIT_TAG v0.6.7 + GIT_SHALLOW TRUE +) + +FetchContent_MakeAvailable(flecs doctest glm nlohmann_json glfw imgui imnodes) # ---- Core library ------------------------------------------------------------ @@ -125,3 +137,7 @@ target_link_libraries(factory-hole-tests PRIVATE ) add_test(NAME factory-hole-tests COMMAND factory-hole-tests) + +# ---- Tools ------------------------------------------------------------------- + +add_subdirectory(tools/node-editor) diff --git a/graph.json b/graph.json new file mode 100644 index 0000000..006aff0 --- /dev/null +++ b/graph.json @@ -0,0 +1,43 @@ +{ + "editor": { + "nodePositions": { + "1": [ + -170.0, + -162.0 + ], + "__worldOutput": [ + 400.0, + 0.0 + ] + }, + "previewOriginX": 0, + "previewOriginY": 0, + "previewScale": 1.0, + "seed": 0, + "tileRegistry": [ + { + "color": [ + 0.7348794341087341, + 0.730800986289978, + 0.7554347515106201 + ], + "id": 1, + "name": "Stone" + } + ], + "worldOutputPasses": [ + 1 + ] + }, + "graph": { + "connections": [], + "nextId": 2, + "nodes": [ + { + "id": 1, + "tileId": 1, + "type": "TileID" + } + ] + } +} \ No newline at end of file diff --git a/imgui.ini b/imgui.ini new file mode 100644 index 0000000..a0e3735 --- /dev/null +++ b/imgui.ini @@ -0,0 +1,36 @@ +[Window][Node Canvas] +Pos=200,18 +Size=1080,702 + +[Window][Settings] +Pos=0,18 +Size=200,702 + +[Window][Debug##Default] +Pos=60,60 +Size=400,400 + +[Window][Save Graph##SaveFileDlg] +Pos=323,94 +Size=600,400 + +[Window][Open Graph##OpenFileDlg] +Pos=60,60 +Size=600,400 + +[Table][0x4ED31530,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0x837FA078,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0x80A59430,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0xF2EF1CF5,4] +RefScale=13 +Column 0 Sort=0v + diff --git a/include/WorldGraph/README.md b/include/WorldGraph/README.md new file mode 100644 index 0000000..1e0009c --- /dev/null +++ b/include/WorldGraph/README.md @@ -0,0 +1,213 @@ +# WorldGraph + +A node-based procedural tile generation system. Graphs are composed of typed nodes wired together; evaluating the graph at a given world cell produces a tile ID. + +--- + +## Overview + +World generation is expressed as a **directed acyclic graph (DAG)** of computation nodes. Each node takes zero or more typed inputs, performs some computation, and produces a single typed output. The graph is evaluated independently **per cell** — there is no shared mutable state between cells. + +The final result is an integer **tile ID**, obtained by calling `AsInt()` on whatever value the output node produces. + +--- + +## Core Types (`WorldGraphTypes.h`) + +### `Value` + +A tagged union that can hold an `Int`, `Float`, or `Bool`. Values are coerced automatically when a node requests a different type (e.g. `AsFloat()` on an Int just casts it). This means connections between different types are permissive at runtime, though the editor can flag them. + +### `EvalContext` + +The per-cell context forwarded to every node during evaluation: + +| Field | Description | +|---|---| +| `worldX`, `worldY` | World-space tile coordinates of the cell being generated | +| `seed` | World seed for deterministic noise | +| `prevTiles` | Flat row-major array of tile IDs from the previous generation pass | +| `prevOriginX/Y`, `prevWidth/Height` | Position and size of the previous pass buffer | + +`GetPrevTile(x, y)` safely reads the previous pass at any absolute world coordinate, returning `0` (AIR) for out-of-bounds or when no previous pass exists. + +--- + +## Graph (`WorldGraph.h`) + +`Graph` owns a set of nodes and the connections between them. + +``` +NodeID AddNode(unique_ptr) — add a node, returns its ID +bool Connect(from, to, inputSlot) — wire an output to an input +Value Evaluate(outputNode, ctx) — evaluate for one cell +bool IsValid(outputNode) — check all inputs are wired +``` + +**Cycle detection** runs at `Connect()` time. Any connection that would form a cycle is rejected and returns `false`. + +**Evaluation** is recursive and on-demand. Starting from the output node, the graph walks upstream, evaluating each dependency before the node that depends on it. Unconnected inputs receive a zero value of their declared type. + +--- + +## Nodes (`WorldGraphNode.h`) + +All nodes derive from the abstract `Node` base class and implement: +- `GetOutputType()` — the type this node produces +- `GetInputTypes()` — the types of each input slot +- `Evaluate(ctx, inputs)` — computes and returns the output value + +### Source Nodes (no inputs) + +| Node | Output | Description | +|---|---|---| +| `ConstantNode` | Float | A fixed float value | +| `IDNode` | Int | A fixed tile ID integer | +| `PositionXNode` | Int | World X coordinate of the current cell | +| `PositionYNode` | Int | World Y coordinate of the current cell | + +### Math Nodes + +| Node | Inputs | Output | Description | +|---|---|---|---| +| `AddNode` | Float, Float | Float | a + b | +| `SubtractNode` | Float, Float | Float | a − b | +| `MultiplyNode` | Float, Float | Float | a × b | +| `DivideNode` | Float, Float | Float | a / b (0 on divide-by-zero) | +| `ModuloNode` | Float, Float | Float | fmod(a, b) | +| `SinNode` | Float | Float | sin(a) in radians | +| `CosNode` | Float | Float | cos(a) in radians | + +### Comparison Nodes + +All produce `Bool`. Inputs are Float. + +`LessNode`, `GreaterNode`, `LessEqualNode`, `GreaterEqualNode`, `EqualNode` + +> **Note:** `EqualNode` compares floats directly — use only with integer-valued inputs. + +### Boolean Logic Nodes + +| Node | Inputs | Output | +|---|---|---| +| `AndNode` | Bool, Bool | Bool | +| `OrNode` | Bool, Bool | Bool | +| `NotNode` | Bool | Bool | + +### Control Flow + +`BranchNode` — selects between two values based on a condition: + +``` +inputs[0] Bool — condition +inputs[1] Float — value if true +inputs[2] Float — value if false +``` + +The concrete type of the chosen branch is passed through, so an `IDNode` connected to the true/false slot will produce an `Int` even though `GetOutputType()` reports `Float`. + +### Previous-Pass Query Nodes + +These nodes read from the previous generation pass via `EvalContext`. They have **no inputs** — their parameters are baked into the node at construction time. The chunk generator uses these baked offsets to pre-compute how much border padding the previous pass must produce. + +| Node | Output | Description | +|---|---|---| +| `QueryTileNode(offsetX, offsetY, expectedID)` | Bool | `true` if the tile at `(worldX+offsetX, worldY+offsetY)` equals `expectedID` | +| `QueryRangeNode(minX, minY, maxX, maxY, tileID)` | Int | Count of tiles matching `tileID` within the relative rectangle | +| `QueryDistanceNode(tileID, maxDistance)` | Int | Chebyshev distance to the nearest tile matching `tileID`; returns `maxDistance + 1` if none found. The current cell is excluded. | + +--- + +## Multi-Pass Chunk Generation (`WorldGraphChunk.h`) + +Complex terrain is built in **ordered passes**, where each pass can read the output of the previous one. For example: pass 1 places stone, pass 2 adds ores based on nearby stone. + +### `GenerationPass` + +A thin struct pairing a `Graph` with the `NodeID` of its output node. + +### Zero = "No Change" + +When a pass outputs `0` for a cell, it means **keep the previous pass's value** (or AIR if there was no previous pass). This lets later passes act as selective overrides rather than full rewrites. + +### `GenerateRegion` + +Generates a single rectangular region using one graph. Walks every cell, builds an `EvalContext` pointing at `prevPassData`, evaluates the graph, and writes the result. + +### `GenerateChunk` + +Runs a full sequence of passes over a chunk, automatically computing the padding each pass needs. + +``` +Algorithm: + 1. The last pass generates exactly [chunkOriginX, chunkOriginY, W × H]. + 2. Working backwards from the last pass, ComputeRequiredPadding inspects + every QueryTile / QueryRange / QueryDistance node in each pass's graph + to determine how much the previous pass's output region must expand. + This expansion accumulates from last pass to first. + 3. Passes execute in forward order. Each feeds its TileGrid output to the + next pass as prevPassData. + 4. The final TileGrid covers the original chunk region only. +``` + +### `PaddingBounds` + +Records how many extra tiles are needed beyond the output region in each direction (`negX`, `posX`, `negY`, `posY`). Computed automatically by `ComputeRequiredPadding`, which walks the graph and unions the baked offsets from all query nodes. + +--- + +## Serialization (`WorldGraphSerializer.h`) + +`GraphSerializer` converts a `Graph` to/from JSON (nlohmann/json). + +```cpp +nlohmann::json j = GraphSerializer::ToJson(graph); +optional g = GraphSerializer::FromJson(j); +bool ok = GraphSerializer::Save(graph, "path/to/file.json"); +optional g2 = GraphSerializer::Load("path/to/file.json"); +``` + +JSON structure: +```json +{ + "nextId": 5, + "nodes": [ + { "id": 1, "type": "Constant", "value": 0.5 }, + { "id": 2, "type": "TileID", "tileId": 3 }, + { "id": 3, "type": "Branch" } + ], + "connections": [ + { "from": 1, "to": 3, "slot": 0 }, + { "from": 2, "to": 3, "slot": 1 } + ] +} +``` + +Only `Constant` and `TileID` nodes carry extra fields. All query nodes (`QueryTile`, `QueryRange`, `QueryDistance`) serialize their baked offset/ID parameters. + +--- + +## Minimal Usage Example + +```cpp +using namespace WorldGraph; + +// Build a graph that places tile 2 when Y < -10, otherwise tile 1 +Graph g; +auto posY = g.AddNode(std::make_unique()); +auto thresh = g.AddNode(std::make_unique(-10.0f)); +auto cond = g.AddNode(std::make_unique()); +auto tileA = g.AddNode(std::make_unique(2)); // underground tile +auto tileB = g.AddNode(std::make_unique(1)); // surface tile +auto branch = g.AddNode(std::make_unique()); + +g.Connect(posY, cond, 0); // posY → Less.a +g.Connect(thresh, cond, 1); // -10 → Less.b +g.Connect(cond, branch, 0); // bool → Branch.condition +g.Connect(tileA, branch, 1); // tile2 → Branch.true +g.Connect(tileB, branch, 2); // tile1 → Branch.false + +// Generate a 64×64 chunk at world position (0, -32) +GenerationPass pass { g, branch }; +TileGrid chunk = GenerateChunk({ pass }, 0, -32, 64, 64, /*seed=*/12345); +``` diff --git a/tools/node-editor/CMakeLists.txt b/tools/node-editor/CMakeLists.txt new file mode 100644 index 0000000..9e927bd --- /dev/null +++ b/tools/node-editor/CMakeLists.txt @@ -0,0 +1,65 @@ +find_package(OpenGL REQUIRED) +include(FetchContent) + +# ── ImGui static library ────────────────────────────────────────────────────── + +add_library(imgui_lib STATIC + ${imgui_SOURCE_DIR}/imgui.cpp + ${imgui_SOURCE_DIR}/imgui_draw.cpp + ${imgui_SOURCE_DIR}/imgui_tables.cpp + ${imgui_SOURCE_DIR}/imgui_widgets.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp + ${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp +) + +target_include_directories(imgui_lib PUBLIC + ${imgui_SOURCE_DIR} + ${imgui_SOURCE_DIR}/backends +) + +target_link_libraries(imgui_lib PUBLIC glfw OpenGL::GL) +target_compile_definitions(imgui_lib PUBLIC IMGUI_DEFINE_MATH_OPERATORS) + +# ── imnodes static library ──────────────────────────────────────────────────── + +add_library(imnodes_lib STATIC + ${imnodes_SOURCE_DIR}/imnodes.cpp +) + +target_include_directories(imnodes_lib PUBLIC + ${imnodes_SOURCE_DIR} +) + +target_link_libraries(imnodes_lib PUBLIC imgui_lib) + +# ── ImGuiFileDialog static library ─────────────────────────────────────────── +# Declared in root CMakeLists.txt; populated here so we build only the sources +# we need without running ImGuiFileDialog's own CMakeLists.txt. + +FetchContent_GetProperties(ImGuiFileDialog) +if(NOT imguifiledialog_POPULATED) + FetchContent_Populate(ImGuiFileDialog) +endif() + +add_library(imgui_file_dialog_lib STATIC + ${imguifiledialog_SOURCE_DIR}/ImGuiFileDialog.cpp +) + +target_include_directories(imgui_file_dialog_lib PUBLIC + ${imguifiledialog_SOURCE_DIR} +) + +target_link_libraries(imgui_file_dialog_lib PUBLIC imgui_lib) + +# ── Node editor executable ──────────────────────────────────────────────────── + +add_executable(node-editor + main.cpp +) + +target_link_libraries(node-editor PRIVATE + factory-hole-core + imgui_lib + imnodes_lib + imgui_file_dialog_lib +) diff --git a/tools/node-editor/README.md b/tools/node-editor/README.md new file mode 100644 index 0000000..7380086 --- /dev/null +++ b/tools/node-editor/README.md @@ -0,0 +1,143 @@ +# WorldGraph Node Editor + +A standalone visual node editor for the WorldGraph procedural generation system, built with Dear ImGui and imnodes. Every node displays a live 64×64 preview of its output, ShaderGraph-style. + +## Building & Running + +```bash +make world-editor +``` + +This configures, builds, and launches the editor in one step. + +--- + +## Interface + +### Layout + +| Area | Description | +|---|---| +| Left sidebar | Preview settings, World Output status, help | +| Main canvas | Node graph — pan with middle-mouse, zoom with scroll | + +### Adding Nodes + +Right-click anywhere on the empty canvas to open the node menu, grouped by category. The node spawns at the click position. + +### Connecting Nodes + +Drag from an **output pin** (right side of a node) to an **input pin** (left side). Dragging in either direction works. Connecting to an already-wired input replaces the existing connection. Cycles are rejected automatically. + +### Disconnecting + +Click an existing link to select it, then press **Delete**. Or drag a new connection onto the same input slot to replace it. + +### Deleting Nodes + +Select one or more nodes and press **Delete**. The **World Output** node cannot be deleted. + +### Keyboard Shortcuts + +| Key | Action | +|---|---| +| `Ctrl+S` | Save | +| `Ctrl+O` | Open | +| `Delete` | Delete selected nodes or links | + +--- + +## World Output Node + +The **World Output** node (blue title bar) is the fixed endpoint of the graph. It is always present and cannot be moved off-canvas or deleted. + +Each input slot represents one **generation pass**. Passes execute in order — pass 1 can read the tile output of pass 0 via `QueryTile`, `QueryRange`, and `QueryDistance` nodes. + +- **Connect a pass:** drag any node's output pin into a `Pass N` slot. +- **Add a pass slot:** click `+ Pass` inside the node. +- **Remove the last pass slot:** click `- Pass`. + +The World Output preview shows the result of running `GenerateChunk()` with all connected passes over a 64×64 tile region starting at the preview origin. Each pixel equals one world tile. + +--- + +## Per-Node Previews + +Every node in the graph renders its own 64×64 preview by evaluating the subgraph rooted at that node across a grid of world coordinates. + +| Output type | Preview color | +|---|---| +| `Float` | Grayscale, clamped to `[0, 1]` | +| `Bool` | White (true) / black (false) | +| `Int` (tile ID) | Hashed to a distinct hue; tile 0 (AIR) = near-black | + +**Scale** in the sidebar controls how many world units one pixel represents in per-node previews. This lets you zoom in on high-frequency noise or zoom out to see large-scale structure. + +> Query nodes (`QueryTile`, `QueryRange`, `QueryDistance`) always preview as blank in the per-node view because no previous-pass data is available at that stage. They work correctly in the World Output preview when used in a later pass. + +--- + +## Preview Settings (Sidebar) + +| Setting | Effect | +|---|---| +| **Origin X / Y** | World-space top-left corner of all previews | +| **Scale** | World units per pixel (per-node previews only; World Output is always 1 tile/pixel) | +| **Seed** | World seed passed to every `EvalContext` | + +--- + +## Node Types + +### Source +| Node | Output | Description | +|---|---|---| +| `Constant` | Float | Fixed float value (drag to edit) | +| `TileID` | Int | Fixed tile ID integer (drag to edit) | +| `PositionX` | Int | World X coordinate of the current cell | +| `PositionY` | Int | World Y coordinate of the current cell | + +### Math +`Add`, `Subtract`, `Multiply`, `Divide`, `Modulo`, `Sin`, `Cos` + +### Compare +`Less`, `Greater`, `LessEqual`, `GreaterEqual`, `Equal` — all output `Bool` + +### Logic +`And`, `Or`, `Not` + +### Control +| Node | Inputs | Description | +|---|---|---| +| `Branch` | condition (Bool), true, false | Passes through whichever branch the condition selects | + +### Query (previous pass) +These nodes read from the previous generation pass. They have no input pins — their parameters are edited inline by dragging. + +| Node | Output | Parameters | +|---|---|---| +| `QueryTile` | Bool | `offsetX`, `offsetY`, `expectedID` — true if prev-pass tile at offset equals ID | +| `QueryRange` | Int | `minX..maxX`, `minY..maxY`, `tileID` — count of matching tiles in rectangle | +| `QueryDistance` | Int | `tileID`, `maxDistance` — Chebyshev distance to nearest matching tile | + +--- + +## File Format + +Graphs are saved as `.wge` JSON files containing both the graph data and editor layout: + +```json +{ + "graph": { ... }, + "editor": { + "nodePositions": { "1": [x, y], "__worldOutput": [x, y] }, + "worldOutputPasses": [3, 7], + "seed": 0, + "previewOriginX": 0, + "previewOriginY": 0, + "previewScale": 1.0 + } +} +``` + +`worldOutputPasses` is an array of graph node IDs, one per pass slot (`0` = empty slot). diff --git a/tools/node-editor/main.cpp b/tools/node-editor/main.cpp new file mode 100644 index 0000000..9feab14 --- /dev/null +++ b/tools/node-editor/main.cpp @@ -0,0 +1,1185 @@ +// WorldGraph Node Editor +// Standalone Dear ImGui + imnodes tool for editing WorldGraph node graphs. +// Each node displays a 64×64 preview image of its output, ShaderGraph-style. +// The permanent "World Output" node collects generation passes and renders +// the final generated chunk using GenerateChunk(). + +#include +#include "imgui.h" +#include "imgui_impl_glfw.h" +#include "imgui_impl_opengl3.h" +#include "imnodes.h" +#include "ImGuiFileDialog.h" + +#include "WorldGraph/WorldGraph.h" +#include "WorldGraph/WorldGraphNode.h" +#include "WorldGraph/WorldGraphTypes.h" +#include "WorldGraph/WorldGraphSerializer.h" +#include "WorldGraph/WorldGraphChunk.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace WorldGraph; + +// ───────────────────────────────────────────────────────────────────────────── +// Pin / link ID space +// +// Regular node output pin → nodeID * 10 + 9 +// Regular node input slot S → nodeID * 10 + S (S = 0..8) +// +// World Output input pin N → WORLD_OUTPUT_PIN_BASE + N +// World Output static attr → WORLD_OUTPUT_PIN_BASE + 500 +// +// Regular link IDs → 0 .. (num regular connections - 1) +// World Output link N → WORLD_OUTPUT_LINK_BASE + N +// ───────────────────────────────────────────────────────────────────────────── + +static constexpr int WORLD_OUTPUT_IMNODES_ID = 0; +static constexpr int WORLD_OUTPUT_PIN_BASE = 10000; +static constexpr int WORLD_OUTPUT_LINK_BASE = 20000; + +static int OutputPin(Graph::NodeID n) { return static_cast(n) * 10 + 9; } +static int InputPin (Graph::NodeID n, int slot) { return static_cast(n) * 10 + slot; } +static Graph::NodeID PinToNode(int attr) { return static_cast(attr / 10); } +static int PinToSlot(int attr) { return attr % 10; } // 9 = output + +static bool IsWorldOutputPin(int attr) +{ + return attr >= WORLD_OUTPUT_PIN_BASE && attr < WORLD_OUTPUT_PIN_BASE + 500; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tile registry – maps integer IDs to human-readable names +// ───────────────────────────────────────────────────────────────────────────── + +struct TileEntry { + int32_t id { 0 }; + std::string name { "Tile" }; + float color[3] { 1.0f, 1.0f, 1.0f }; // RGB in [0, 1] +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Color helpers +// ───────────────────────────────────────────────────────────────────────────── + +static void HsvToRgb(float h, float s, float v, uint8_t& r, uint8_t& g, uint8_t& b) +{ + float c = v * s; + float x = c * (1.0f - std::fabs(std::fmod(h * 6.0f, 2.0f) - 1.0f)); + float m = v - c; + float r1, g1, b1; + int hi = static_cast(h * 6.0f) % 6; + switch (hi) { + case 0: r1 = c; g1 = x; b1 = 0; break; + case 1: r1 = x; g1 = c; b1 = 0; break; + case 2: r1 = 0; g1 = c; b1 = x; break; + case 3: r1 = 0; g1 = x; b1 = c; break; + case 4: r1 = x; g1 = 0; b1 = c; break; + default:r1 = c; g1 = 0; b1 = x; break; + } + r = static_cast((r1 + m) * 255.0f); + g = static_cast((g1 + m) * 255.0f); + b = static_cast((b1 + m) * 255.0f); +} + +static void ValueToRGBA(const Value& v, + const std::vector* registry, + uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a) +{ + a = 255; + switch (v.type) { + case Type::Float: { + uint8_t grey = static_cast(std::clamp(v.AsFloat(), 0.0f, 1.0f) * 255.0f); + r = g = b = grey; + return; + } + case Type::Bool: { + uint8_t c = v.AsBool() ? 255 : 0; + r = g = b = c; + return; + } + case Type::Int: + break; + } + + // Int path — look up registry first, fall back to hash. + int32_t id = v.AsInt(); + if (registry) { + for (const auto& t : *registry) { + if (t.id == id) { + r = static_cast(t.color[0] * 255.0f); + g = static_cast(t.color[1] * 255.0f); + b = static_cast(t.color[2] * 255.0f); + return; + } + } + } + // Fallback: tile 0 (AIR) = near-black, others get a hashed hue. + if (id == 0) { r = g = b = 30; return; } + uint32_t h = static_cast(id) * 2654435761u; + HsvToRgb((h & 0xFFu) / 255.0f, 0.7f, 0.85f, r, g, b); +} + +// Pin colors by value type. +// Float → blue Int → orange Bool → green +static ImU32 PinColor(Type t) +{ + switch (t) { + case Type::Float: return IM_COL32(100, 150, 220, 255); + case Type::Int: return IM_COL32(220, 160, 60, 255); + case Type::Bool: return IM_COL32( 80, 200, 100, 255); + } + return IM_COL32(200, 200, 200, 255); +} +static ImU32 PinColorHovered(Type t) +{ + switch (t) { + case Type::Float: return IM_COL32(130, 180, 255, 255); + case Type::Int: return IM_COL32(255, 195, 90, 255); + case Type::Bool: return IM_COL32(110, 235, 130, 255); + } + return IM_COL32(230, 230, 230, 255); +} + +// ───────────────────────────────────────────────────────────────────────────── +// VisualNode – wraps a graph node with its preview texture +// ───────────────────────────────────────────────────────────────────────────── + +static constexpr int PREVIEW_SIZE = 64; + +static GLuint MakePreviewTexture() +{ + GLuint tex = 0; + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + std::array buf; + buf.fill(40); + for (int i = 3; i < (int)buf.size(); i += 4) buf[i] = 255; + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, PREVIEW_SIZE, PREVIEW_SIZE, + 0, GL_RGBA, GL_UNSIGNED_BYTE, buf.data()); + return tex; +} + +struct VisualNode { + Graph::NodeID id { Graph::INVALID_ID }; + GLuint tex { 0 }; + bool dirty { true }; + + void InitTex() { tex = MakePreviewTexture(); } + void FreeTex() { if (tex) { glDeleteTextures(1, &tex); tex = 0; } } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Node catalogue – all creatable node types +// ───────────────────────────────────────────────────────────────────────────── + +struct NodeMenuItem { + const char* label; + const char* category; + std::function()> create; +}; + +static const std::vector NODE_MENU = { + { "Constant", "Source", [] { return std::make_unique(0.0f); } }, + { "TileID", "Source", [] { return std::make_unique(1); } }, + { "PositionX", "Source", [] { return std::make_unique(); } }, + { "PositionY", "Source", [] { return std::make_unique(); } }, + { "Add", "Math", [] { return std::make_unique(); } }, + { "Subtract", "Math", [] { return std::make_unique(); } }, + { "Multiply", "Math", [] { return std::make_unique(); } }, + { "Divide", "Math", [] { return std::make_unique(); } }, + { "Modulo", "Math", [] { return std::make_unique(); } }, + { "Sin", "Math", [] { return std::make_unique(); } }, + { "Cos", "Math", [] { return std::make_unique(); } }, + { "Less", "Compare", [] { return std::make_unique(); } }, + { "Greater", "Compare", [] { return std::make_unique(); } }, + { "LessEqual", "Compare", [] { return std::make_unique(); } }, + { "GreaterEqual", "Compare", [] { return std::make_unique(); } }, + { "Equal", "Compare", [] { return std::make_unique(); } }, + { "And", "Logic", [] { return std::make_unique(); } }, + { "Or", "Logic", [] { return std::make_unique(); } }, + { "Not", "Logic", [] { return std::make_unique(); } }, + { "Branch", "Control", [] { return std::make_unique(); } }, + { "QueryTile", "Query", [] { return std::make_unique(0, -1, 1); } }, + { "QueryRange", "Query", [] { return std::make_unique(-1, -1, 1, 1, 1); } }, + { "QueryDistance", "Query", [] { return std::make_unique(1, 4); } }, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// NodeEditorApp +// ───────────────────────────────────────────────────────────────────────────── + +class NodeEditorApp { +public: + NodeEditorApp() + { + worldOutputTex = MakePreviewTexture(); + } + + ~NodeEditorApp() + { + for (auto& [id, vn] : visualNodes) vn.FreeTex(); + if (worldOutputTex) glDeleteTextures(1, &worldOutputTex); + } + + // ── Preview settings ────────────────────────────────────────────────────── + + int previewOriginX { 0 }; + int previewOriginY { 0 }; + float previewScale { 1.0f }; // world units per pixel (per-node previews only) + uint64_t previewSeed { 0 }; + + // ── Public render entry point ───────────────────────────────────────────── + + void Render() + { + // Position World Output node on first frame (or after NewGraph). + if (pendingWorldOutputPos) { + pendingWorldOutputPos = false; + ImNodes::SetNodeGridSpacePos(WORLD_OUTPUT_IMNODES_ID, ImVec2(400.0f, 0.0f)); + } + + RenderDirtyPreviews(); + + DrawMenuBar(); + + ImGui::SetNextWindowPos(ImVec2(0, 18), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(200, ImGui::GetIO().DisplaySize.y - 18), ImGuiCond_Always); + ImGui::Begin("Settings", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoBringToFrontOnFocus); + DrawSettingsPanel(); + ImGui::End(); + + ImGui::SetNextWindowPos(ImVec2(200, 18), ImGuiCond_Always); + ImGui::SetNextWindowSize( + ImVec2(ImGui::GetIO().DisplaySize.x - 200, ImGui::GetIO().DisplaySize.y - 18), + ImGuiCond_Always); + ImGui::Begin("Node Canvas", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoBringToFrontOnFocus | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + DrawNodeCanvas(); + ImGui::End(); + } + +private: + Graph graph; + std::unordered_map visualNodes; + std::string currentFile; + std::vector tileRegistry { TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}} }; + + // ── World Output node state ─────────────────────────────────────────────── + + // Each entry is the graph node ID connected to that pass slot (INVALID_ID = empty). + std::vector worldOutputPasses { Graph::INVALID_ID }; + GLuint worldOutputTex { 0 }; + bool worldOutputDirty { true }; + + // Deferred first-frame positioning of the World Output node. + bool pendingWorldOutputPos { true }; + + // ── Preview rendering ───────────────────────────────────────────────────── + + void RenderDirtyPreviews() + { + std::array pixels; + + // Per-node previews (evaluate single node across a grid). + for (auto& [id, vn] : visualNodes) { + if (!vn.dirty) continue; + vn.dirty = false; + + Node* node = graph.GetNode(id); + if (!node) continue; + + for (int py = 0; py < PREVIEW_SIZE; ++py) { + for (int px = 0; px < PREVIEW_SIZE; ++px) { + EvalContext ctx; + ctx.worldX = previewOriginX + static_cast(px * previewScale); + ctx.worldY = previewOriginY + static_cast(py * previewScale); + ctx.seed = previewSeed; + + Value v = graph.Evaluate(id, ctx); + int base = (py * PREVIEW_SIZE + px) * 4; + ValueToRGBA(v, &tileRegistry, pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]); + } + } + + glBindTexture(GL_TEXTURE_2D, vn.tex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, PREVIEW_SIZE, PREVIEW_SIZE, + 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + } + + // World Output preview — runs GenerateChunk with all connected passes. + if (worldOutputDirty) { + worldOutputDirty = false; + + std::vector passes; + for (auto nodeID : worldOutputPasses) { + if (nodeID != Graph::INVALID_ID && graph.GetNode(nodeID)) + passes.push_back({ graph, nodeID }); + } + + if (passes.empty()) { + pixels.fill(30); + for (int i = 3; i < (int)pixels.size(); i += 4) pixels[i] = 255; + } else { + // Each pixel = 1 world tile starting at previewOriginX/Y. + TileGrid chunk = GenerateChunk(passes, + previewOriginX, previewOriginY, + PREVIEW_SIZE, PREVIEW_SIZE, + previewSeed); + + for (int py = 0; py < PREVIEW_SIZE; ++py) { + for (int px = 0; px < PREVIEW_SIZE; ++px) { + int32_t tileID = chunk.Get(previewOriginX + px, previewOriginY + py); + int base = (py * PREVIEW_SIZE + px) * 4; + Value v = Value::MakeInt(tileID); + ValueToRGBA(v, &tileRegistry, pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]); + } + } + } + + glBindTexture(GL_TEXTURE_2D, worldOutputTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, PREVIEW_SIZE, PREVIEW_SIZE, + 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + } + } + + void MarkAllDirty() + { + for (auto& [id, vn] : visualNodes) vn.dirty = true; + worldOutputDirty = true; + } + + // ── UI drawing ──────────────────────────────────────────────────────────── + + void DrawMenuBar() + { + if (!ImGui::BeginMainMenuBar()) return; + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("New")) { NewGraph(); } + if (ImGui::MenuItem("Open...", "Ctrl+O")) { OpenFileDialog(); } + if (ImGui::MenuItem("Save", "Ctrl+S")) { SaveCurrent(); } + if (ImGui::MenuItem("Save As...")) { SaveAsDialog(); } + ImGui::EndMenu(); + } + if (!currentFile.empty()) + ImGui::TextDisabled(" %s", currentFile.c_str()); + ImGui::EndMainMenuBar(); + } + + void DrawSettingsPanel() + { + ImGui::SeparatorText("Preview"); + + bool changed = false; + changed |= ImGui::DragInt("Origin X", &previewOriginX, 1.0f); + changed |= ImGui::DragInt("Origin Y", &previewOriginY, 1.0f); + changed |= ImGui::DragFloat("Scale", &previewScale, 0.1f, 0.1f, 64.0f, "%.2f wp/px"); + + int seed32 = static_cast(previewSeed & 0xFFFFFFFFu); + if (ImGui::DragInt("Seed", &seed32)) { + previewSeed = static_cast(static_cast(seed32)); + changed = true; + } + + if (changed) MarkAllDirty(); + + ImGui::Spacing(); + ImGui::SeparatorText("Tile IDs"); + + int toRemove = -1; + for (int i = 0; i < static_cast(tileRegistry.size()); ++i) { + ImGui::PushID(i); + TileEntry& entry = tileRegistry[i]; + + // Color button — opens a popup picker. + char cpopup[32]; + snprintf(cpopup, sizeof(cpopup), "##cpick%d", i); + if (ImGui::ColorButton(cpopup, + ImVec4(entry.color[0], entry.color[1], entry.color[2], 1.0f), + ImGuiColorEditFlags_NoTooltip | ImGuiColorEditFlags_NoBorder, + ImVec2(18, 18))) { + ImGui::OpenPopup(cpopup); + } + if (ImGui::BeginPopup(cpopup)) { + if (ImGui::ColorPicker3("##cp", entry.color, + ImGuiColorEditFlags_NoLabel | ImGuiColorEditFlags_NoSidePreview)) + MarkAllDirty(); + ImGui::EndPopup(); + } + ImGui::SameLine(0, 3); + + // ID field (narrow) — locked for the reserved Empty entry + ImGui::BeginDisabled(entry.id == 0); + ImGui::SetNextItemWidth(32); + ImGui::DragInt("##tid", &entry.id, 1, 0, 9999); + ImGui::EndDisabled(); + ImGui::SameLine(0, 3); + + // Name field (fills remaining width minus remove button) + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 20); + char buf[64]; + strncpy(buf, entry.name.c_str(), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + if (ImGui::InputText("##tname", buf, sizeof(buf))) + entry.name = buf; + ImGui::SameLine(0, 3); + + // ID 0 "Empty" is permanent — show a disabled remove button + ImGui::BeginDisabled(entry.id == 0); + if (ImGui::SmallButton("x")) + toRemove = i; + ImGui::EndDisabled(); + + ImGui::PopID(); + } + if (toRemove >= 0) + tileRegistry.erase(tileRegistry.begin() + toRemove); + + if (ImGui::SmallButton("+ Add Tile")) { + int32_t nextId = tileRegistry.empty() ? 1 + : tileRegistry.back().id + 1; + TileEntry entry; + entry.id = nextId; + entry.name = "Tile"; + // Seed the default color from the hash so new tiles start distinct. + uint8_t hr, hg, hb; + uint32_t h = static_cast(nextId) * 2654435761u; + HsvToRgb((h & 0xFFu) / 255.0f, 0.7f, 0.85f, hr, hg, hb); + entry.color[0] = hr / 255.0f; + entry.color[1] = hg / 255.0f; + entry.color[2] = hb / 255.0f; + tileRegistry.push_back(entry); + } + + ImGui::Spacing(); + ImGui::SeparatorText("World Output"); + + int connected = 0; + for (auto id : worldOutputPasses) + if (id != Graph::INVALID_ID) ++connected; + ImGui::Text("%d / %zu pass(es) connected", connected, worldOutputPasses.size()); + + ImGui::Spacing(); + ImGui::SeparatorText("Graph"); + ImGui::Text("%zu nodes", graph.NodeCount()); + + ImGui::Spacing(); + ImGui::SeparatorText("Help"); + ImGui::TextWrapped( + "Right-click canvas to add nodes.\n" + "Drag an output pin into a World Output pass slot to register a generation pass.\n" + "Delete key removes selected nodes.\n" + "Scale controls per-node previews only; World Output always shows 1 tile/pixel.\n" + "\nQuery nodes preview blank (no prev-pass data in per-node preview)." + ); + } + + void DrawNodeCanvas() + { + ImNodes::BeginNodeEditor(); + + // Right-click blank canvas → add node menu + if (ImGui::IsWindowHovered(ImGuiFocusedFlags_RootAndChildWindows) && + ImNodes::IsEditorHovered() && + ImGui::IsMouseReleased(ImGuiMouseButton_Right) && + !ImGui::IsAnyItemHovered()) + { + ImGui::OpenPopup("##add_node_menu"); + } + + ImGui::SetNextWindowSize(ImVec2(220, 300), ImGuiCond_Always); + if (ImGui::BeginPopup("##add_node_menu")) { + ImVec2 spawnPos = ImGui::GetMousePosOnOpeningCurrentPopup(); + + static char filterBuf[128] = ""; + if (ImGui::IsWindowAppearing()) { + ImGui::SetKeyboardFocusHere(); + filterBuf[0] = '\0'; + } + ImGui::SetNextItemWidth(-1); + ImGui::InputText("##filter", filterBuf, sizeof(filterBuf)); + ImGui::Separator(); + + const bool filtering = filterBuf[0] != '\0'; + + auto spawnItem = [&](const NodeMenuItem& item) { + if (ImGui::MenuItem(item.label)) { + Graph::NodeID newId = AddNode(item.create()); + ImNodes::SetNodeScreenSpacePos(static_cast(newId), spawnPos); + } + }; + + // Scrollable list region — fills the remaining space below the filter. + ImGui::BeginChild("##node_list", ImVec2(0, 0), false); + + if (filtering) { + // Case-insensitive flat list — matches label or category name. + std::string needle = filterBuf; + for (char& c : needle) c = static_cast(std::tolower(static_cast(c))); + + for (const auto& item : NODE_MENU) { + auto contains = [&](const char* s) { + std::string hay = s; + for (char& c : hay) c = static_cast(std::tolower(static_cast(c))); + return hay.find(needle) != std::string::npos; + }; + if (contains(item.label) || contains(item.category)) + spawnItem(item); + } + } else { + // Tree view grouped by category, all expanded by default. + const char* curCat = nullptr; + bool catOpen = false; + for (const auto& item : NODE_MENU) { + if (!curCat || strcmp(curCat, item.category) != 0) { + if (curCat && catOpen) ImGui::TreePop(); + catOpen = ImGui::TreeNodeEx(item.category, ImGuiTreeNodeFlags_DefaultOpen); + curCat = item.category; + } + if (catOpen) + spawnItem(item); + } + if (curCat && catOpen) ImGui::TreePop(); + } + + ImGui::EndChild(); + ImGui::EndPopup(); + } + + // Draw the permanent World Output node first (so it's rendered behind others). + DrawWorldOutputNode(); + + // Draw all regular nodes. + for (auto& [id, vn] : visualNodes) + DrawNode(vn); + + // Draw regular graph connections. + int linkId = 0; + for (auto& [id, vn] : visualNodes) { + Node* node = graph.GetNode(id); + if (!node) continue; + for (int slot = 0; slot < static_cast(node->GetInputCount()); ++slot) { + auto src = graph.GetInput(id, slot); + if (src.has_value()) + ImNodes::Link(linkId++, OutputPin(*src), InputPin(id, slot)); + } + } + + // Draw World Output pass connections (fixed link IDs avoid re-enumeration issues). + for (int i = 0; i < static_cast(worldOutputPasses.size()); ++i) { + if (worldOutputPasses[i] != Graph::INVALID_ID) + ImNodes::Link(WORLD_OUTPUT_LINK_BASE + i, + OutputPin(worldOutputPasses[i]), + WORLD_OUTPUT_PIN_BASE + i); + } + + ImNodes::EndNodeEditor(); + + // ── Handle new connections ──────────────────────────────────────────── + + int fromAttr, toAttr; + if (ImNodes::IsLinkCreated(&fromAttr, &toAttr)) { + bool fromIsWO = IsWorldOutputPin(fromAttr); + bool toIsWO = IsWorldOutputPin(toAttr); + + if (toIsWO || fromIsWO) { + // One end is a World Output pass slot; the other must be a regular output. + int woPin = toIsWO ? toAttr : fromAttr; + int regularPin = toIsWO ? fromAttr : toAttr; + + if (PinToSlot(regularPin) == 9) { // must be an output pin + int passIdx = woPin - WORLD_OUTPUT_PIN_BASE; + if (passIdx >= 0 && passIdx < static_cast(worldOutputPasses.size())) { + // World Output only accepts Int (tile ID) outputs. + Node* srcNode = graph.GetNode(PinToNode(regularPin)); + if (srcNode && srcNode->GetOutputType() == Type::Int) { + worldOutputPasses[passIdx] = PinToNode(regularPin); + worldOutputDirty = true; + } + } + } + } else { + // Regular node-to-node connection. + Graph::NodeID fromNode, toNode; + int toSlot; + if (PinToSlot(fromAttr) == 9 && PinToSlot(toAttr) != 9) { + fromNode = PinToNode(fromAttr); + toNode = PinToNode(toAttr); + toSlot = PinToSlot(toAttr); + } else if (PinToSlot(toAttr) == 9 && PinToSlot(fromAttr) != 9) { + fromNode = PinToNode(toAttr); + toNode = PinToNode(fromAttr); + toSlot = PinToSlot(fromAttr); + } else { + goto done_link; + } + // Only connect when output type matches the destination input type. + Node* srcNode = graph.GetNode(fromNode); + Node* dstNode = graph.GetNode(toNode); + if (srcNode && dstNode) { + auto dstInputTypes = dstNode->GetInputTypes(); + if (toSlot < static_cast(dstInputTypes.size()) && + srcNode->GetOutputType() == dstInputTypes[toSlot]) + { + if (graph.Connect(fromNode, toNode, toSlot)) + MarkAllDirty(); + } + } + } + } + done_link:; + + // ── Handle link deletion ────────────────────────────────────────────── + + int destroyedLink; + if (ImNodes::IsLinkDestroyed(&destroyedLink)) { + if (destroyedLink >= WORLD_OUTPUT_LINK_BASE) { + // World Output link + int passIdx = destroyedLink - WORLD_OUTPUT_LINK_BASE; + if (passIdx >= 0 && passIdx < static_cast(worldOutputPasses.size())) { + worldOutputPasses[passIdx] = Graph::INVALID_ID; + worldOutputDirty = true; + } + } else { + // Regular link — re-enumerate to find which one. + int idx = 0; + bool found = false; + for (auto& [id, vn] : visualNodes) { + if (found) break; + Node* node = graph.GetNode(id); + if (!node) continue; + for (int slot = 0; slot < static_cast(node->GetInputCount()); ++slot) { + if (graph.GetInput(id, slot).has_value()) { + if (idx == destroyedLink) { + graph.Disconnect(id, slot); + MarkAllDirty(); + found = true; + break; + } + ++idx; + } + } + } + } + } + + // ── Delete selected nodes ───────────────────────────────────────────── + + if (ImGui::IsKeyPressed(ImGuiKey_Delete)) { + int count = ImNodes::NumSelectedNodes(); + if (count > 0) { + std::vector selected(count); + ImNodes::GetSelectedNodes(selected.data()); + for (int sid : selected) { + if (sid == WORLD_OUTPUT_IMNODES_ID) continue; // permanent node + + Graph::NodeID gid = static_cast(sid); + + // Clear any World Output pass that referenced this node. + for (auto& pass : worldOutputPasses) + if (pass == gid) { pass = Graph::INVALID_ID; worldOutputDirty = true; } + + graph.RemoveNode(gid); + auto it = visualNodes.find(gid); + if (it != visualNodes.end()) { + it->second.FreeTex(); + visualNodes.erase(it); + } + } + MarkAllDirty(); + } + } + } + + // ── World Output node ───────────────────────────────────────────────────── + + void DrawWorldOutputNode() + { + ImNodes::PushColorStyle(ImNodesCol_TitleBar, IM_COL32( 30, 80, 160, 255)); + ImNodes::PushColorStyle(ImNodesCol_TitleBarHovered, IM_COL32( 45, 100, 190, 255)); + ImNodes::PushColorStyle(ImNodesCol_TitleBarSelected, IM_COL32( 60, 120, 210, 255)); + + ImNodes::BeginNode(WORLD_OUTPUT_IMNODES_ID); + + ImNodes::BeginNodeTitleBar(); + ImGui::Text("World Output"); + ImNodes::EndNodeTitleBar(); + + // One input pin per generation pass — Int only. + for (int i = 0; i < static_cast(worldOutputPasses.size()); ++i) { + ImNodes::PushColorStyle(ImNodesCol_Pin, PinColor(Type::Int)); + ImNodes::PushColorStyle(ImNodesCol_PinHovered, PinColorHovered(Type::Int)); + ImNodes::BeginInputAttribute(WORLD_OUTPUT_PIN_BASE + i); + Graph::NodeID connected = worldOutputPasses[i]; + if (connected != Graph::INVALID_ID) { + Node* n = graph.GetNode(connected); + ImGui::Text("Pass %d (%s)", i, n ? n->GetName().c_str() : "?"); + } else { + ImGui::TextDisabled("Pass %d (empty)", i); + } + ImNodes::EndInputAttribute(); + ImNodes::PopColorStyle(); + ImNodes::PopColorStyle(); + } + + // Add / remove pass buttons. + ImNodes::BeginStaticAttribute(WORLD_OUTPUT_PIN_BASE + 500); + if (ImGui::SmallButton("+ Pass")) { + worldOutputPasses.push_back(Graph::INVALID_ID); + } + if (worldOutputPasses.size() > 1) { + ImGui::SameLine(); + if (ImGui::SmallButton("- Pass")) { + worldOutputPasses.pop_back(); + worldOutputDirty = true; + } + } + ImNodes::EndStaticAttribute(); + + // Generated chunk preview — always 1 tile per pixel. + ImGui::Image( + static_cast(static_cast(worldOutputTex)), + ImVec2(PREVIEW_SIZE, PREVIEW_SIZE)); + + ImNodes::EndNode(); + + ImNodes::PopColorStyle(); + ImNodes::PopColorStyle(); + ImNodes::PopColorStyle(); + } + + // ── Regular node ───────────────────────────────────────────────────────── + + void DrawNode(VisualNode& vn) + { + Node* node = graph.GetNode(vn.id); + if (!node) return; + + ImNodes::BeginNode(static_cast(vn.id)); + + ImNodes::BeginNodeTitleBar(); + ImGui::Text("%s", node->GetName().c_str()); + ImNodes::EndNodeTitleBar(); + + // Input pins. + auto inputTypes = node->GetInputTypes(); + for (int slot = 0; slot < static_cast(inputTypes.size()); ++slot) { + ImNodes::PushColorStyle(ImNodesCol_Pin, PinColor(inputTypes[slot])); + ImNodes::PushColorStyle(ImNodesCol_PinHovered, PinColorHovered(inputTypes[slot])); + ImNodes::BeginInputAttribute(InputPin(vn.id, slot)); + const char* t = inputTypes[slot] == Type::Float ? "f" + : inputTypes[slot] == Type::Int ? "i" : "b"; + ImGui::TextDisabled("[%s] in%d", t, slot); + ImNodes::EndInputAttribute(); + ImNodes::PopColorStyle(); + ImNodes::PopColorStyle(); + } + + DrawNodeParams(vn.id, node); + + ImGui::Image( + static_cast(static_cast(vn.tex)), + ImVec2(PREVIEW_SIZE, PREVIEW_SIZE)); + + ImNodes::PushColorStyle(ImNodesCol_Pin, PinColor(node->GetOutputType())); + ImNodes::PushColorStyle(ImNodesCol_PinHovered, PinColorHovered(node->GetOutputType())); + ImNodes::BeginOutputAttribute(OutputPin(vn.id)); + const char* outType = node->GetOutputType() == Type::Float ? "Float" + : node->GetOutputType() == Type::Int ? "Int" : "Bool"; + ImGui::Text("out (%s) →", outType); + ImNodes::EndOutputAttribute(); + ImNodes::PopColorStyle(); + ImNodes::PopColorStyle(); + + ImNodes::EndNode(); + } + + // ── Per-type param drawers ──────────────────────────────────────────────── + // Each function receives the app (for instance state like tileRegistry) and + // the node, and returns true when any value changed. + // Static member functions can access private members through the app ref. + // + // To support a new node type: + // 1. Add a Draw*Params function here. + // 2. Register it in the kDrawers table in DrawNodeParams below. + + static bool DrawConstantParams(NodeEditorApp& /*app*/, Node* node) + { + auto* n = static_cast(node); + return ImGui::DragFloat("##val", &n->value, 0.01f); + } + + static bool DrawIDParams(NodeEditorApp& app, Node* node) + { + auto* n = static_cast(node); + bool changed = false; + if (app.tileRegistry.empty()) { + changed = ImGui::DragInt("##id", &n->tileID, 1, 0, 9999); + } else { + int selIdx = -1; + for (int i = 0; i < static_cast(app.tileRegistry.size()); ++i) + if (app.tileRegistry[i].id == n->tileID) { selIdx = i; break; } + const char* preview = selIdx >= 0 ? app.tileRegistry[selIdx].name.c_str() : "???"; + ImGui::SetNextItemWidth(130); + if (ImGui::BeginCombo("##tile", preview)) { + for (int i = 0; i < static_cast(app.tileRegistry.size()); ++i) { + bool sel = (i == selIdx); + char label[80]; + snprintf(label, sizeof(label), "%s (%d)", + app.tileRegistry[i].name.c_str(), app.tileRegistry[i].id); + if (ImGui::Selectable(label, sel)) { + n->tileID = app.tileRegistry[i].id; + changed = true; + } + if (sel) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } + return changed; + } + + static bool DrawQueryTileParams(NodeEditorApp& /*app*/, Node* node) + { + auto* n = static_cast(node); + bool changed = false; + changed |= ImGui::DragInt("##ox", &n->offsetX, 1); + ImGui::SameLine(); + changed |= ImGui::DragInt("##oy", &n->offsetY, 1); + changed |= ImGui::DragInt("##eid", &n->expectedID, 1, 0, 1024); + return changed; + } + + static bool DrawQueryRangeParams(NodeEditorApp& /*app*/, Node* node) + { + auto* n = static_cast(node); + bool changed = false; + changed |= ImGui::DragInt("##mnx", &n->minX, 1); + ImGui::SameLine(); + changed |= ImGui::DragInt("##mxx", &n->maxX, 1); + changed |= ImGui::DragInt("##mny", &n->minY, 1); + ImGui::SameLine(); + changed |= ImGui::DragInt("##mxy", &n->maxY, 1); + changed |= ImGui::DragInt("##rtid", &n->tileID, 1, 0, 1024); + return changed; + } + + static bool DrawQueryDistanceParams(NodeEditorApp& /*app*/, Node* node) + { + auto* n = static_cast(node); + bool changed = false; + changed |= ImGui::DragInt("##dtid", &n->tileID, 1, 0, 1024); + changed |= ImGui::DragInt("##md", &n->maxDistance, 1, 1, 32); + return changed; + } + + // ── Param dispatcher ────────────────────────────────────────────────────── + + void DrawNodeParams(Graph::NodeID /*id*/, Node* node) + { + using DrawFn = bool(*)(NodeEditorApp&, Node*); + static const std::unordered_map kDrawers = { + { typeid(ConstantNode), DrawConstantParams }, + { typeid(IDNode), DrawIDParams }, + { typeid(QueryTileNode), DrawQueryTileParams }, + { typeid(QueryRangeNode), DrawQueryRangeParams }, + { typeid(QueryDistanceNode), DrawQueryDistanceParams }, + }; + + auto it = kDrawers.find(typeid(*node)); + if (it != kDrawers.end() && it->second(*this, node)) + MarkAllDirty(); + } + + // ── Graph management ────────────────────────────────────────────────────── + + Graph::NodeID AddNode(std::unique_ptr node) + { + Graph::NodeID id = graph.AddNode(std::move(node)); + VisualNode vn; + vn.id = id; + vn.dirty = true; + vn.InitTex(); + visualNodes.emplace(id, std::move(vn)); + return id; + } + + void NewGraph() + { + for (auto& [id, vn] : visualNodes) vn.FreeTex(); + visualNodes.clear(); + graph = Graph{}; + worldOutputPasses = { Graph::INVALID_ID }; + worldOutputDirty = true; + pendingWorldOutputPos = true; + currentFile = ""; + } + + // ── Serialization ───────────────────────────────────────────────────────── + + void Save(const std::string& path) + { + nlohmann::json root; + root["graph"] = GraphSerializer::ToJson(graph); + + // Node positions (including World Output). + nlohmann::json positions = nlohmann::json::object(); + for (auto& [id, vn] : visualNodes) { + ImVec2 pos = ImNodes::GetNodeGridSpacePos(static_cast(id)); + positions[std::to_string(id)] = { pos.x, pos.y }; + } + { + ImVec2 pos = ImNodes::GetNodeGridSpacePos(WORLD_OUTPUT_IMNODES_ID); + positions["__worldOutput"] = { pos.x, pos.y }; + } + + root["editor"]["nodePositions"] = positions; + root["editor"]["seed"] = previewSeed; + root["editor"]["previewOriginX"] = previewOriginX; + root["editor"]["previewOriginY"] = previewOriginY; + root["editor"]["previewScale"] = previewScale; + + nlohmann::json passes = nlohmann::json::array(); + for (auto id : worldOutputPasses) + passes.push_back(id); + root["editor"]["worldOutputPasses"] = passes; + + nlohmann::json tiles = nlohmann::json::array(); + for (auto& t : tileRegistry) + tiles.push_back({ {"id", t.id}, {"name", t.name}, + {"color", { t.color[0], t.color[1], t.color[2] }} }); + root["editor"]["tileRegistry"] = tiles; + + std::ofstream f(path); + if (f) { f << root.dump(2); currentFile = path; } + else fprintf(stderr, "Failed to write %s\n", path.c_str()); + } + + void Load(const std::string& path) + { + std::ifstream f(path); + if (!f) { fprintf(stderr, "Cannot open %s\n", path.c_str()); return; } + + nlohmann::json root; + try { root = nlohmann::json::parse(f); } + catch (...) { fprintf(stderr, "JSON parse error in %s\n", path.c_str()); return; } + + auto newGraph = GraphSerializer::FromJson(root.value("graph", nlohmann::json::object())); + if (!newGraph) { fprintf(stderr, "Invalid graph in %s\n", path.c_str()); return; } + + NewGraph(); + graph = std::move(*newGraph); + + uint32_t maxId = root["graph"].value("nextId", 1u); + for (uint32_t id = 1; id < maxId; ++id) { + if (graph.GetNode(id)) { + VisualNode vn; + vn.id = id; + vn.dirty = true; + vn.InitTex(); + visualNodes.emplace(id, std::move(vn)); + } + } + + if (root.contains("editor")) { + const auto& ed = root["editor"]; + previewSeed = ed.value("seed", uint64_t{0}); + previewOriginX = ed.value("previewOriginX", 0); + previewOriginY = ed.value("previewOriginY", 0); + previewScale = ed.value("previewScale", 1.0f); + + if (ed.contains("tileRegistry")) { + tileRegistry.clear(); + for (auto& t : ed["tileRegistry"]) { + TileEntry entry; + entry.id = t.value("id", 0); + entry.name = t.value("name", std::string("Tile")); + if (t.contains("color") && t["color"].size() == 3) { + entry.color[0] = t["color"][0].get(); + entry.color[1] = t["color"][1].get(); + entry.color[2] = t["color"][2].get(); + } + tileRegistry.push_back(entry); + } + // Always guarantee the Empty (ID 0) sentinel is present. + bool hasEmpty = std::any_of(tileRegistry.begin(), tileRegistry.end(), + [](const TileEntry& e) { return e.id == 0; }); + if (!hasEmpty) + tileRegistry.insert(tileRegistry.begin(), + TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}}); + } + + if (ed.contains("worldOutputPasses")) { + worldOutputPasses.clear(); + for (auto& v : ed["worldOutputPasses"]) + worldOutputPasses.push_back(v.get()); + if (worldOutputPasses.empty()) + worldOutputPasses.push_back(Graph::INVALID_ID); + } + + if (ed.contains("nodePositions")) { + for (auto& [key, val] : ed["nodePositions"].items()) { + float x = val[0].get(); + float y = val[1].get(); + if (key == "__worldOutput") { + ImNodes::SetNodeGridSpacePos(WORLD_OUTPUT_IMNODES_ID, ImVec2(x, y)); + pendingWorldOutputPos = false; + } else { + ImNodes::SetNodeGridSpacePos(std::stoi(key), ImVec2(x, y)); + } + } + } + } + + currentFile = path; + } + + // ── File dialogs ────────────────────────────────────────────────────────── + + void OpenFileDialog() + { + IGFD::FileDialogConfig cfg; + cfg.path = currentFile.empty() ? "." + : std::filesystem::path(currentFile).parent_path().string(); + ImGuiFileDialog::Instance()->OpenDialog("OpenFileDlg", "Open Graph", ".json", cfg); + } + + void SaveAsDialog() + { + IGFD::FileDialogConfig cfg; + if (!currentFile.empty()) { + std::filesystem::path p(currentFile); + cfg.path = p.parent_path().string(); + cfg.fileName = p.filename().string(); + } else { + cfg.path = "."; + cfg.fileName = "graph.json"; + } + ImGuiFileDialog::Instance()->OpenDialog("SaveFileDlg", "Save Graph", ".json", cfg); + } + + void SaveCurrent() { if (currentFile.empty()) SaveAsDialog(); else Save(currentFile); } + +public: + void RenderFileDialogs() + { + ImVec2 dlgSize(600, 400); + + if (ImGuiFileDialog::Instance()->Display("OpenFileDlg", + ImGuiWindowFlags_NoCollapse, dlgSize)) { + if (ImGuiFileDialog::Instance()->IsOk()) + Load(ImGuiFileDialog::Instance()->GetFilePathName()); + ImGuiFileDialog::Instance()->Close(); + } + + if (ImGuiFileDialog::Instance()->Display("SaveFileDlg", + ImGuiWindowFlags_NoCollapse, dlgSize)) { + if (ImGuiFileDialog::Instance()->IsOk()) + Save(ImGuiFileDialog::Instance()->GetFilePathName()); + ImGuiFileDialog::Instance()->Close(); + } + } + + void HandleKeyboardShortcuts() + { + ImGuiIO& io = ImGui::GetIO(); + if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_S)) SaveCurrent(); + if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_O)) ImGui::OpenPopup("##open_path"); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Main / render loop +// ───────────────────────────────────────────────────────────────────────────── + +static void GlfwErrorCallback(int error, const char* desc) +{ + fprintf(stderr, "GLFW error %d: %s\n", error, desc); +} + +int main() +{ + glfwSetErrorCallback(GlfwErrorCallback); + if (!glfwInit()) return 1; + + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); +#ifdef __APPLE__ + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); +#endif + + GLFWwindow* window = glfwCreateWindow(1280, 720, "WorldGraph Node Editor", nullptr, nullptr); + if (!window) { glfwTerminate(); return 1; } + + glfwMakeContextCurrent(window); + glfwSwapInterval(1); + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImNodes::CreateContext(); + + ImGuiIO& io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + + ImGui::StyleColorsDark(); + ImNodes::StyleColorsDark(); + ImNodes::GetStyle().NodePadding = ImVec2(8, 8); + + ImGui_ImplGlfw_InitForOpenGL(window, true); + ImGui_ImplOpenGL3_Init("#version 330"); + + NodeEditorApp app; + + while (!glfwWindowShouldClose(window)) { + glfwPollEvents(); + + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplGlfw_NewFrame(); + ImGui::NewFrame(); + + app.HandleKeyboardShortcuts(); + app.Render(); + app.RenderFileDialogs(); + + ImGui::Render(); + + int dispW, dispH; + glfwGetFramebufferSize(window, &dispW, &dispH); + glViewport(0, 0, dispW, dispH); + glClearColor(0.15f, 0.15f, 0.15f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + glfwSwapBuffers(window); + } + + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplGlfw_Shutdown(); + ImNodes::DestroyContext(); + ImGui::DestroyContext(); + glfwDestroyWindow(window); + glfwTerminate(); + return 0; +}