diff --git a/src/features/editScene/TerrainRequirements.md b/src/features/editScene/TerrainRequirements.md index 03ae9db..64fb365 100644 --- a/src/features/editScene/TerrainRequirements.md +++ b/src/features/editScene/TerrainRequirements.md @@ -1308,11 +1308,6 @@ radius. instead of the base heightmap. - "Clear All Fixups" button — deletes all fixup chunks and marks affected pages dirty for rebuild. -**Camera pivot above terrain** (on-demand, not automatic): -- "Snap Camera Above Terrain" button or hotkey in TerrainEditor. -- Raycast downward from camera at current XZ, get terrain height, place camera - at `(cam.x, terrainHeight + 5.0, cam.z)`. -- Only on user request — never auto-corrects. **Definition of done**: user can sculpt terrain with visual brush feedback; save scene, reload, terrain restored with edited heightmap and layer paint; diff --git a/src/features/editScene/components/TerrainModule.cpp b/src/features/editScene/components/TerrainModule.cpp index 10c80d9..981eb9c 100644 --- a/src/features/editScene/components/TerrainModule.cpp +++ b/src/features/editScene/components/TerrainModule.cpp @@ -4,6 +4,7 @@ #include "EntityName.hpp" #include "../ui/ComponentRegistration.hpp" #include "../ui/TerrainEditor.hpp" +#include REGISTER_COMPONENT_GROUP("Terrain", "Environment", TerrainComponent, TerrainEditor) @@ -13,8 +14,14 @@ REGISTER_COMPONENT_GROUP("Terrain", "Environment", TerrainComponent, std::make_unique(), /* Adder */ [](flecs::entity e) { - if (!e.has()) - e.set({}); + if (!e.has()) { + TerrainComponent tc; + tc.terrainId = (uint64_t)std::chrono:: + system_clock::now() + .time_since_epoch() + .count(); + e.set(tc); + } }, /* Remover */ [](flecs::entity e) { diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 02a8492..955a48a 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -33,6 +33,7 @@ #include "../components/BuoyancyInfo.hpp" #include "../components/WaterPhysics.hpp" #include "../components/WaterPlane.hpp" +#include "../components/Terrain.hpp" #include "../components/Sun.hpp" #include "../components/Skybox.hpp" #include "../components/EventHandler.hpp" @@ -319,6 +320,9 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity) if (entity.has()) { json["waterPlane"] = serializeWaterPlane(entity); } + if (entity.has()) { + json["terrain"] = serializeTerrain(entity); + } // ActionDatabase is now a singleton, serialized at scene level if (entity.has()) { @@ -552,6 +556,7 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json, if (json.contains("waterPlane")) { deserializeWaterPlane(entity, json["waterPlane"]); } + if (json.contains("terrain")) deserializeTerrain(entity, json["terrain"]); // ActionDatabase is now a singleton, deserialized at scene level if (json.contains("actionDebug")) { @@ -858,6 +863,7 @@ void SceneSerializer::deserializeEntityComponents( if (json.contains("waterPlane")) { deserializeWaterPlane(entity, json["waterPlane"]); } + if (json.contains("terrain")) deserializeTerrain(entity, json["terrain"]); // ActionDatabase is now a singleton, deserialized at scene level if (json.contains("actionDebug")) { @@ -4129,3 +4135,65 @@ void SceneSerializer::deserializeInventory(flecs::entity entity, } } } + +/* ------------------------------------------------------------------ */ +/* TerrainComponent serialization */ +/* ------------------------------------------------------------------ */ + +nlohmann::json SceneSerializer::serializeTerrain(flecs::entity entity) +{ + const TerrainComponent &tc = entity.get(); + nlohmann::json json; + + json["enabled"] = tc.enabled; + json["terrainSize"] = tc.terrainSize; + json["terrainId"] = tc.terrainId; + json["heightmapSize"] = tc.heightmapSize; + json["worldSize"] = tc.worldSize; + json["maxPixelError"] = tc.maxPixelError; + json["compositeMapDistance"] = tc.compositeMapDistance; + json["minBatchSize"] = tc.minBatchSize; + json["maxBatchSize"] = tc.maxBatchSize; + json["heightmapFile"] = tc.heightmapFile; + + nlohmann::json layersJson = nlohmann::json::array(); + for (auto &l : tc.layers) { + nlohmann::json lj; + lj["diffuseTexture"] = l.diffuseTexture; + lj["normalTexture"] = l.normalTexture; + lj["worldSize"] = l.worldSize; + layersJson.push_back(lj); + } + json["layers"] = layersJson; + + return json; +} + +void SceneSerializer::deserializeTerrain(flecs::entity entity, + const nlohmann::json &json) +{ + TerrainComponent tc; + + tc.enabled = json.value("enabled", true); + tc.terrainSize = json.value("terrainSize", 65); + tc.terrainId = json.value("terrainId", (uint64_t)0); + tc.heightmapSize = json.value("heightmapSize", 256); + tc.worldSize = json.value("worldSize", 2000.0f); + tc.maxPixelError = json.value("maxPixelError", 1.0f); + tc.compositeMapDistance = json.value("compositeMapDistance", 300.0f); + tc.minBatchSize = json.value("minBatchSize", 17); + tc.maxBatchSize = json.value("maxBatchSize", 65); + tc.heightmapFile = json.value("heightmapFile", "heightmap.bin"); + + if (json.contains("layers") && json["layers"].is_array()) { + for (auto &lj : json["layers"]) { + TerrainComponent::Layer layer; + layer.diffuseTexture = lj.value("diffuseTexture", ""); + layer.normalTexture = lj.value("normalTexture", ""); + layer.worldSize = lj.value("worldSize", 100.0f); + tc.layers.push_back(layer); + } + } + + entity.set(tc); +} diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index d553f34..70d4940 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -206,6 +206,8 @@ private: // WaterPlane serialization nlohmann::json serializeWaterPlane(flecs::entity entity); + nlohmann::json serializeTerrain(flecs::entity entity); + void deserializeTerrain(flecs::entity entity, const nlohmann::json &json); void deserializeWaterPlane(flecs::entity entity, const nlohmann::json &json); diff --git a/src/features/editScene/systems/TerrainSystem.cpp b/src/features/editScene/systems/TerrainSystem.cpp index faadd0e..e02bd9d 100644 --- a/src/features/editScene/systems/TerrainSystem.cpp +++ b/src/features/editScene/systems/TerrainSystem.cpp @@ -18,6 +18,8 @@ #include #include +#include +#include TerrainSystem *TerrainSystem::s_instance = nullptr; @@ -98,7 +100,7 @@ void TerrainSystem::CustomTerrainDefiner::define(Ogre::TerrainGroup *group, long wx = (long)(worldPos.x + (Ogre::Real)i * step); long wz = (long)(worldPos.z + (Ogre::Real)j * step); heightMap[j * terrainSize + i] = - proceduralHeight(wx, wz); + m_sys->sampleHeightAt(wx, wz); } } @@ -358,6 +360,25 @@ void TerrainSystem::activate(TerrainComponent &tc, TransformComponent &xform) m_physics->setBodyDrawFilter(&mBodyDrawFilter); processColliderCreates(); + /* If no heightmap loaded, generate m_heightData from procedural + * so that Save Heightmap has something to write. */ + if (!m_heightmapLoaded) { + m_heightmapRes = tc.heightmapSize; + m_heightData.resize(m_heightmapRes * m_heightmapRes); + float ws = mTerrainGroup->getTerrainWorldSize(); + for (int z = 0; z < m_heightmapRes; ++z) { + for (int x = 0; x < m_heightmapRes; ++x) { + float fx = (float)x * + ws / (float)(m_heightmapRes - 1); + float fz = (float)z * + ws / (float)(m_heightmapRes - 1); + m_heightData[z * m_heightmapRes + x] = + proceduralHeight((long)fx, (long)fz); + } + } + m_heightmapLoaded = true; + } + m_active = true; Ogre::LogManager::getSingleton().logMessage( "TerrainSystem: activation complete"); @@ -483,4 +504,156 @@ bool TerrainSystem::raycastTerrain(const Ogre::Ray &ray, float &outT, void TerrainSystem::setShowTerrainColliders(bool show) { m_showTerrainColliders = show; + +} +/* ------------------------------------------------------------------ */ +/* Heightmap data (M3) */ +/* ------------------------------------------------------------------ */ + +float TerrainSystem::sampleHeightAt(long worldX, long worldZ) const +{ + if (!m_heightmapLoaded || m_heightData.empty()) + return proceduralHeight(worldX, worldZ); + + /* Bilinear sample of base heightmap (resolution m_heightmapRes x m_heightmapRes). */ + float ws = mTerrainGroup ? mTerrainGroup->getTerrainWorldSize() : 2000.0f; + float fx = (float)worldX / ws * (float)m_heightmapRes; + float fz = (float)worldZ / ws * (float)m_heightmapRes; + + int x0 = (int)floorf(fx); + int z0 = (int)floorf(fz); + int x1 = x0 + 1; + int z1 = z0 + 1; + + int r = m_heightmapRes; + x0 = std::max(0, std::min(x0, r - 1)); + x1 = std::max(0, std::min(x1, r - 1)); + z0 = std::max(0, std::min(z0, r - 1)); + z1 = std::max(0, std::min(z1, r - 1)); + + float tx = fx - (float)x0; + float tz = fz - (float)z0; + + float h00 = m_heightData[z0 * r + x0]; + float h10 = m_heightData[z0 * r + x1]; + float h01 = m_heightData[z1 * r + x0]; + float h11 = m_heightData[z1 * r + x1]; + + return (1.0f - tx) * (1.0f - tz) * h00 + + tx * (1.0f - tz) * h10 + + (1.0f - tx) * tz * h01 + + tx * tz * h11; +} + +void TerrainSystem::setHeightAt(long worldX, long worldZ, float value) +{ + if (!m_heightmapLoaded) return; + float ws = mTerrainGroup ? mTerrainGroup->getTerrainWorldSize() : 2000.0f; + int x = (int)((float)worldX / ws * (float)m_heightmapRes); + int z = (int)((float)worldZ / ws * (float)m_heightmapRes); + if (x < 0 || x >= m_heightmapRes || z < 0 || z >= m_heightmapRes) return; + m_heightData[z * m_heightmapRes + x] = value; +} + +bool TerrainSystem::loadHeightmap(const std::string &path) +{ + std::ifstream file(path, std::ios::binary); + if (!file) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: failed to open heightmap: " + path); + return false; + } + uint32_t res = 0; + file.read(reinterpret_cast(&res), sizeof(res)); + if (res < 2 || res > 8192) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: invalid heightmap resolution: " + + Ogre::StringConverter::toString(res)); + return false; + } + m_heightmapRes = (int)res; + m_heightData.resize(res * res); + file.read(reinterpret_cast(m_heightData.data()), + res * res * sizeof(float)); + m_heightmapLoaded = true; + Ogre::LogManager::getSingleton().logMessage( + "Terrain: loaded heightmap " + path + " (" + + Ogre::StringConverter::toString(res) + "x" + + Ogre::StringConverter::toString(res) + ")"); + return true; +} + +bool TerrainSystem::saveHeightmap(const std::string &path) +{ + if (!m_heightmapLoaded) return false; + + /* Ensure directory exists. */ + size_t lastSlash = path.rfind('/'); + if (lastSlash != std::string::npos) { + std::string dir = path.substr(0, lastSlash); + mkdir(dir.c_str(), 0755); + } + + std::ofstream file(path, std::ios::binary); + if (!file) { + Ogre::LogManager::getSingleton().logMessage( + "Terrain: failed to create heightmap file: " + path); + return false; + } + uint32_t res = (uint32_t)m_heightmapRes; + file.write(reinterpret_cast(&res), sizeof(res)); + file.write(reinterpret_cast(m_heightData.data()), + m_heightmapRes * m_heightmapRes * sizeof(float)); + Ogre::LogManager::getSingleton().logMessage( + "Terrain: saved heightmap " + path + " (" + + Ogre::StringConverter::toString(res) + "x" + + Ogre::StringConverter::toString(res) + ")"); + return true; +} + +void TerrainSystem::markPageDirty(long pageX, long pageY) +{ + if (mTerrainGroup) + m_dirtyPages.insert(mTerrainGroup->packIndex(pageX, pageY)); +} + +void TerrainSystem::rebuildDirtyPages() +{ + if (!mTerrainGroup || m_dirtyPages.empty()) return; + Ogre::LogManager::getSingleton().logMessage( + "Terrain: rebuilding " + + Ogre::StringConverter::toString((int)m_dirtyPages.size()) + + " dirty pages"); + for (uint64_t key : m_dirtyPages) { + long x, y; + mTerrainGroup->unpackIndex(key, &x, &y); + mTerrainDefiner->define(mTerrainGroup, x, y); + queueColliderCreate(x, y); + } + m_dirtyPages.clear(); +} + +/* ------------------------------------------------------------------ */ +/* snapCameraAboveTerrain (M3) */ +/* ------------------------------------------------------------------ */ + +void TerrainSystem::snapCameraAboveTerrain() +{ + if (!m_active || !m_sceneMgr) + return; + + /* Move the camera target ("pivot"), not the camera node. + * EditorCamera creates m_targetNode as "EditorCameraTarget" + * — the CameraMan orbits the camera around this node. */ + Ogre::SceneNode *targetNode = + m_sceneMgr->getSceneNode("EditorCameraTarget"); + if (!targetNode) + targetNode = m_sceneMgr->getSceneNode("EditorCameraTargetNode"); + if (!targetNode) + return; + + Ogre::Vector3 pos = targetNode->getPosition(); + float h = getHeightAt(pos); + if (pos.y < h + 5.0f) + targetNode->setPosition(pos.x, h + 5.0f, pos.z); } diff --git a/src/features/editScene/systems/TerrainSystem.hpp b/src/features/editScene/systems/TerrainSystem.hpp index 5e7493d..5efc488 100644 --- a/src/features/editScene/systems/TerrainSystem.hpp +++ b/src/features/editScene/systems/TerrainSystem.hpp @@ -49,6 +49,34 @@ public: return m_showTerrainColliders; } + /* --- Heightmap data (M3) --- */ + float sampleHeightAt(long worldX, long worldZ) const; + void setHeightAt(long worldX, long worldZ, float value); + bool loadHeightmap(const std::string &path); + bool saveHeightmap(const std::string &path); + void markPageDirty(long pageX, long pageY); + void rebuildDirtyPages(); + + /* --- Sculpting (M3) --- */ + enum class SculptTool { Raise, Lower, Smooth, Flatten, SplatPaint }; + bool m_sculptMode = false; + SculptTool m_sculptTool = SculptTool::Raise; + float m_sculptRadius = 5.0f; + float m_sculptStrength = 0.5f; + int m_sculptLayerIndex = 0; + bool getSculptMode() const { return m_sculptMode; } + void setSculptMode(bool v) { m_sculptMode = v; } + bool isSculpting() const { return m_sculptMode; } + SculptTool getSculptTool() const { return m_sculptTool; } + void setSculptTool(SculptTool t) { m_sculptTool = t; } + float getSculptRadius() const { return m_sculptRadius; } + void setSculptRadius(float r) { m_sculptRadius = r; } + float getSculptStrength() const { return m_sculptStrength; } + void setSculptStrength(float s) { m_sculptStrength = s; } + int getSculptLayerIndex() const { return m_sculptLayerIndex; } + void setSculptLayerIndex(int i) { m_sculptLayerIndex = i; } + void snapCameraAboveTerrain(); + private: void activate(struct TerrainComponent &tc, struct TransformComponent &xform); @@ -136,6 +164,13 @@ private: std::deque> mColliderCreateQueue; TerrainBodyDrawFilter mBodyDrawFilter; + /* Heightmap data (M3) */ + std::vector m_heightData; + bool m_heightmapLoaded = false; + int m_heightmapRes = 0; + std::set m_dirtyPages; + bool hasHeightmap() const { return m_heightmapLoaded; } + flecs::query m_terrainQuery; }; diff --git a/src/features/editScene/ui/TerrainEditor.hpp b/src/features/editScene/ui/TerrainEditor.hpp index 8d12aff..36ff445 100644 --- a/src/features/editScene/ui/TerrainEditor.hpp +++ b/src/features/editScene/ui/TerrainEditor.hpp @@ -39,8 +39,64 @@ public: ImGui::Separator(); - /* Read-only display of sizing params (restart needed to change). - * In M2+ these get Restart Terrain buttons. */ + /* --- Sculpting (M3) --- */ + if (ts) { + ImGui::Text("Sculpting"); + bool sculpt = ts->getSculptMode(); + if (ImGui::Checkbox("Sculpt Mode", &sculpt)) + ts->setSculptMode(sculpt); + if (sculpt) + ImGui::TextColored(ImVec4(0, 1, 0, 1), + "SCULPT MODE ACTIVE"); + ImGui::SameLine(); + ImGui::TextDisabled("(ESC to exit)"); + + const char *tools[] = { "Raise", "Lower", "Smooth", + "Flatten", "Splat Paint" }; + int tool = (int)ts->getSculptTool(); + if (ImGui::Combo("Tool", &tool, tools, + IM_ARRAYSIZE(tools))) + ts->setSculptTool((TerrainSystem::SculptTool)tool); + + float radius = ts->getSculptRadius(); + if (ImGui::SliderFloat("Radius", &radius, 0.5f, 50.0f)) + ts->setSculptRadius(radius); + + float strength = ts->getSculptStrength(); + if (ImGui::SliderFloat("Strength", &strength, 0.01f, + 1.0f)) + ts->setSculptStrength(strength); + + if (ts->getSculptTool() == + TerrainSystem::SculptTool::SplatPaint) { + int li = ts->getSculptLayerIndex(); + if (ImGui::InputInt("Layer Index", &li, 1, 1)) + ts->setSculptLayerIndex( + std::max(0, li)); + } + } + + ImGui::Separator(); + + /* --- Camera & file operations --- */ + if (ts) { + if (ImGui::Button("Snap Camera Above Terrain")) + ts->snapCameraAboveTerrain(); + ImGui::SameLine(); + if (ImGui::Button("Save Heightmap")) + ts->saveHeightmap("heightmaps/ht_" + + std::to_string(tc.terrainId) + ".bin"); + ImGui::SameLine(); + if (ImGui::Button("Load Heightmap")) + ts->loadHeightmap("heightmaps/ht_" + + std::to_string(tc.terrainId) + ".bin"); + } + + ImGui::Separator(); + + /* Read-only display of sizing params */ + ImGui::Text("Terrain ID: %llu", + (unsigned long long)tc.terrainId); ImGui::Text("Page size: %d x %d vertices", tc.terrainSize, tc.terrainSize); ImGui::Text("World size: %.0f units", tc.worldSize);