diff --git a/data/WorldGraphs/dirt.json b/data/WorldGraphs/dirt.json new file mode 100644 index 0000000..f0f7036 --- /dev/null +++ b/data/WorldGraphs/dirt.json @@ -0,0 +1,205 @@ +{ + "editor": { + "nodePositions": { + "1": [ + -1011.0, + -214.0 + ], + "10": [ + -601.0, + -250.0 + ], + "11": [ + -337.0, + -265.0 + ], + "12": [ + -351.2725830078125, + 240.48638916015625 + ], + "2": [ + -740.0, + -231.0 + ], + "3": [ + -885.0, + -153.0 + ], + "4": [ + -472.0, + -118.0 + ], + "5": [ + -144.0, + -283.0 + ], + "6": [ + -341.0, + -61.0 + ], + "7": [ + -23.0, + -218.0 + ], + "9": [ + 136.0, + -300.0 + ], + "__worldOutput": [ + 400.0, + 0.0 + ] + }, + "previewOriginX": 0, + "previewOriginY": 0, + "previewScale": 0.4000000059604645, + "seed": 0, + "tileRegistry": [ + { + "color": [ + 0.11764705926179886, + 0.11764705926179886, + 0.11764705926179886 + ], + "id": 0, + "name": "Empty" + }, + { + "color": [ + 0.6958129405975342, + 0.6926098465919495, + 0.7119565010070801 + ], + "id": 1, + "name": "Stone" + }, + { + "color": [ + 0.05567699670791626, + 0.7880434989929199, + 0.09547946602106094 + ], + "id": 2, + "name": "Dirt" + }, + { + "color": [ + 0.2771739363670349, + 0.06326796859502792, + 0.0 + ], + "id": 3, + "name": "Tree" + } + ], + "worldOutputPasses": [ + 9, + 0 + ] + }, + "graph": { + "connections": [ + { + "from": 1, + "slot": 0, + "to": 2 + }, + { + "from": 3, + "slot": 1, + "to": 2 + }, + { + "from": 11, + "slot": 0, + "to": 5 + }, + { + "from": 6, + "slot": 1, + "to": 5 + }, + { + "from": 5, + "slot": 0, + "to": 9 + }, + { + "from": 7, + "slot": 1, + "to": 9 + }, + { + "from": 2, + "slot": 0, + "to": 10 + }, + { + "from": 10, + "slot": 0, + "to": 11 + }, + { + "from": 4, + "slot": 1, + "to": 11 + } + ], + "nextId": 13, + "nodes": [ + { + "id": 1, + "type": "PositionY" + }, + { + "id": 2, + "type": "Multiply" + }, + { + "id": 3, + "type": "Constant", + "value": 0.019999999552965164 + }, + { + "frequency": 0.07880000025033951, + "id": 4, + "type": "SimplexNoise" + }, + { + "id": 5, + "type": "Greater" + }, + { + "id": 6, + "type": "Constant", + "value": 0.1599999964237213 + }, + { + "id": 7, + "tileId": 1, + "type": "TileID" + }, + { + "id": 9, + "type": "IntBranch" + }, + { + "id": 10, + "type": "Max" + }, + { + "id": 11, + "type": "Multiply" + }, + { + "id": 12, + "maxX": 1, + "maxY": 1, + "minX": -1, + "minY": -1, + "tileId": 1, + "type": "QueryRange" + } + ] + } +} \ No newline at end of file diff --git a/data/WorldGraphs/stone.json b/data/WorldGraphs/stone.json new file mode 100644 index 0000000..842161a --- /dev/null +++ b/data/WorldGraphs/stone.json @@ -0,0 +1,120 @@ +{ + "editor": { + "nodePositions": { + "1": [ + -70.0, + -259.0 + ], + "13": [ + -220.0, + 24.0 + ], + "15": [ + -77.0, + 8.0 + ], + "16": [ + -223.0, + 206.0 + ], + "2": [ + 112.0, + -289.0 + ], + "4": [ + -360.0, + 41.0 + ], + "__worldOutput": [ + 400.0, + 0.0 + ] + }, + "previewOriginX": 0, + "previewOriginY": 0, + "previewScale": 0.4000000059604645, + "seed": 0, + "tileRegistry": [ + { + "color": [ + 0.11764705926179886, + 0.11764705926179886, + 0.11764705926179886 + ], + "id": 0, + "name": "Empty" + }, + { + "color": [ + 0.7348794341087341, + 0.730800986289978, + 0.7554347515106201 + ], + "id": 1, + "name": "Stone" + } + ], + "worldOutputPasses": [ + 2 + ] + }, + "graph": { + "connections": [ + { + "from": 15, + "slot": 0, + "to": 2 + }, + { + "from": 1, + "slot": 1, + "to": 2 + }, + { + "from": 4, + "slot": 0, + "to": 13 + }, + { + "from": 13, + "slot": 0, + "to": 15 + }, + { + "from": 16, + "slot": 1, + "to": 15 + } + ], + "nextId": 17, + "nodes": [ + { + "id": 1, + "tileId": 1, + "type": "TileID" + }, + { + "id": 2, + "type": "IntBranch" + }, + { + "frequency": 0.10000000149011612, + "id": 4, + "type": "SimplexNoise" + }, + { + "id": 13, + "type": "Abs" + }, + { + "id": 15, + "type": "Less" + }, + { + "id": 16, + "type": "Constant", + "value": 0.3199999928474426 + } + ] + } +} \ No newline at end of file diff --git a/graph.json b/graph.json deleted file mode 100644 index 006aff0..0000000 --- a/graph.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "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 index a0e3735..a23bce8 100644 --- a/imgui.ini +++ b/imgui.ini @@ -1,10 +1,10 @@ [Window][Node Canvas] -Pos=200,18 -Size=1080,702 +Pos=200,42 +Size=1720,1039 [Window][Settings] -Pos=0,18 -Size=200,702 +Pos=0,42 +Size=200,1039 [Window][Debug##Default] Pos=60,60 @@ -34,3 +34,59 @@ Column 0 Sort=0v RefScale=13 Column 0 Sort=0v +[Table][0x3E490810,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0x4546A666,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0xFE9B7E06,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0xD2055CEE,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0xCDBCCF58,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0x9E317450,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0x26C1F0A6,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0x5DDF70E5,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0x4AC5837A,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0xFB4E8B6C,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0x8F6E2C61,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0x42C29929,4] +RefScale=13 +Column 0 Sort=0v + +[Table][0x8FD0E34F,4] +RefScale=13 +Column 0 Sort=0v + +[NodeEditor][Session] +LastFiles=/home/connor/repos/factory-hole-core/data/WorldGraphs/stone.json;/home/connor/repos/factory-hole-core/data/WorldGraphs/dirt.json +ActiveTab=1 + diff --git a/include/WorldGraph/WorldGraphNode.h b/include/WorldGraph/WorldGraphNode.h index 3825425..766f41e 100644 --- a/include/WorldGraph/WorldGraphNode.h +++ b/include/WorldGraph/WorldGraphNode.h @@ -366,19 +366,45 @@ public: /// Outputs the world-space X coordinate of the cell being evaluated. class PositionXNode : public Node { public: - Type GetOutputType() const override { return Type::Int; } + Type GetOutputType() const override { return Type::Float; } std::vector GetInputTypes() const override { return {}; } std::string GetName() const override { return "PositionX"; } - Value Evaluate(const EvalContext& ctx, const std::vector&) const override { return Value::MakeInt(ctx.worldX); }; + Value Evaluate(const EvalContext& ctx, const std::vector&) const override { return Value::MakeFloat(ctx.worldX); }; }; /// Outputs the world-space Y coordinate of the cell being evaluated. class PositionYNode : public Node { public: - Type GetOutputType() const override { return Type::Int; } + Type GetOutputType() const override { return Type::Float; } std::vector GetInputTypes() const override { return {}; } std::string GetName() const override { return "PositionY"; } - Value Evaluate(const EvalContext& ctx, const std::vector&) const override { return Value::MakeInt(ctx.worldY); }; + Value Evaluate(const EvalContext& ctx, const std::vector&) const override { return Value::MakeFloat(ctx.worldY); }; +}; + +// ─────────────────────────────── Abs / Negate ──────────────────────────────── + +/// |a| (Float) +class AbsNode : public Node { +public: + Type GetOutputType() const override { return Type::Float; } + std::vector GetInputTypes() const override { return { Type::Float }; } + std::string GetName() const override { return "Abs"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 1); + return Value::MakeFloat(std::abs(in[0].AsFloat())); + } +}; + +/// −a (Float) +class NegateNode : public Node { +public: + Type GetOutputType() const override { return Type::Float; } + std::vector GetInputTypes() const override { return { Type::Float }; } + std::string GetName() const override { return "Negate"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 1); + return Value::MakeFloat(-in[0].AsFloat()); + } }; // ─────────────────────────────── Min / Max / Clamp ─────────────────────────── @@ -419,6 +445,37 @@ public: } }; +// ─────────────────────────────── Map (range remap) ─────────────────────────── + +/// Remaps a value from [min0, max0] to [min1, max1]. +/// +/// inputs[0] value to remap (Float) +/// +/// The four range endpoints are baked into the node at construction time. +/// When min0 == max0 the output is min1 (avoids divide-by-zero). +class MapNode : public Node { +public: + float min0 { 0.0f }; + float max0 { 1.0f }; + float min1 { 0.0f }; + float max1 { 1.0f }; + + MapNode() = default; + MapNode(float mn0, float mx0, float mn1, float mx1) + : min0(mn0), max0(mx0), min1(mn1), max1(mx1) {} + + Type GetOutputType() const override { return Type::Float; } + std::vector GetInputTypes() const override { return { Type::Float }; } + std::string GetName() const override { return "Map"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 1); + float range0 = max0 - min0; + if (range0 == 0.0f) return Value::MakeFloat(min1); + float t = (in[0].AsFloat() - min0) / range0; + return Value::MakeFloat(min1 + t * (max1 - min1)); + } +}; + // ─────────────────────────────── Int control flow ──────────────────────────── /// Selects between two integer inputs based on a boolean condition. diff --git a/src/WorldGraph/WorldGraphSerializer.cpp b/src/WorldGraph/WorldGraphSerializer.cpp index ad744db..6d940f4 100644 --- a/src/WorldGraph/WorldGraphSerializer.cpp +++ b/src/WorldGraph/WorldGraphSerializer.cpp @@ -38,9 +38,18 @@ std::unique_ptr GraphSerializer::CreateNode(const std::string& type, if (type == "Pow") return std::make_unique(); if (type == "Square") return std::make_unique(); if (type == "OneMinus") return std::make_unique(); + if (type == "Abs") return std::make_unique(); + if (type == "Negate") return std::make_unique(); if (type == "Min") return std::make_unique(); if (type == "Max") return std::make_unique(); if (type == "Clamp") return std::make_unique(); + if (type == "Map") { + return std::make_unique( + j.value("min0", 0.0f), + j.value("max0", 1.0f), + j.value("min1", 0.0f), + j.value("max1", 1.0f)); + } // Noise nodes (frequency baked in) if (type == "PerlinNoise") return std::make_unique(j.value("frequency", 0.01f)); @@ -117,6 +126,11 @@ nlohmann::json GraphSerializer::ToJson(const Graph& g) } else if (const auto* qd = dynamic_cast(node)) { jNode["tileId"] = qd->tileID; jNode["maxDistance"] = qd->maxDistance; + } else if (const auto* mn = dynamic_cast(node)) { + jNode["min0"] = mn->min0; + jNode["max0"] = mn->max0; + jNode["min1"] = mn->min1; + jNode["max1"] = mn->max1; } else if (const auto* pn = dynamic_cast(node)) { jNode["frequency"] = pn->frequency; } else if (const auto* sn = dynamic_cast(node)) { diff --git a/tests/WorldGraph/test_GraphNodes.cpp b/tests/WorldGraph/test_GraphNodes.cpp index 075691a..19e8222 100644 --- a/tests/WorldGraph/test_GraphNodes.cpp +++ b/tests/WorldGraph/test_GraphNodes.cpp @@ -168,6 +168,38 @@ TEST_SUITE("WorldGraph::Nodes") { CHECK(n.Evaluate(c, {}).AsInt() == -3); } + // ── Abs / Negate ────────────────────────────────────────────────────────── + + TEST_CASE("AbsNode: positive input unchanged") { + AbsNode n; + CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f) }).AsFloat() == doctest::Approx(3.0f)); + } + + TEST_CASE("AbsNode: negative input becomes positive") { + AbsNode n; + CHECK(n.Evaluate(ctx, { Value::MakeFloat(-7.5f) }).AsFloat() == doctest::Approx(7.5f)); + } + + TEST_CASE("AbsNode: zero") { + AbsNode n; + CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f) }).AsFloat() == doctest::Approx(0.0f)); + } + + TEST_CASE("NegateNode: positive becomes negative") { + NegateNode n; + CHECK(n.Evaluate(ctx, { Value::MakeFloat(4.0f) }).AsFloat() == doctest::Approx(-4.0f)); + } + + TEST_CASE("NegateNode: negative becomes positive") { + NegateNode n; + CHECK(n.Evaluate(ctx, { Value::MakeFloat(-2.5f) }).AsFloat() == doctest::Approx(2.5f)); + } + + TEST_CASE("NegateNode: zero") { + NegateNode n; + CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f) }).AsFloat() == doctest::Approx(0.0f)); + } + // ── Min / Max / Clamp ───────────────────────────────────────────────────── TEST_CASE("MinNode: returns smaller value") { @@ -216,6 +248,42 @@ TEST_SUITE("WorldGraph::Nodes") { == doctest::Approx(10.0f)); } + // ── Map (range remap) ───────────────────────────────────────────────────── + + TEST_CASE("MapNode: maps midpoint correctly") { + MapNode n(0.0f, 1.0f, 0.0f, 100.0f); + CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.5f) }).AsFloat() == doctest::Approx(50.0f)); + } + + TEST_CASE("MapNode: maps min boundary") { + MapNode n(0.0f, 1.0f, 10.0f, 20.0f); + CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f) }).AsFloat() == doctest::Approx(10.0f)); + } + + TEST_CASE("MapNode: maps max boundary") { + MapNode n(0.0f, 1.0f, 10.0f, 20.0f); + CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f) }).AsFloat() == doctest::Approx(20.0f)); + } + + TEST_CASE("MapNode: maps noise range [-1, 1] to [0, 1]") { + MapNode n(-1.0f, 1.0f, 0.0f, 1.0f); + CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f) }).AsFloat() == doctest::Approx(0.0f)); + CHECK(n.Evaluate(ctx, { Value::MakeFloat( 0.0f) }).AsFloat() == doctest::Approx(0.5f)); + CHECK(n.Evaluate(ctx, { Value::MakeFloat( 1.0f) }).AsFloat() == doctest::Approx(1.0f)); + } + + TEST_CASE("MapNode: inverted output range") { + MapNode n(0.0f, 1.0f, 1.0f, 0.0f); + CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f) }).AsFloat() == doctest::Approx(1.0f)); + CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f) }).AsFloat() == doctest::Approx(0.0f)); + CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.25f) }).AsFloat() == doctest::Approx(0.75f)); + } + + TEST_CASE("MapNode: degenerate input range returns min1") { + MapNode n(5.0f, 5.0f, 99.0f, 100.0f); // min0 == max0 + CHECK(n.Evaluate(ctx, { Value::MakeFloat(5.0f) }).AsFloat() == doctest::Approx(99.0f)); + } + // ── Int control flow ────────────────────────────────────────────────────── TEST_CASE("IntBranchNode: selects true branch") { diff --git a/tools/node-editor/main.cpp b/tools/node-editor/main.cpp index 9feab14..f6802f7 100644 --- a/tools/node-editor/main.cpp +++ b/tools/node-editor/main.cpp @@ -6,6 +6,7 @@ #include #include "imgui.h" +#include "imgui_internal.h" #include "imgui_impl_glfw.h" #include "imgui_impl_opengl3.h" #include "imnodes.h" @@ -27,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -159,6 +161,7 @@ static ImU32 PinColorHovered(Type t) // ───────────────────────────────────────────────────────────────────────────── static constexpr int PREVIEW_SIZE = 64; +static constexpr float PREVIEW_SCALE = 1.5f; // world units per pixel static GLuint MakePreviewTexture() { @@ -197,10 +200,12 @@ struct NodeMenuItem { }; static const std::vector NODE_MENU = { + // ── Source ────────────────────────────────────────────────────────────── { "Constant", "Source", [] { return std::make_unique(0.0f); } }, { "TileID", "Source", [] { return std::make_unique(1); } }, { "PositionX", "Source", [] { return std::make_unique(); } }, { "PositionY", "Source", [] { return std::make_unique(); } }, + // ── Math ──────────────────────────────────────────────────────────────── { "Add", "Math", [] { return std::make_unique(); } }, { "Subtract", "Math", [] { return std::make_unique(); } }, { "Multiply", "Math", [] { return std::make_unique(); } }, @@ -208,18 +213,87 @@ static const std::vector NODE_MENU = { { "Modulo", "Math", [] { return std::make_unique(); } }, { "Sin", "Math", [] { return std::make_unique(); } }, { "Cos", "Math", [] { return std::make_unique(); } }, + { "Sqrt", "Math", [] { return std::make_unique(); } }, + { "Pow", "Math", [] { return std::make_unique(); } }, + { "Square", "Math", [] { return std::make_unique(); } }, + { "Abs", "Math", [] { return std::make_unique(); } }, + { "Negate", "Math", [] { return std::make_unique(); } }, + { "OneMinus", "Math", [] { return std::make_unique(); } }, + { "Min", "Math", [] { return std::make_unique(); } }, + { "Max", "Math", [] { return std::make_unique(); } }, + { "Clamp", "Math", [] { return std::make_unique(); } }, + { "Map", "Math", [] { return std::make_unique(-1.0f, 1.0f, 0.0f, 1.0f); } }, + // ── Compare ───────────────────────────────────────────────────────────── { "Less", "Compare", [] { return std::make_unique(); } }, { "Greater", "Compare", [] { return std::make_unique(); } }, { "LessEqual", "Compare", [] { return std::make_unique(); } }, { "GreaterEqual", "Compare", [] { return std::make_unique(); } }, { "Equal", "Compare", [] { return std::make_unique(); } }, + // ── Logic ─────────────────────────────────────────────────────────────── { "And", "Logic", [] { return std::make_unique(); } }, { "Or", "Logic", [] { return std::make_unique(); } }, { "Not", "Logic", [] { return std::make_unique(); } }, + { "Xor", "Logic", [] { return std::make_unique(); } }, + { "Nand", "Logic", [] { return std::make_unique(); } }, + { "Nor", "Logic", [] { return std::make_unique(); } }, + { "Xnor", "Logic", [] { return std::make_unique(); } }, + // ── Control ───────────────────────────────────────────────────────────── { "Branch", "Control", [] { return std::make_unique(); } }, + { "IntBranch", "Control", [] { return std::make_unique(); } }, + // ── Query ─────────────────────────────────────────────────────────────── { "QueryTile", "Query", [] { return std::make_unique(0, -1, 1); } }, { "QueryRange", "Query", [] { return std::make_unique(-1, -1, 1, 1, 1); } }, { "QueryDistance", "Query", [] { return std::make_unique(1, 4); } }, + // ── Noise ─────────────────────────────────────────────────────────────── + { "PerlinNoise", "Noise", [] { return std::make_unique(0.01f); } }, + { "SimplexNoise", "Noise", [] { return std::make_unique(0.01f); } }, + { "CellularNoise", "Noise", [] { return std::make_unique(0.01f); } }, + { "ValueNoise", "Noise", [] { return std::make_unique(0.01f); } }, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// GraphTab – all state for one open graph (one browser-style tab) +// ───────────────────────────────────────────────────────────────────────────── + +struct GraphTab { + std::string currentFile; + + Graph graph; + std::unordered_map visualNodes; + std::vector tileRegistry; + + std::vector worldOutputPasses; + GLuint worldOutputTex { 0 }; + bool worldOutputDirty { true }; + bool pendingWorldOutputPos { true }; + + int previewOriginX { 0 }; + int previewOriginY { 0 }; + float previewScale { 1.0f }; + uint64_t previewSeed { 0 }; + + ImNodesEditorContext* imnodesCtx { nullptr }; + + void Init() + { + 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(); + } }; // ───────────────────────────────────────────────────────────────────────────── @@ -230,47 +304,66 @@ class NodeEditorApp { public: NodeEditorApp() { - worldOutputTex = MakePreviewTexture(); + NewTab(); } ~NodeEditorApp() { - for (auto& [id, vn] : visualNodes) vn.FreeTex(); - if (worldOutputTex) glDeleteTextures(1, &worldOutputTex); + for (auto& tab : tabs) tab.Free(); } - // ── 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)); + // 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(tabs.size())) + requestedTab = activeTab = pendingActiveTab; + pendingActiveTab = -1; } RenderDirtyPreviews(); DrawMenuBar(); - ImGui::SetNextWindowPos(ImVec2(0, 18), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(200, ImGui::GetIO().DisplaySize.y - 18), ImGuiCond_Always); + 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, 18), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImVec2(200, kContentY), ImGuiCond_Always); ImGui::SetNextWindowSize( - ImVec2(ImGui::GetIO().DisplaySize.x - 200, ImGui::GetIO().DisplaySize.y - 18), + ImVec2(dispW - 200, dispH - kContentY), ImGuiCond_Always); ImGui::Begin("Node Canvas", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | @@ -281,45 +374,88 @@ public: } 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}} }; + std::vector tabs; + int activeTab { 0 }; + int requestedTab { -1 }; // set to trigger programmatic tab switch (one frame) - // ── World Output node state ─────────────────────────────────────────────── + // Pending data from ini settings handler + std::vector pendingLoadFiles; + int pendingActiveTab { -1 }; - // 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 }; + GraphTab& Active() { return tabs[activeTab]; } - // Deferred first-frame positioning of the World Output node. - bool pendingWorldOutputPos { true }; + // ── Tab management ──────────────────────────────────────────────────────── + + void NewTab() + { + tabs.emplace_back(); + tabs.back().Init(); + requestedTab = activeTab = static_cast(tabs.size()) - 1; + } + + void CloseTab(int idx) + { + tabs[idx].Free(); + tabs.erase(tabs.begin() + idx); + if (tabs.empty()) { NewTab(); return; } + if (activeTab >= static_cast(tabs.size())) + activeTab = static_cast(tabs.size()) - 1; + requestedTab = activeTab; + } + + void DrawTabBar() + { + ImGuiTabBarFlags flags = ImGuiTabBarFlags_Reorderable | + ImGuiTabBarFlags_AutoSelectNewTabs; + if (ImGui::BeginTabBar("##maintabs", flags)) { + for (int i = 0; i < static_cast(tabs.size()); ++i) { + bool open = true; + std::string label = tabs[i].Label() + "##tab" + std::to_string(i); + ImGuiTabItemFlags itemFlags = (requestedTab == i) + ? ImGuiTabItemFlags_SetSelected : ImGuiTabItemFlags_None; + if (ImGui::BeginTabItem(label.c_str(), &open, itemFlags)) { + activeTab = i; + ImGui::EndTabItem(); + } + if (!open) { + CloseTab(i); + break; // vector modified — restart next frame + } + } + if (ImGui::TabItemButton("+", + ImGuiTabItemFlags_Trailing | ImGuiTabItemFlags_NoTooltip)) + NewTab(); + ImGui::EndTabBar(); + } + requestedTab = -1; + } // ── Preview rendering ───────────────────────────────────────────────────── void RenderDirtyPreviews() { + auto& tab = Active(); std::array pixels; // Per-node previews (evaluate single node across a grid). - for (auto& [id, vn] : visualNodes) { + for (auto& [id, vn] : tab.visualNodes) { if (!vn.dirty) continue; vn.dirty = false; - Node* node = graph.GetNode(id); + 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 = previewOriginX + static_cast(px * previewScale); - ctx.worldY = previewOriginY + static_cast(py * previewScale); - ctx.seed = previewSeed; + ctx.worldX = tab.previewOriginX + static_cast(px * tab.previewScale); + ctx.worldY = tab.previewOriginY + static_cast(py * tab.previewScale); + ctx.seed = tab.previewSeed; - Value v = graph.Evaluate(id, ctx); + Value v = tab.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]); + ValueToRGBA(v, &tab.tileRegistry, + pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]); } } @@ -329,36 +465,36 @@ private: } // World Output preview — runs GenerateChunk with all connected passes. - if (worldOutputDirty) { - worldOutputDirty = false; + if (tab.worldOutputDirty) { + tab.worldOutputDirty = false; std::vector passes; - for (auto nodeID : worldOutputPasses) { - if (nodeID != Graph::INVALID_ID && graph.GetNode(nodeID)) - passes.push_back({ graph, nodeID }); + 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 { - // Each pixel = 1 world tile starting at previewOriginX/Y. TileGrid chunk = GenerateChunk(passes, - previewOriginX, previewOriginY, + tab.previewOriginX, tab.previewOriginY, PREVIEW_SIZE, PREVIEW_SIZE, - previewSeed); + tab.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); + 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, &tileRegistry, pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]); + ValueToRGBA(v, &tab.tileRegistry, + pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]); } } } - glBindTexture(GL_TEXTURE_2D, worldOutputTex); + 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()); } @@ -366,8 +502,8 @@ private: void MarkAllDirty() { - for (auto& [id, vn] : visualNodes) vn.dirty = true; - worldOutputDirty = true; + for (auto& [id, vn] : Active().visualNodes) vn.dirty = true; + Active().worldOutputDirty = true; } // ── UI drawing ──────────────────────────────────────────────────────────── @@ -376,29 +512,32 @@ private: { 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(); } + 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 (!currentFile.empty()) - ImGui::TextDisabled(" %s", currentFile.c_str()); + 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", &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"); + 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(previewSeed & 0xFFFFFFFFu); + int seed32 = static_cast(tab.previewSeed & 0xFFFFFFFFu); if (ImGui::DragInt("Seed", &seed32)) { - previewSeed = static_cast(static_cast(seed32)); + tab.previewSeed = static_cast(static_cast(seed32)); changed = true; } @@ -408,9 +547,9 @@ private: ImGui::SeparatorText("Tile IDs"); int toRemove = -1; - for (int i = 0; i < static_cast(tileRegistry.size()); ++i) { + for (int i = 0; i < static_cast(tab.tileRegistry.size()); ++i) { ImGui::PushID(i); - TileEntry& entry = tileRegistry[i]; + TileEntry& entry = tab.tileRegistry[i]; // Color button — opens a popup picker. char cpopup[32]; @@ -454,11 +593,11 @@ private: ImGui::PopID(); } if (toRemove >= 0) - tileRegistry.erase(tileRegistry.begin() + toRemove); + tab.tileRegistry.erase(tab.tileRegistry.begin() + toRemove); if (ImGui::SmallButton("+ Add Tile")) { - int32_t nextId = tileRegistry.empty() ? 1 - : tileRegistry.back().id + 1; + int32_t nextId = tab.tileRegistry.empty() ? 1 + : tab.tileRegistry.back().id + 1; TileEntry entry; entry.id = nextId; entry.name = "Tile"; @@ -469,20 +608,20 @@ private: entry.color[0] = hr / 255.0f; entry.color[1] = hg / 255.0f; entry.color[2] = hb / 255.0f; - tileRegistry.push_back(entry); + tab.tileRegistry.push_back(entry); } ImGui::Spacing(); ImGui::SeparatorText("World Output"); int connected = 0; - for (auto id : worldOutputPasses) + for (auto id : tab.worldOutputPasses) if (id != Graph::INVALID_ID) ++connected; - ImGui::Text("%d / %zu pass(es) connected", connected, worldOutputPasses.size()); + ImGui::Text("%d / %zu pass(es) connected", connected, tab.worldOutputPasses.size()); ImGui::Spacing(); ImGui::SeparatorText("Graph"); - ImGui::Text("%zu nodes", graph.NodeCount()); + ImGui::Text("%zu nodes", tab.graph.NodeCount()); ImGui::Spacing(); ImGui::SeparatorText("Help"); @@ -497,6 +636,17 @@ private: 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 @@ -527,6 +677,7 @@ private: if (ImGui::MenuItem(item.label)) { Graph::NodeID newId = AddNode(item.create()); ImNodes::SetNodeScreenSpacePos(static_cast(newId), spawnPos); + ImGui::CloseCurrentPopup(); } }; @@ -571,26 +722,26 @@ private: DrawWorldOutputNode(); // Draw all regular nodes. - for (auto& [id, vn] : visualNodes) + for (auto& [id, vn] : tab.visualNodes) DrawNode(vn); // Draw regular graph connections. int linkId = 0; - for (auto& [id, vn] : visualNodes) { - Node* node = graph.GetNode(id); + for (auto& [id, vn] : tab.visualNodes) { + Node* node = tab.graph.GetNode(id); if (!node) continue; for (int slot = 0; slot < static_cast(node->GetInputCount()); ++slot) { - auto src = graph.GetInput(id, 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(worldOutputPasses.size()); ++i) { - if (worldOutputPasses[i] != Graph::INVALID_ID) + for (int i = 0; i < static_cast(tab.worldOutputPasses.size()); ++i) { + if (tab.worldOutputPasses[i] != Graph::INVALID_ID) ImNodes::Link(WORLD_OUTPUT_LINK_BASE + i, - OutputPin(worldOutputPasses[i]), + OutputPin(tab.worldOutputPasses[i]), WORLD_OUTPUT_PIN_BASE + i); } @@ -610,12 +761,12 @@ private: if (PinToSlot(regularPin) == 9) { // must be an output pin int passIdx = woPin - WORLD_OUTPUT_PIN_BASE; - if (passIdx >= 0 && passIdx < static_cast(worldOutputPasses.size())) { + if (passIdx >= 0 && passIdx < static_cast(tab.worldOutputPasses.size())) { // World Output only accepts Int (tile ID) outputs. - Node* srcNode = graph.GetNode(PinToNode(regularPin)); + Node* srcNode = tab.graph.GetNode(PinToNode(regularPin)); if (srcNode && srcNode->GetOutputType() == Type::Int) { - worldOutputPasses[passIdx] = PinToNode(regularPin); - worldOutputDirty = true; + tab.worldOutputPasses[passIdx] = PinToNode(regularPin); + tab.worldOutputDirty = true; } } } @@ -635,14 +786,14 @@ private: goto done_link; } // Only connect when output type matches the destination input type. - Node* srcNode = graph.GetNode(fromNode); - Node* dstNode = graph.GetNode(toNode); + Node* srcNode = tab.graph.GetNode(fromNode); + Node* dstNode = tab.graph.GetNode(toNode); if (srcNode && dstNode) { auto dstInputTypes = dstNode->GetInputTypes(); if (toSlot < static_cast(dstInputTypes.size()) && srcNode->GetOutputType() == dstInputTypes[toSlot]) { - if (graph.Connect(fromNode, toNode, toSlot)) + if (tab.graph.Connect(fromNode, toNode, toSlot)) MarkAllDirty(); } } @@ -657,22 +808,22 @@ private: 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; + if (passIdx >= 0 && passIdx < static_cast(tab.worldOutputPasses.size())) { + tab.worldOutputPasses[passIdx] = Graph::INVALID_ID; + tab.worldOutputDirty = true; } } else { // Regular link — re-enumerate to find which one. int idx = 0; bool found = false; - for (auto& [id, vn] : visualNodes) { + for (auto& [id, vn] : tab.visualNodes) { if (found) break; - Node* node = graph.GetNode(id); + Node* node = tab.graph.GetNode(id); if (!node) continue; for (int slot = 0; slot < static_cast(node->GetInputCount()); ++slot) { - if (graph.GetInput(id, slot).has_value()) { + if (tab.graph.GetInput(id, slot).has_value()) { if (idx == destroyedLink) { - graph.Disconnect(id, slot); + tab.graph.Disconnect(id, slot); MarkAllDirty(); found = true; break; @@ -697,14 +848,14 @@ private: 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; } + for (auto& pass : tab.worldOutputPasses) + if (pass == gid) { pass = Graph::INVALID_ID; tab.worldOutputDirty = true; } - graph.RemoveNode(gid); - auto it = visualNodes.find(gid); - if (it != visualNodes.end()) { + tab.graph.RemoveNode(gid); + auto it = tab.visualNodes.find(gid); + if (it != tab.visualNodes.end()) { it->second.FreeTex(); - visualNodes.erase(it); + tab.visualNodes.erase(it); } } MarkAllDirty(); @@ -716,6 +867,8 @@ private: 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)); @@ -727,13 +880,13 @@ private: ImNodes::EndNodeTitleBar(); // One input pin per generation pass — Int only. - for (int i = 0; i < static_cast(worldOutputPasses.size()); ++i) { + for (int i = 0; i < static_cast(tab.worldOutputPasses.size()); ++i) { ImNodes::PushColorStyle(ImNodesCol_Pin, PinColor(Type::Int)); ImNodes::PushColorStyle(ImNodesCol_PinHovered, PinColorHovered(Type::Int)); ImNodes::BeginInputAttribute(WORLD_OUTPUT_PIN_BASE + i); - Graph::NodeID connected = worldOutputPasses[i]; + Graph::NodeID connected = tab.worldOutputPasses[i]; if (connected != Graph::INVALID_ID) { - Node* n = graph.GetNode(connected); + Node* n = tab.graph.GetNode(connected); ImGui::Text("Pass %d (%s)", i, n ? n->GetName().c_str() : "?"); } else { ImGui::TextDisabled("Pass %d (empty)", i); @@ -746,21 +899,21 @@ private: // Add / remove pass buttons. ImNodes::BeginStaticAttribute(WORLD_OUTPUT_PIN_BASE + 500); if (ImGui::SmallButton("+ Pass")) { - worldOutputPasses.push_back(Graph::INVALID_ID); + tab.worldOutputPasses.push_back(Graph::INVALID_ID); } - if (worldOutputPasses.size() > 1) { + if (tab.worldOutputPasses.size() > 1) { ImGui::SameLine(); if (ImGui::SmallButton("- Pass")) { - worldOutputPasses.pop_back(); - worldOutputDirty = true; + tab.worldOutputPasses.pop_back(); + tab.worldOutputDirty = true; } } ImNodes::EndStaticAttribute(); // Generated chunk preview — always 1 tile per pixel. ImGui::Image( - static_cast(static_cast(worldOutputTex)), - ImVec2(PREVIEW_SIZE, PREVIEW_SIZE)); + static_cast(static_cast(tab.worldOutputTex)), + ImVec2(PREVIEW_SIZE * PREVIEW_SCALE, PREVIEW_SIZE * PREVIEW_SCALE)); ImNodes::EndNode(); @@ -773,7 +926,8 @@ private: void DrawNode(VisualNode& vn) { - Node* node = graph.GetNode(vn.id); + auto& tab = Active(); + Node* node = tab.graph.GetNode(vn.id); if (!node) return; ImNodes::BeginNode(static_cast(vn.id)); @@ -782,6 +936,17 @@ private: ImGui::Text("%s", node->GetName().c_str()); ImNodes::EndNodeTitleBar(); + // Output pin + ImNodes::PushColorStyle(ImNodesCol_Pin, PinColor(node->GetOutputType())); + ImNodes::PushColorStyle(ImNodesCol_PinHovered, PinColorHovered(node->GetOutputType())); + ImNodes::BeginOutputAttribute(OutputPin(vn.id)); + const char* outType = node->GetOutputType() == Type::Float ? "Float" + : node->GetOutputType() == Type::Int ? "Int" : "Bool"; + ImGui::Text("out (%s)", outType); + ImNodes::EndOutputAttribute(); + ImNodes::PopColorStyle(); + ImNodes::PopColorStyle(); + // Input pins. auto inputTypes = node->GetInputTypes(); for (int slot = 0; slot < static_cast(inputTypes.size()); ++slot) { @@ -800,17 +965,7 @@ private: 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(); + ImVec2(PREVIEW_SIZE * PREVIEW_SCALE, PREVIEW_SIZE * PREVIEW_SCALE)); ImNodes::EndNode(); } @@ -827,29 +982,31 @@ private: static bool DrawConstantParams(NodeEditorApp& /*app*/, Node* node) { auto* n = static_cast(node); + ImGui::SetNextItemWidth(80); return ImGui::DragFloat("##val", &n->value, 0.01f); } static bool DrawIDParams(NodeEditorApp& app, Node* node) { auto* n = static_cast(node); + auto& reg = app.Active().tileRegistry; bool changed = false; - if (app.tileRegistry.empty()) { + if (reg.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); + for (int i = 0; i < static_cast(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(app.tileRegistry.size()); ++i) { + for (int i = 0; i < static_cast(reg.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); + reg[i].name.c_str(), reg[i].id); if (ImGui::Selectable(label, sel)) { - n->tileID = app.tileRegistry[i].id; + n->tileID = reg[i].id; changed = true; } if (sel) ImGui::SetItemDefaultFocus(); @@ -864,10 +1021,15 @@ private: { auto* n = static_cast(node); bool changed = false; + ImGui::Text("relative offset"); + ImGui::PushItemWidth(70); changed |= ImGui::DragInt("##ox", &n->offsetX, 1); ImGui::SameLine(); changed |= ImGui::DragInt("##oy", &n->offsetY, 1); + // TODO: it would be best if this is a dropdown of tile ids that the user can choose from + ImGui::Text("Tile ID"); changed |= ImGui::DragInt("##eid", &n->expectedID, 1, 0, 1024); + ImGui::PopItemWidth(); return changed; } @@ -875,6 +1037,7 @@ private: { auto* n = static_cast(node); bool changed = false; + ImGui::PushItemWidth(70); changed |= ImGui::DragInt("##mnx", &n->minX, 1); ImGui::SameLine(); changed |= ImGui::DragInt("##mxx", &n->maxX, 1); @@ -882,6 +1045,7 @@ private: ImGui::SameLine(); changed |= ImGui::DragInt("##mxy", &n->maxY, 1); changed |= ImGui::DragInt("##rtid", &n->tileID, 1, 0, 1024); + ImGui::PopItemWidth(); return changed; } @@ -889,22 +1053,56 @@ private: { auto* n = static_cast(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(node); + bool changed = false; + ImGui::TextDisabled("in"); + ImGui::SetNextItemWidth(70); changed |= ImGui::DragFloat("##mn0", &n->min0, 0.01f, 0.0f, 0.0f, "%.2f"); + ImGui::SameLine(0, 3); + ImGui::SetNextItemWidth(70); changed |= ImGui::DragFloat("##mx0", &n->max0, 0.01f, 0.0f, 0.0f, "%.2f"); + ImGui::TextDisabled("out"); + ImGui::SetNextItemWidth(70); changed |= ImGui::DragFloat("##mn1", &n->min1, 0.01f, 0.0f, 0.0f, "%.2f"); + ImGui::SameLine(0, 3); + ImGui::SetNextItemWidth(70); changed |= ImGui::DragFloat("##mx1", &n->max1, 0.01f, 0.0f, 0.0f, "%.2f"); + return changed; + } + + // All four noise nodes share the same single-field UI; a common helper + // avoids repeating the widget call four times. + static bool DrawFrequencyParam(float& freq) + { + ImGui::SetNextItemWidth(80); + return ImGui::DragFloat("##freq", &freq, 0.0001f, 0.0001f, 10.0f, "%.4f"); + } + static bool DrawPerlinNoiseParams (NodeEditorApp&, Node* n) { return DrawFrequencyParam(static_cast (n)->frequency); } + static bool DrawSimplexNoiseParams (NodeEditorApp&, Node* n) { return DrawFrequencyParam(static_cast (n)->frequency); } + static bool DrawCellularNoiseParams(NodeEditorApp&, Node* n) { return DrawFrequencyParam(static_cast(n)->frequency); } + static bool DrawValueNoiseParams (NodeEditorApp&, Node* n) { return DrawFrequencyParam(static_cast (n)->frequency); } + // ── Param dispatcher ────────────────────────────────────────────────────── void DrawNodeParams(Graph::NodeID /*id*/, Node* node) { using DrawFn = bool(*)(NodeEditorApp&, Node*); static const std::unordered_map kDrawers = { - { typeid(ConstantNode), DrawConstantParams }, - { typeid(IDNode), DrawIDParams }, - { typeid(QueryTileNode), DrawQueryTileParams }, - { typeid(QueryRangeNode), DrawQueryRangeParams }, - { typeid(QueryDistanceNode), DrawQueryDistanceParams }, + { typeid(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)); @@ -916,36 +1114,29 @@ private: Graph::NodeID AddNode(std::unique_ptr node) { - Graph::NodeID id = graph.AddNode(std::move(node)); + auto& tab = Active(); + Graph::NodeID id = tab.graph.AddNode(std::move(node)); VisualNode vn; vn.id = id; vn.dirty = true; vn.InitTex(); - visualNodes.emplace(id, std::move(vn)); + tab.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) { + auto& tab = Active(); + ImNodes::EditorContextSet(tab.imnodesCtx); + nlohmann::json root; - root["graph"] = GraphSerializer::ToJson(graph); + root["graph"] = GraphSerializer::ToJson(tab.graph); // Node positions (including World Output). nlohmann::json positions = nlohmann::json::object(); - for (auto& [id, vn] : visualNodes) { + for (auto& [id, vn] : tab.visualNodes) { ImVec2 pos = ImNodes::GetNodeGridSpacePos(static_cast(id)); positions[std::to_string(id)] = { pos.x, pos.y }; } @@ -955,24 +1146,24 @@ private: } root["editor"]["nodePositions"] = positions; - root["editor"]["seed"] = previewSeed; - root["editor"]["previewOriginX"] = previewOriginX; - root["editor"]["previewOriginY"] = previewOriginY; - root["editor"]["previewScale"] = previewScale; + 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 : worldOutputPasses) + for (auto id : tab.worldOutputPasses) passes.push_back(id); root["editor"]["worldOutputPasses"] = passes; nlohmann::json tiles = nlohmann::json::array(); - for (auto& t : tileRegistry) + 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); currentFile = path; } + if (f) { f << root.dump(2); tab.currentFile = path; } else fprintf(stderr, "Failed to write %s\n", path.c_str()); } @@ -988,29 +1179,39 @@ private: 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); + // 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 (graph.GetNode(id)) { + if (tab.graph.GetNode(id)) { VisualNode vn; vn.id = id; vn.dirty = true; vn.InitTex(); - visualNodes.emplace(id, std::move(vn)); + tab.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); + 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")) { - tileRegistry.clear(); + tab.tileRegistry.clear(); for (auto& t : ed["tileRegistry"]) { TileEntry entry; entry.id = t.value("id", 0); @@ -1020,31 +1221,32 @@ private: entry.color[1] = t["color"][1].get(); entry.color[2] = t["color"][2].get(); } - tileRegistry.push_back(entry); + tab.tileRegistry.push_back(entry); } // Always guarantee the Empty (ID 0) sentinel is present. - bool hasEmpty = std::any_of(tileRegistry.begin(), tileRegistry.end(), + bool hasEmpty = std::any_of(tab.tileRegistry.begin(), tab.tileRegistry.end(), [](const TileEntry& e) { return e.id == 0; }); if (!hasEmpty) - tileRegistry.insert(tileRegistry.begin(), + 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")) { - worldOutputPasses.clear(); + tab.worldOutputPasses.clear(); for (auto& v : ed["worldOutputPasses"]) - worldOutputPasses.push_back(v.get()); - if (worldOutputPasses.empty()) - worldOutputPasses.push_back(Graph::INVALID_ID); + tab.worldOutputPasses.push_back(v.get()); + if (tab.worldOutputPasses.empty()) + tab.worldOutputPasses.push_back(Graph::INVALID_ID); } + ImNodes::EditorContextSet(tab.imnodesCtx); if (ed.contains("nodePositions")) { for (auto& [key, val] : ed["nodePositions"].items()) { float x = val[0].get(); float y = val[1].get(); if (key == "__worldOutput") { ImNodes::SetNodeGridSpacePos(WORLD_OUTPUT_IMNODES_ID, ImVec2(x, y)); - pendingWorldOutputPos = false; + tab.pendingWorldOutputPos = false; } else { ImNodes::SetNodeGridSpacePos(std::stoi(key), ImVec2(x, y)); } @@ -1052,7 +1254,7 @@ private: } } - currentFile = path; + tab.currentFile = path; } // ── File dialogs ────────────────────────────────────────────────────────── @@ -1060,16 +1262,18 @@ private: void OpenFileDialog() { IGFD::FileDialogConfig cfg; - cfg.path = currentFile.empty() ? "." - : std::filesystem::path(currentFile).parent_path().string(); + 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; - if (!currentFile.empty()) { - std::filesystem::path p(currentFile); + 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 { @@ -1079,7 +1283,7 @@ private: ImGuiFileDialog::Instance()->OpenDialog("SaveFileDlg", "Save Graph", ".json", cfg); } - void SaveCurrent() { if (currentFile.empty()) SaveAsDialog(); else Save(currentFile); } + void SaveCurrent() { if (Active().currentFile.empty()) SaveAsDialog(); else Save(Active().currentFile); } public: void RenderFileDialogs() @@ -1088,8 +1292,25 @@ public: if (ImGuiFileDialog::Instance()->Display("OpenFileDlg", ImGuiWindowFlags_NoCollapse, dlgSize)) { - if (ImGuiFileDialog::Instance()->IsOk()) - Load(ImGuiFileDialog::Instance()->GetFilePathName()); + if (ImGuiFileDialog::Instance()->IsOk()) { + std::string path = ImGuiFileDialog::Instance()->GetFilePathName(); + // If file already open, switch to that tab. + bool found = false; + for (int i = 0; i < static_cast(tabs.size()); ++i) { + if (tabs[i].currentFile == path) { + requestedTab = activeTab = i; + found = true; + break; + } + } + if (!found) { + // Reuse current tab if it's blank, otherwise open a new tab. + auto& cur = Active(); + if (cur.graph.NodeCount() > 0 || !cur.currentFile.empty()) + NewTab(); + Load(path); + } + } ImGuiFileDialog::Instance()->Close(); } @@ -1105,7 +1326,59 @@ public: { ImGuiIO& io = ImGui::GetIO(); if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_S)) SaveCurrent(); - if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_O)) ImGui::OpenPopup("##open_path"); + if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_O)) OpenFileDialog(); + if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_N)) NewTab(); + if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_W)) CloseTab(activeTab); + } + + // Registers a custom [NodeEditor][Session] block in imgui.ini that persists + // all open file paths and the active tab index across sessions. + // Must be called after ImGui::CreateContext() and before the first ImGui::NewFrame(). + void RegisterSettingsHandler() + { + ImGuiSettingsHandler h; + h.TypeName = "NodeEditor"; + h.TypeHash = ImHashStr("NodeEditor"); + h.UserData = this; + + // Called when the [NodeEditor][Session] header is found — return non-null + // to accept the entry and route its lines to ReadLineFn. + h.ReadOpenFn = [](ImGuiContext*, ImGuiSettingsHandler* handler, const char*) -> void* { + return handler->UserData; + }; + + // Called for each key=value line inside the entry. + h.ReadLineFn = [](ImGuiContext*, ImGuiSettingsHandler*, void* entry, const char* line) { + auto* app = static_cast(entry); + if (strncmp(line, "LastFiles=", 10) == 0) { + std::istringstream ss(line + 10); + std::string file; + while (std::getline(ss, file, ';')) + if (!file.empty()) app->pendingLoadFiles.push_back(file); + } + if (strncmp(line, "ActiveTab=", 10) == 0) + app->pendingActiveTab = std::atoi(line + 10); + }; + + // Called when ImGui writes imgui.ini (periodically and on shutdown). + h.WriteAllFn = [](ImGuiContext*, ImGuiSettingsHandler* handler, ImGuiTextBuffer* out_buf) { + auto* app = static_cast(handler->UserData); + std::string files; + for (auto& tab : app->tabs) { + if (!tab.currentFile.empty()) { + if (!files.empty()) files += ";"; + files += tab.currentFile; + } + } + 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); } }; @@ -1151,6 +1424,7 @@ int main() ImGui_ImplOpenGL3_Init("#version 330"); NodeEditorApp app; + app.RegisterSettingsHandler(); while (!glfwWindowShouldClose(window)) { glfwPollEvents();