From 1b7fd1c7f8adb32119197e070a997680c21750ab Mon Sep 17 00:00:00 2001 From: Connor Date: Mon, 23 Feb 2026 23:07:10 +0900 Subject: [PATCH] layer strength --- .gitignore | 2 - Makefile | 26 ++ data/WorldGraphs/dirt.json | 504 ++++++++++++++++++------ data/WorldGraphs/stone.json | 44 +-- data/tiles.json | 40 ++ imgui.ini | 13 +- include/WorldGraph/WorldGraphNode.h | 60 ++- include/WorldGraph/WorldGraphTypes.h | 5 + src/WorldGraph/WorldGraphSerializer.cpp | 10 +- tools/node-editor/main.cpp | 251 ++++++++---- 10 files changed, 725 insertions(+), 230 deletions(-) create mode 100644 Makefile create mode 100644 data/tiles.json diff --git a/.gitignore b/.gitignore index ed4a29f..c12b8b9 100644 --- a/.gitignore +++ b/.gitignore @@ -531,7 +531,6 @@ qrc_*.cpp ui_*.h *.qmlc *.jsc -Makefile* *build-* *.qm *.prl @@ -941,7 +940,6 @@ CMakeCache.txt CMakeFiles CMakeScripts Testing -Makefile cmake_install.cmake install_manifest.txt compile_commands.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d435f81 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +BUILD_DIR := build +BUILD_TYPE ?= Debug + +.PHONY: all configure build run test clean world-editor + +all: build + +configure: + @cmake -S . -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=$(BUILD_TYPE) + +build: configure + @cmake --build $(BUILD_DIR) --target factory-hole-app -j$$(nproc) + +run: build + @$(BUILD_DIR)/factory-hole-app + +test: configure + @cmake --build $(BUILD_DIR) --target factory-hole-tests -j$$(nproc) + @cd $(BUILD_DIR) && ctest --output-on-failure + +world-editor: configure + @cmake --build $(BUILD_DIR) --target node-editor -j$$(nproc) + @$(BUILD_DIR)/tools/node-editor/node-editor + +clean: + @rm -rf $(BUILD_DIR) diff --git a/data/WorldGraphs/dirt.json b/data/WorldGraphs/dirt.json index f0f7036..2950115 100644 --- a/data/WorldGraphs/dirt.json +++ b/data/WorldGraphs/dirt.json @@ -1,49 +1,141 @@ { "editor": { "nodePositions": { - "1": [ - -1011.0, - -214.0 - ], - "10": [ - -601.0, - -250.0 - ], - "11": [ - -337.0, - -265.0 - ], "12": [ - -351.2725830078125, - 240.48638916015625 + -504.2725830078125, + -131.51361083984375 ], - "2": [ - -740.0, - -231.0 + "13": [ + -195.0, + -26.0 ], - "3": [ - -885.0, - -153.0 + "15": [ + -184.0, + -228.0 + ], + "16": [ + -329.0, + -47.0 + ], + "17": [ + -20.0, + -149.0 + ], + "18": [ + 131.0, + -75.0 + ], + "19": [ + -9.0, + 76.0 + ], + "21": [ + -380.255615234375, + 459.421142578125 + ], + "23": [ + -154.0, + 248.0 + ], + "24": [ + 175.0, + 331.0 + ], + "25": [ + -523.0, + 220.0 + ], + "26": [ + -655.7763671875, + 236.2835693359375 + ], + "27": [ + -817.7763671875, + 418.2835693359375 + ], + "28": [ + -816.0, + 249.0 + ], + "29": [ + -357.0, + 248.0 + ], + "30": [ + -516.0, + 408.0 + ], + "31": [ + -7.0, + 389.0 + ], + "32": [ + -588.0, + 597.0 + ], + "33": [ + -151.0, + 497.0 + ], + "34": [ + -1580.6732177734375, + -252.9154052734375 + ], + "37": [ + -938.6732788085938, + -13.9154052734375 + ], + "39": [ + -1082.0, + 58.0 ], "4": [ - -472.0, - -118.0 + -261.0, + -633.0 + ], + "40": [ + -1297.0, + 179.0 + ], + "41": [ + -858.0, + -289.0 + ], + "42": [ + -1519.0, + -63.0 + ], + "43": [ + -1431.0, + -267.0 + ], + "44": [ + -1261.0, + -226.0 + ], + "45": [ + -666.0, + -158.0 + ], + "46": [ + -799.0, + 33.0 ], "5": [ - -144.0, - -283.0 + -122.0, + -513.0 ], "6": [ - -341.0, - -61.0 + -275.0, + -428.0 ], "7": [ - -23.0, - -218.0 + -1.0, + -448.0 ], "9": [ - 136.0, - -300.0 + 158.0, + -530.0 ], "__worldOutput": [ 400.0, @@ -52,65 +144,18 @@ }, "previewOriginX": 0, "previewOriginY": 0, - "previewScale": 0.4000000059604645, + "previewScale": 0.5, "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 + 18, + 24 ] }, "graph": { "connections": [ { - "from": 1, - "slot": 0, - "to": 2 - }, - { - "from": 3, - "slot": 1, - "to": 2 - }, - { - "from": 11, + "from": 4, "slot": 0, "to": 5 }, @@ -130,36 +175,143 @@ "to": 9 }, { - "from": 2, + "from": 12, "slot": 0, - "to": 10 + "to": 15 }, { - "from": 10, - "slot": 0, - "to": 11 - }, - { - "from": 4, + "from": 16, "slot": 1, - "to": 11 + "to": 15 + }, + { + "from": 15, + "slot": 0, + "to": 17 + }, + { + "from": 13, + "slot": 1, + "to": 17 + }, + { + "from": 17, + "slot": 0, + "to": 18 + }, + { + "from": 19, + "slot": 1, + "to": 18 + }, + { + "from": 31, + "slot": 0, + "to": 24 + }, + { + "from": 23, + "slot": 1, + "to": 24 + }, + { + "from": 26, + "slot": 0, + "to": 25 + }, + { + "from": 28, + "slot": 0, + "to": 26 + }, + { + "from": 27, + "slot": 1, + "to": 26 + }, + { + "from": 25, + "slot": 0, + "to": 29 + }, + { + "from": 30, + "slot": 1, + "to": 29 + }, + { + "from": 29, + "slot": 0, + "to": 31 + }, + { + "from": 33, + "slot": 1, + "to": 31 + }, + { + "from": 21, + "slot": 0, + "to": 33 + }, + { + "from": 32, + "slot": 1, + "to": 33 + }, + { + "from": 39, + "slot": 0, + "to": 37 + }, + { + "from": 44, + "slot": 0, + "to": 39 + }, + { + "from": 40, + "slot": 1, + "to": 39 + }, + { + "from": 43, + "slot": 0, + "to": 41 + }, + { + "from": 42, + "slot": 1, + "to": 41 + }, + { + "from": 34, + "slot": 0, + "to": 43 + }, + { + "from": 43, + "slot": 0, + "to": 44 + }, + { + "from": 42, + "slot": 1, + "to": 44 + }, + { + "from": 37, + "slot": 0, + "to": 45 + }, + { + "from": 46, + "slot": 1, + "to": 45 } ], - "nextId": 13, + "nextId": 47, "nodes": [ - { - "id": 1, - "type": "PositionY" - }, - { - "id": 2, - "type": "Multiply" - }, - { - "id": 3, - "type": "Constant", - "value": 0.019999999552965164 - }, { "frequency": 0.07880000025033951, "id": 4, @@ -172,7 +324,7 @@ { "id": 6, "type": "Constant", - "value": 0.1599999964237213 + "value": 0.17000000178813934 }, { "id": 7, @@ -184,21 +336,143 @@ "type": "IntBranch" }, { - "id": 10, - "type": "Max" + "id": 12, + "maxX": 0, + "maxY": 2, + "minX": 0, + "minY": 0, + "tileId": 0, + "type": "QueryRange" }, { - "id": 11, + "expectedID": 1, + "id": 13, + "offsetX": 0, + "offsetY": 0, + "type": "QueryTile" + }, + { + "id": 15, + "type": "GreaterEqual" + }, + { + "id": 16, + "type": "Constant", + "value": 1.0 + }, + { + "id": 17, + "type": "And" + }, + { + "id": 18, + "type": "IntBranch" + }, + { + "id": 19, + "tileId": 2, + "type": "TileID" + }, + { + "expectedID": 2, + "id": 21, + "offsetX": 0, + "offsetY": -1, + "type": "QueryTile" + }, + { + "id": 23, + "tileId": 3, + "type": "TileID" + }, + { + "id": 24, + "type": "IntBranch" + }, + { + "id": 25, + "type": "Floor" + }, + { + "id": 26, + "type": "Add" + }, + { + "id": 27, + "type": "Constant", + "value": 0.05999999865889549 + }, + { + "id": 28, + "type": "Random" + }, + { + "id": 29, + "type": "GreaterEqual" + }, + { + "id": 30, + "type": "Constant", + "value": 1.0 + }, + { + "id": 31, + "type": "And" + }, + { + "expectedID": 0, + "id": 32, + "offsetX": 0, + "offsetY": 0, + "type": "QueryTile" + }, + { + "id": 33, + "type": "And" + }, + { + "frequency": 0.09839999675750732, + "id": 34, + "type": "CellularNoise" + }, + { + "id": 37, + "type": "Ceil" + }, + { + "id": 39, + "type": "Add" + }, + { + "id": 40, + "type": "Constant", + "value": -0.5 + }, + { + "id": 41, "type": "Multiply" }, { - "id": 12, - "maxX": 1, - "maxY": 1, - "minX": -1, - "minY": -1, - "tileId": 1, - "type": "QueryRange" + "id": 42, + "type": "Constant", + "value": 500.0 + }, + { + "id": 43, + "type": "Negate" + }, + { + "id": 44, + "type": "Pow" + }, + { + "id": 45, + "type": "Greater" + }, + { + "id": 46, + "type": "Constant", + "value": 0.0 } ] } diff --git a/data/WorldGraphs/stone.json b/data/WorldGraphs/stone.json index 842161a..2d03f13 100644 --- a/data/WorldGraphs/stone.json +++ b/data/WorldGraphs/stone.json @@ -1,10 +1,6 @@ { "editor": { "nodePositions": { - "1": [ - -70.0, - -259.0 - ], "13": [ -220.0, 24.0 @@ -17,6 +13,10 @@ -223.0, 206.0 ], + "17": [ + -190.0, + -250.0 + ], "2": [ 112.0, -289.0 @@ -34,26 +34,6 @@ "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 ] @@ -66,8 +46,8 @@ "to": 2 }, { - "from": 1, - "slot": 1, + "from": 17, + "slot": 2, "to": 2 }, { @@ -86,13 +66,8 @@ "to": 15 } ], - "nextId": 17, + "nextId": 18, "nodes": [ - { - "id": 1, - "tileId": 1, - "type": "TileID" - }, { "id": 2, "type": "IntBranch" @@ -114,6 +89,11 @@ "id": 16, "type": "Constant", "value": 0.3199999928474426 + }, + { + "id": 17, + "tileId": 1, + "type": "TileID" } ] } diff --git a/data/tiles.json b/data/tiles.json new file mode 100644 index 0000000..6b373e0 --- /dev/null +++ b/data/tiles.json @@ -0,0 +1,40 @@ +{ + "tiles": [ + { + "color": [ + 1.0, + 1.0, + 1.0 + ], + "id": 0, + "name": "Empty" + }, + { + "color": [ + 0.532608687877655, + 0.532608687877655, + 0.532608687877655 + ], + "id": 1, + "name": "Stone" + }, + { + "color": [ + 0.2549019753932953, + 0.8470588326454163, + 0.43529412150382996 + ], + "id": 2, + "name": "Grass" + }, + { + "color": [ + 0.0, + 0.32065218687057495, + 0.01742672547698021 + ], + "id": 3, + "name": "Tree" + } + ] +} \ No newline at end of file diff --git a/imgui.ini b/imgui.ini index a23bce8..63317b8 100644 --- a/imgui.ini +++ b/imgui.ini @@ -1,10 +1,10 @@ [Window][Node Canvas] Pos=200,42 -Size=1720,1039 +Size=1080,678 [Window][Settings] Pos=0,42 -Size=200,1039 +Size=200,678 [Window][Debug##Default] Pos=60,60 @@ -18,6 +18,10 @@ Size=600,400 Pos=60,60 Size=600,400 +[Window][Save Tile Registry##SaveRegistryDlg] +Pos=60,60 +Size=600,400 + [Table][0x4ED31530,4] RefScale=13 Column 0 Sort=0v @@ -86,7 +90,12 @@ Column 0 Sort=0v RefScale=13 Column 0 Sort=0v +[Table][0x493FDC9E,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 +SharedRegistry=/home/connor/repos/factory-hole-core/data/tiles.json diff --git a/include/WorldGraph/WorldGraphNode.h b/include/WorldGraph/WorldGraphNode.h index 766f41e..63373e6 100644 --- a/include/WorldGraph/WorldGraphNode.h +++ b/include/WorldGraph/WorldGraphNode.h @@ -295,7 +295,7 @@ public: QueryRangeNode(int32_t mnX, int32_t mnY, int32_t mxX, int32_t mxY, int32_t id) : minX(mnX), minY(mnY), maxX(mxX), maxY(mxY), tileID(id) {} - 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 "QueryRange"; } Value Evaluate(const EvalContext& ctx, const std::vector&) const override { @@ -304,7 +304,7 @@ public: for (int32_t dx = minX; dx <= maxX; ++dx) if (ctx.GetPrevTile(ctx.worldX + dx, ctx.worldY + dy) == tileID) ++count; - return Value::MakeInt(count); + return Value::MakeFloat(static_cast(count)); } }; @@ -381,6 +381,16 @@ public: Value Evaluate(const EvalContext& ctx, const std::vector&) const override { return Value::MakeFloat(ctx.worldY); }; }; +/// Outputs the current layer's blending strength, set by the world compositor. +/// Returns 1.0 when running outside a layered world (normal graph evaluation). +class LayerStrengthNode : public Node { +public: + Type GetOutputType() const override { return Type::Float; } + std::vector GetInputTypes() const override { return {}; } + std::string GetName() const override { return "LayerStrength"; } + Value Evaluate(const EvalContext& ctx, const std::vector&) const override { return Value::MakeFloat(ctx.layerStrength); } +}; + // ─────────────────────────────── Abs / Negate ──────────────────────────────── /// |a| (Float) @@ -501,6 +511,30 @@ public: // ─────────────────────────────── Extended math nodes ───────────────────────── +/// floor(a) (Float) +class FloorNode : public Node { +public: + Type GetOutputType() const override { return Type::Float; } + std::vector GetInputTypes() const override { return { Type::Float }; } + std::string GetName() const override { return "Floor"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 1); + return Value::MakeFloat(std::floor(in[0].AsFloat())); + } +}; + +/// ceil(a) (Float) +class CeilNode : public Node { +public: + Type GetOutputType() const override { return Type::Float; } + std::vector GetInputTypes() const override { return { Type::Float }; } + std::string GetName() const override { return "Ceil"; } + Value Evaluate(const EvalContext&, const std::vector& in) const override { + DEV_ASSERT(in.size() == 1); + return Value::MakeFloat(std::ceil(in[0].AsFloat())); + } +}; + /// sqrt(a), clamped to 0 for negative inputs (Float) class SqrtNode : public Node { public: @@ -605,6 +639,28 @@ public: // Each noise node reads worldX/Y and seed from EvalContext; no graph inputs. // Output is a Float in [-1, 1]. // +// RandomNode is the exception: it produces spatially incoherent white noise +// via a hash of (seed, worldX, worldY), output in [0, 1]. + +/// Spatially incoherent hash-based random value in [0, 1]. +/// Each (seed, worldX, worldY) triple produces an independent value — no +/// smoothing, no spatial correlation. (Float) +class RandomNode : public Node { +public: + Type GetOutputType() const override { return Type::Float; } + std::vector GetInputTypes() const override { return {}; } + std::string GetName() const override { return "Random"; } + Value Evaluate(const EvalContext& ctx, const std::vector&) const override { + // Mix seed, x, y with Knuth multiplicative hashes then finalise. + uint32_t h = static_cast(ctx.seed) + ^ (static_cast(ctx.worldX) * 2654435761u) + ^ (static_cast(ctx.worldY) * 2246822519u); + h ^= h >> 16; + h *= 0x45d9f3bu; + h ^= h >> 16; + return Value::MakeFloat((h & 0xFFFFFFu) / static_cast(0x1000000u)); + } +}; // A new FastNoiseLite object is constructed per evaluation so that the seed // from the context is applied correctly across every cell. diff --git a/include/WorldGraph/WorldGraphTypes.h b/include/WorldGraph/WorldGraphTypes.h index 7640c95..3d80ab3 100644 --- a/include/WorldGraph/WorldGraphTypes.h +++ b/include/WorldGraph/WorldGraphTypes.h @@ -44,6 +44,11 @@ struct EvalContext { int32_t prevWidth { 0 }; int32_t prevHeight { 0 }; + // ── Layer blending strength ──────────────────────────────────────────── + // Set by the world layer compositor before calling Evaluate(). + // 1.0 = this layer is fully active; 0.0 = this layer has no influence. + float layerStrength { 1.0f }; + /// Query the previous pass at an absolute world position. /// Returns 0 (AIR / empty) when no previous pass or position is out of bounds. inline int32_t GetPrevTile(int32_t x, int32_t y) const noexcept; diff --git a/src/WorldGraph/WorldGraphSerializer.cpp b/src/WorldGraph/WorldGraphSerializer.cpp index 6d940f4..5d4452c 100644 --- a/src/WorldGraph/WorldGraphSerializer.cpp +++ b/src/WorldGraph/WorldGraphSerializer.cpp @@ -29,11 +29,14 @@ std::unique_ptr GraphSerializer::CreateNode(const std::string& type, if (type == "Xnor") return std::make_unique(); if (type == "Branch") return std::make_unique(); if (type == "IntBranch") return std::make_unique(); - if (type == "PositionX") return std::make_unique(); - if (type == "PositionY") return std::make_unique(); + if (type == "PositionX") return std::make_unique(); + if (type == "PositionY") return std::make_unique(); + if (type == "LayerStrength") return std::make_unique(); if (type == "Sin") return std::make_unique(); if (type == "Cos") return std::make_unique(); if (type == "Modulo") return std::make_unique(); + if (type == "Floor") return std::make_unique(); + if (type == "Ceil") return std::make_unique(); if (type == "Sqrt") return std::make_unique(); if (type == "Pow") return std::make_unique(); if (type == "Square") return std::make_unique(); @@ -51,7 +54,8 @@ std::unique_ptr GraphSerializer::CreateNode(const std::string& type, j.value("max1", 1.0f)); } - // Noise nodes (frequency baked in) + // Noise nodes + if (type == "Random") return std::make_unique(); if (type == "PerlinNoise") return std::make_unique(j.value("frequency", 0.01f)); if (type == "SimplexNoise") return std::make_unique(j.value("frequency", 0.01f)); if (type == "CellularNoise") return std::make_unique(j.value("frequency",0.01f)); diff --git a/tools/node-editor/main.cpp b/tools/node-editor/main.cpp index f6802f7..3f986fd 100644 --- a/tools/node-editor/main.cpp +++ b/tools/node-editor/main.cpp @@ -162,6 +162,7 @@ static ImU32 PinColorHovered(Type t) static constexpr int PREVIEW_SIZE = 64; static constexpr float PREVIEW_SCALE = 1.5f; // world units per pixel +static constexpr float FINAL_PREVIEW_SCALE = 4.0f; // for World Output node static GLuint MakePreviewTexture() { @@ -203,8 +204,9 @@ 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(); } }, + { "PositionX", "Source", [] { return std::make_unique(); } }, + { "PositionY", "Source", [] { return std::make_unique(); } }, + { "LayerStrength", "Source", [] { return std::make_unique(); } }, // ── Math ──────────────────────────────────────────────────────────────── { "Add", "Math", [] { return std::make_unique(); } }, { "Subtract", "Math", [] { return std::make_unique(); } }, @@ -213,6 +215,8 @@ static const std::vector NODE_MENU = { { "Modulo", "Math", [] { return std::make_unique(); } }, { "Sin", "Math", [] { return std::make_unique(); } }, { "Cos", "Math", [] { return std::make_unique(); } }, + { "Floor", "Math", [] { return std::make_unique(); } }, + { "Ceil", "Math", [] { return std::make_unique(); } }, { "Sqrt", "Math", [] { return std::make_unique(); } }, { "Pow", "Math", [] { return std::make_unique(); } }, { "Square", "Math", [] { return std::make_unique(); } }, @@ -245,6 +249,7 @@ static const std::vector NODE_MENU = { { "QueryRange", "Query", [] { return std::make_unique(-1, -1, 1, 1, 1); } }, { "QueryDistance", "Query", [] { return std::make_unique(1, 4); } }, // ── Noise ─────────────────────────────────────────────────────────────── + { "Random", "Noise", [] { return std::make_unique(); } }, { "PerlinNoise", "Noise", [] { return std::make_unique(0.01f); } }, { "SimplexNoise", "Noise", [] { return std::make_unique(0.01f); } }, { "CellularNoise", "Noise", [] { return std::make_unique(0.01f); } }, @@ -260,7 +265,6 @@ struct GraphTab { Graph graph; std::unordered_map visualNodes; - std::vector tileRegistry; std::vector worldOutputPasses; GLuint worldOutputTex { 0 }; @@ -276,7 +280,6 @@ struct GraphTab { 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(); @@ -317,6 +320,10 @@ public: void Render() { // Restore last session written by the ini settings handler. + if (!pendingLoadRegistry.empty()) { + LoadSharedRegistry(pendingLoadRegistry); + pendingLoadRegistry.clear(); + } if (!pendingLoadFiles.empty()) { for (const auto& f : pendingLoadFiles) { auto& cur = Active(); @@ -378,9 +385,16 @@ private: int activeTab { 0 }; int requestedTab { -1 }; // set to trigger programmatic tab switch (one frame) + // Shared tile registry — one across all tabs / graph files + std::vector sharedRegistry { + TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}} + }; + std::string sharedRegistryPath; + // Pending data from ini settings handler std::vector pendingLoadFiles; - int pendingActiveTab { -1 }; + int pendingActiveTab { -1 }; + std::string pendingLoadRegistry; GraphTab& Active() { return tabs[activeTab]; } @@ -449,12 +463,12 @@ private: for (int px = 0; px < PREVIEW_SIZE; ++px) { EvalContext ctx; ctx.worldX = tab.previewOriginX + static_cast(px * tab.previewScale); - ctx.worldY = tab.previewOriginY + static_cast(py * tab.previewScale); + ctx.worldY = tab.previewOriginY + static_cast((PREVIEW_SIZE - 1 - py) * tab.previewScale); ctx.seed = tab.previewSeed; Value v = tab.graph.Evaluate(id, ctx); int base = (py * PREVIEW_SIZE + px) * 4; - ValueToRGBA(v, &tab.tileRegistry, + ValueToRGBA(v, &sharedRegistry, pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]); } } @@ -485,10 +499,10 @@ private: for (int py = 0; py < PREVIEW_SIZE; ++py) { for (int px = 0; px < PREVIEW_SIZE; ++px) { - int32_t tileID = chunk.Get(tab.previewOriginX + px, tab.previewOriginY + py); + int32_t tileID = chunk.Get(tab.previewOriginX + px, tab.previewOriginY + (PREVIEW_SIZE - 1 - py)); int base = (py * PREVIEW_SIZE + px) * 4; Value v = Value::MakeInt(tileID); - ValueToRGBA(v, &tab.tileRegistry, + ValueToRGBA(v, &sharedRegistry, pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]); } } @@ -546,10 +560,27 @@ private: ImGui::Spacing(); ImGui::SeparatorText("Tile IDs"); + // Registry file path (read-only display) + { + const char* rpath = sharedRegistryPath.empty() ? "(unsaved)" : sharedRegistryPath.c_str(); + ImGui::TextDisabled("%s", rpath); + } + if (ImGui::SmallButton("Open##reg")) OpenRegistryDialog(); + ImGui::SameLine(); + if (ImGui::SmallButton("Save##reg")) + { + if (sharedRegistryPath.empty()) SaveRegistryDialog(); + else SaveSharedRegistry(sharedRegistryPath); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Save As##reg")) SaveRegistryDialog(); + + ImGui::Spacing(); + int toRemove = -1; - for (int i = 0; i < static_cast(tab.tileRegistry.size()); ++i) { + for (int i = 0; i < static_cast(sharedRegistry.size()); ++i) { ImGui::PushID(i); - TileEntry& entry = tab.tileRegistry[i]; + TileEntry& entry = sharedRegistry[i]; // Color button — opens a popup picker. char cpopup[32]; @@ -593,11 +624,11 @@ private: ImGui::PopID(); } if (toRemove >= 0) - tab.tileRegistry.erase(tab.tileRegistry.begin() + toRemove); + sharedRegistry.erase(sharedRegistry.begin() + toRemove); if (ImGui::SmallButton("+ Add Tile")) { - int32_t nextId = tab.tileRegistry.empty() ? 1 - : tab.tileRegistry.back().id + 1; + int32_t nextId = sharedRegistry.empty() ? 1 + : sharedRegistry.back().id + 1; TileEntry entry; entry.id = nextId; entry.name = "Tile"; @@ -608,7 +639,7 @@ private: entry.color[0] = hr / 255.0f; entry.color[1] = hg / 255.0f; entry.color[2] = hb / 255.0f; - tab.tileRegistry.push_back(entry); + sharedRegistry.push_back(entry); } ImGui::Spacing(); @@ -913,7 +944,7 @@ private: // Generated chunk preview — always 1 tile per pixel. ImGui::Image( static_cast(static_cast(tab.worldOutputTex)), - ImVec2(PREVIEW_SIZE * PREVIEW_SCALE, PREVIEW_SIZE * PREVIEW_SCALE)); + ImVec2(PREVIEW_SIZE * FINAL_PREVIEW_SCALE, PREVIEW_SIZE * FINAL_PREVIEW_SCALE)); ImNodes::EndNode(); @@ -986,38 +1017,46 @@ private: return ImGui::DragFloat("##val", &n->value, 0.01f); } - static bool DrawIDParams(NodeEditorApp& app, Node* node) + static bool DrawIDDropdown(const char* label, NodeEditorApp& app, int& currentID) { - auto* n = static_cast(node); - auto& reg = app.Active().tileRegistry; + auto& reg = app.sharedRegistry; + int selIdx = -1; bool changed = false; - if (reg.empty()) { - changed = ImGui::DragInt("##id", &n->tileID, 1, 0, 9999); - } else { - int selIdx = -1; - for (int i = 0; i < static_cast(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(reg.size()); ++i) { - bool sel = (i == selIdx); - char label[80]; - snprintf(label, sizeof(label), "%s (%d)", - reg[i].name.c_str(), reg[i].id); - if (ImGui::Selectable(label, sel)) { - n->tileID = reg[i].id; - changed = true; - } - if (sel) ImGui::SetItemDefaultFocus(); + for (int i = 0; i < static_cast(reg.size()); ++i) + if (reg[i].id == currentID) { selIdx = i; break; } + const char* preview = selIdx >= 0 ? reg[selIdx].name.c_str() : "???"; + ImGui::SetNextItemWidth(80); + if (ImGui::BeginCombo(label, preview)) { + for (int i = 0; i < static_cast(reg.size()); ++i) { + bool sel = (i == selIdx); + char label[80]; + snprintf(label, sizeof(label), "%s (%d)", + reg[i].name.c_str(), reg[i].id); + if (ImGui::Selectable(label, sel)) { + currentID = reg[i].id; + changed = true; } - ImGui::EndCombo(); + if (sel) ImGui::SetItemDefaultFocus(); } + ImGui::EndCombo(); } return changed; } - static bool DrawQueryTileParams(NodeEditorApp& /*app*/, Node* node) + static bool DrawIDParams(NodeEditorApp& app, Node* node) + { + auto* n = static_cast(node); + auto& reg = app.sharedRegistry; + bool changed = false; + if (reg.empty()) { + changed = ImGui::DragInt("##id", &n->tileID, 1, 0, 9999); + } else { + changed |= DrawIDDropdown("##id", app, n->tileID); + } + return changed; + } + + static bool DrawQueryTileParams(NodeEditorApp& app, Node* node) { auto* n = static_cast(node); bool changed = false; @@ -1028,33 +1067,35 @@ private: 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); + changed |= DrawIDDropdown("##eid", app, n->expectedID); ImGui::PopItemWidth(); return changed; } - static bool DrawQueryRangeParams(NodeEditorApp& /*app*/, Node* node) + static bool DrawQueryRangeParams(NodeEditorApp& app, Node* node) { auto* n = static_cast(node); bool changed = false; ImGui::PushItemWidth(70); + ImGui::Text("min x -> max x"); changed |= ImGui::DragInt("##mnx", &n->minX, 1); ImGui::SameLine(); changed |= ImGui::DragInt("##mxx", &n->maxX, 1); + ImGui::Text("min y -> max y"); changed |= ImGui::DragInt("##mny", &n->minY, 1); ImGui::SameLine(); changed |= ImGui::DragInt("##mxy", &n->maxY, 1); - changed |= ImGui::DragInt("##rtid", &n->tileID, 1, 0, 1024); + changed |= DrawIDDropdown("##rtid", app, n->tileID); ImGui::PopItemWidth(); return changed; } - static bool DrawQueryDistanceParams(NodeEditorApp& /*app*/, Node* node) + static bool DrawQueryDistanceParams(NodeEditorApp& app, Node* node) { auto* n = static_cast(node); bool changed = false; ImGui::PushItemWidth(70); - changed |= ImGui::DragInt("##dtid", &n->tileID, 1, 0, 1024); + changed |= DrawIDDropdown("##dtid", app, n->tileID); changed |= ImGui::DragInt("##md", &n->maxDistance, 1, 1, 32); ImGui::PopItemWidth(); return changed; @@ -1126,6 +1167,75 @@ private: // ── Serialization ───────────────────────────────────────────────────────── + // ── Shared tile registry I/O ────────────────────────────────────────────── + + void SaveSharedRegistry(const std::string& path) + { + nlohmann::json j; + auto& tiles = j["tiles"] = nlohmann::json::array(); + for (auto& t : sharedRegistry) + tiles.push_back({ {"id", t.id}, {"name", t.name}, + {"color", { t.color[0], t.color[1], t.color[2] }} }); + std::ofstream f(path); + if (f) { f << j.dump(2); sharedRegistryPath = path; } + else fprintf(stderr, "Failed to write registry %s\n", path.c_str()); + } + + void LoadSharedRegistry(const std::string& path) + { + std::ifstream f(path); + if (!f) { fprintf(stderr, "Cannot open registry %s\n", path.c_str()); return; } + nlohmann::json j; + try { j = nlohmann::json::parse(f); } + catch (...) { fprintf(stderr, "JSON parse error in registry %s\n", path.c_str()); return; } + + if (!j.contains("tiles") || !j["tiles"].is_array()) return; + + sharedRegistry.clear(); + for (auto& t : j["tiles"]) { + TileEntry entry; + entry.id = t.value("id", 0); + entry.name = t.value("name", std::string("Tile")); + if (t.contains("color") && t["color"].size() == 3) { + entry.color[0] = t["color"][0].get(); + entry.color[1] = t["color"][1].get(); + entry.color[2] = t["color"][2].get(); + } + sharedRegistry.push_back(entry); + } + // Always ensure ID 0 sentinel is present. + bool hasEmpty = std::any_of(sharedRegistry.begin(), sharedRegistry.end(), + [](const TileEntry& e) { return e.id == 0; }); + if (!hasEmpty) + sharedRegistry.insert(sharedRegistry.begin(), + TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}}); + + sharedRegistryPath = path; + MarkAllDirty(); + } + + void OpenRegistryDialog() + { + IGFD::FileDialogConfig cfg; + cfg.path = sharedRegistryPath.empty() ? "." + : std::filesystem::path(sharedRegistryPath).parent_path().string(); + ImGuiFileDialog::Instance()->OpenDialog("OpenRegistryDlg", "Open Tile Registry", ".json", cfg); + } + + void SaveRegistryDialog() + { + IGFD::FileDialogConfig cfg; + if (!sharedRegistryPath.empty()) { + std::filesystem::path p(sharedRegistryPath); + cfg.path = p.parent_path().string(); + cfg.fileName = p.filename().string(); + } else { + cfg.path = "."; + cfg.fileName = "tiles.json"; + } + ImGuiFileDialog::Instance()->OpenDialog("SaveRegistryDlg", "Save Tile Registry", ".json", cfg); + } + void Save(const std::string& path) { auto& tab = Active(); @@ -1156,12 +1266,6 @@ private: passes.push_back(id); root["editor"]["worldOutputPasses"] = passes; - nlohmann::json tiles = nlohmann::json::array(); - for (auto& t : tab.tileRegistry) - tiles.push_back({ {"id", t.id}, {"name", t.name}, - {"color", { t.color[0], t.color[1], t.color[2] }} }); - root["editor"]["tileRegistry"] = tiles; - std::ofstream f(path); if (f) { f << root.dump(2); tab.currentFile = path; } else fprintf(stderr, "Failed to write %s\n", path.c_str()); @@ -1188,7 +1292,6 @@ private: 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); @@ -1210,26 +1313,8 @@ private: tab.previewOriginY = ed.value("previewOriginY", 0); tab.previewScale = ed.value("previewScale", 1.0f); - if (ed.contains("tileRegistry")) { - tab.tileRegistry.clear(); - for (auto& t : ed["tileRegistry"]) { - TileEntry entry; - entry.id = t.value("id", 0); - entry.name = t.value("name", std::string("Tile")); - if (t.contains("color") && t["color"].size() == 3) { - entry.color[0] = t["color"][0].get(); - entry.color[1] = t["color"][1].get(); - entry.color[2] = t["color"][2].get(); - } - tab.tileRegistry.push_back(entry); - } - // Always guarantee the Empty (ID 0) sentinel is present. - bool hasEmpty = std::any_of(tab.tileRegistry.begin(), tab.tileRegistry.end(), - [](const TileEntry& e) { return e.id == 0; }); - if (!hasEmpty) - tab.tileRegistry.insert(tab.tileRegistry.begin(), - TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}}); - } + // Note: "tileRegistry" embedded in old .wge files is intentionally + // ignored — use the shared tiles.json registry instead. if (ed.contains("worldOutputPasses")) { tab.worldOutputPasses.clear(); @@ -1320,6 +1405,20 @@ public: Save(ImGuiFileDialog::Instance()->GetFilePathName()); ImGuiFileDialog::Instance()->Close(); } + + if (ImGuiFileDialog::Instance()->Display("OpenRegistryDlg", + ImGuiWindowFlags_NoCollapse, dlgSize)) { + if (ImGuiFileDialog::Instance()->IsOk()) + LoadSharedRegistry(ImGuiFileDialog::Instance()->GetFilePathName()); + ImGuiFileDialog::Instance()->Close(); + } + + if (ImGuiFileDialog::Instance()->Display("SaveRegistryDlg", + ImGuiWindowFlags_NoCollapse, dlgSize)) { + if (ImGuiFileDialog::Instance()->IsOk()) + SaveSharedRegistry(ImGuiFileDialog::Instance()->GetFilePathName()); + ImGuiFileDialog::Instance()->Close(); + } } void HandleKeyboardShortcuts() @@ -1358,6 +1457,8 @@ public: } if (strncmp(line, "ActiveTab=", 10) == 0) app->pendingActiveTab = std::atoi(line + 10); + if (strncmp(line, "SharedRegistry=", 15) == 0) + app->pendingLoadRegistry = line + 15; }; // Called when ImGui writes imgui.ini (periodically and on shutdown). @@ -1370,12 +1471,14 @@ public: files += tab.currentFile; } } + out_buf->appendf("[NodeEditor][Session]\n"); 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"); } + if (!app->sharedRegistryPath.empty()) + out_buf->appendf("SharedRegistry=%s\n", app->sharedRegistryPath.c_str()); + out_buf->appendf("\n"); }; ImGui::AddSettingsHandler(&h);