From 115fe8436f1a866ff4eaaecbb9a13ebb142a9b2a Mon Sep 17 00:00:00 2001 From: Kelsi Date: Tue, 5 May 2026 14:22:21 -0700 Subject: [PATCH] feat(editor): terrain-aligned objects, batch convert, WCP import+load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New features: - Align to Slope: rotates objects to match terrain surface normal at their position (trees on hillsides lean naturally). Works with multi-select. Available in object panel and right-click context menu - Batch Convert Assets: File menu option to recursively convert all M2→WOM and WMO→WOB files in a data directory to open format - Import & Load: one-click WCP unpack + auto-open the imported zone - sampleTerrainNormal() for slope detection via height differencing - Zone load error toasts for missing/corrupt files --- tools/editor/editor_app.cpp | 74 +++++++++++++++++++++++++++++++++ tools/editor/editor_app.hpp | 2 + tools/editor/editor_ui.cpp | 31 ++++++++++++++ tools/editor/terrain_editor.cpp | 26 ++++++++++++ tools/editor/terrain_editor.hpp | 3 ++ 5 files changed, 136 insertions(+) diff --git a/tools/editor/editor_app.cpp b/tools/editor/editor_app.cpp index 693b5299..498ee95e 100644 --- a/tools/editor/editor_app.cpp +++ b/tools/editor/editor_app.cpp @@ -678,12 +678,14 @@ void EditorApp::loadADT(const std::string& mapName, int tileX, int tileY) { auto adtData = assetManager_->readFile(path.str()); if (adtData.empty()) { LOG_ERROR("ADT file not found: ", path.str()); + showToast("Zone not found: " + mapName + " [" + std::to_string(tileX) + "," + std::to_string(tileY) + "]"); return; } terrain_ = pipeline::ADTLoader::load(adtData); if (!terrain_.isLoaded()) { LOG_ERROR("Failed to parse ADT: ", path.str()); + showToast("Failed to load zone (corrupt or unsupported format)"); return; } } @@ -1296,6 +1298,78 @@ void EditorApp::snapSelectedToGround() { } } +void EditorApp::alignSelectedToTerrain() { + auto& indices = objectPlacer_.getSelectedIndices(); + auto& objects = objectPlacer_.getObjects(); + int count = 0; + auto alignOne = [&](PlacedObject& obj) { + glm::vec3 normal = terrainEditor_.sampleTerrainNormal(obj.position); + float pitchDeg = glm::degrees(std::asin(-normal.x)); + float rollDeg = glm::degrees(std::asin(normal.y)); + obj.rotation.x = pitchDeg; + obj.rotation.z = rollDeg; + count++; + }; + if (!indices.empty()) { + for (int idx : indices) alignOne(objects[idx]); + } else if (auto* sel = objectPlacer_.getSelected()) { + alignOne(*sel); + } + if (count > 0) { + objectsDirty_ = true; + showToast("Aligned " + std::to_string(count) + " object(s) to terrain"); + } +} + +int EditorApp::batchConvertAssets(const std::string& dataDir) { + namespace fs = std::filesystem; + if (!fs::exists(dataDir)) return 0; + + int converted = 0; + for (auto& entry : fs::recursive_directory_iterator(dataDir)) { + if (!entry.is_regular_file()) continue; + std::string ext = entry.path().extension().string(); + for (char& c : ext) c = static_cast(std::tolower(static_cast(c))); + std::string relPath = fs::relative(entry.path(), dataDir).string(); + + if (ext == ".m2") { + auto wom = pipeline::WoweeModelLoader::fromM2(relPath, assetManager_.get()); + if (wom.isValid()) { + std::string outPath = relPath; + auto dot = outPath.rfind('.'); + if (dot != std::string::npos) outPath = outPath.substr(0, dot); + pipeline::WoweeModelLoader::save(wom, "output/models/" + outPath); + converted++; + } + } else if (ext == ".wmo") { + auto wmoData = assetManager_->readFile(relPath); + if (!wmoData.empty()) { + auto wmoModel = pipeline::WMOLoader::load(wmoData); + if (wmoModel.nGroups > 0) { + std::string wmoBase = relPath; + if (wmoBase.size() > 4) wmoBase = wmoBase.substr(0, wmoBase.size() - 4); + for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) { + char suffix[16]; + snprintf(suffix, sizeof(suffix), "_%03u.wmo", gi); + auto gd = assetManager_->readFile(wmoBase + suffix); + if (!gd.empty()) pipeline::WMOLoader::loadGroup(gd, wmoModel, gi); + } + } + auto wob = pipeline::WoweeBuildingLoader::fromWMO(wmoModel, relPath); + if (wob.isValid()) { + std::string outPath = relPath; + auto dot = outPath.rfind('.'); + if (dot != std::string::npos) outPath = outPath.substr(0, dot); + pipeline::WoweeBuildingLoader::save(wob, "output/buildings/" + outPath); + converted++; + } + } + } + } + LOG_INFO("Batch converted ", converted, " assets from ", dataDir); + return converted; +} + void EditorApp::resetCamera() { camera_.setPosition(glm::vec3(0.0f, 0.0f, 300.0f)); camera_.setYawPitch(0.0f, -30.0f); diff --git a/tools/editor/editor_app.hpp b/tools/editor/editor_app.hpp index cf1c143d..4555e52b 100644 --- a/tools/editor/editor_app.hpp +++ b/tools/editor/editor_app.hpp @@ -89,7 +89,9 @@ public: void setGizmoAxis(TransformAxis axis); void setSkyPreset(int preset); // 0=day, 1=dusk, 2=night void snapSelectedToGround(); + void alignSelectedToTerrain(); void flyToSelected(); + int batchConvertAssets(const std::string& dataDir); void clearAllObjects(); void generateCompleteZone(); void centerOnTerrain(); diff --git a/tools/editor/editor_ui.cpp b/tools/editor/editor_ui.cpp index 5b7f4f6f..a5908d72 100644 --- a/tools/editor/editor_ui.cpp +++ b/tools/editor/editor_ui.cpp @@ -273,6 +273,22 @@ void EditorUI::renderMenuBar(EditorApp& app) { else app.showToast("Import failed — check path"); } + if (ImGui::MenuItem("Import & Load")) { + editor::ContentPackInfo info; + if (editor::ContentPacker::readInfo(wcpImportPath, info) && + editor::ContentPacker::unpackZone(wcpImportPath, "custom_zones")) { + app.showToast("Imported: " + info.name); + auto zones = pipeline::CustomZoneDiscovery::scan({"custom_zones"}); + for (const auto& z : zones) { + if (z.name == info.name && !z.tiles.empty()) { + app.loadADT(z.name, z.tiles[0].first, z.tiles[0].second); + break; + } + } + } else { + app.showToast("Import failed — check path"); + } + } if (ImGui::MenuItem("Inspect Pack Info")) { editor::ContentPackInfo info; if (editor::ContentPacker::readInfo(wcpImportPath, info)) { @@ -346,6 +362,17 @@ void EditorUI::renderMenuBar(EditorApp& app) { fmt(val.hasObjects, true, "objects", "placed objects"); ImGui::EndMenu(); } + if (ImGui::BeginMenu("Batch Convert Assets")) { + static char batchDir[256] = "Data"; + ImGui::InputText("Data Directory", batchDir, sizeof(batchDir)); + ImGui::TextColored(ImVec4(0.6f,0.6f,0.6f,1), + "Recursively converts M2->WOM and WMO->WOB"); + if (ImGui::MenuItem("Convert All")) { + int n = app.batchConvertAssets(batchDir); + app.showToast("Converted " + std::to_string(n) + " assets to open format"); + } + ImGui::EndMenu(); + } if (ImGui::BeginMenu("Add Adjacent Tile", app.hasTerrainLoaded())) { if (ImGui::MenuItem("North (+X)")) app.addAdjacentTile(1, 0); if (ImGui::MenuItem("South (-X)")) app.addAdjacentTile(-1, 0); @@ -1483,6 +1510,8 @@ void EditorUI::renderObjectPanel(EditorApp& app) { if (ImGui::Button("Snap Ground", ImVec2(75, 0))) app.snapSelectedToGround(); ImGui::SameLine(); + if (ImGui::Button("Align Slope", ImVec2(75, 0))) + app.alignSelectedToTerrain(); if (ImGui::Button("Fly To", ImVec2(55, 0))) app.flyToSelected(); ImGui::SameLine(); @@ -2057,6 +2086,8 @@ void EditorUI::renderContextMenu(EditorApp& app) { ImGui::Separator(); if (ImGui::MenuItem("Snap to Ground")) app.snapSelectedToGround(); + if (ImGui::MenuItem("Align to Slope")) + app.alignSelectedToTerrain(); if (ImGui::MenuItem("Fly To")) app.flyToSelected(); } diff --git a/tools/editor/terrain_editor.cpp b/tools/editor/terrain_editor.cpp index d05deb49..1cd20b63 100644 --- a/tools/editor/terrain_editor.cpp +++ b/tools/editor/terrain_editor.cpp @@ -223,6 +223,32 @@ std::vector TerrainEditor::getAffectedChunks(const glm::vec3& center, float return result; } +glm::vec3 TerrainEditor::sampleTerrainNormal(const glm::vec3& worldPos) const { + if (!terrain_) return glm::vec3(0, 0, 1); + + auto sampleH = [&](float x, float y) -> float { + rendering::Ray ray; + ray.origin = glm::vec3(x, y, 10000.0f); + ray.direction = glm::vec3(0, 0, -1); + glm::vec3 hit; + if (const_cast(this)->raycastTerrain(ray, hit)) + return hit.z; + return worldPos.z; + }; + + float step = 2.0f; + float hL = sampleH(worldPos.x - step, worldPos.y); + float hR = sampleH(worldPos.x + step, worldPos.y); + float hD = sampleH(worldPos.x, worldPos.y - step); + float hU = sampleH(worldPos.x, worldPos.y + step); + + glm::vec3 dx(2.0f * step, 0, hR - hL); + glm::vec3 dy(0, 2.0f * step, hU - hD); + glm::vec3 n = glm::normalize(glm::cross(dx, dy)); + if (n.z < 0) n = -n; + return n; +} + void TerrainEditor::beginStroke() { if (!terrain_ || strokeActive_) return; strokeActive_ = true; diff --git a/tools/editor/terrain_editor.hpp b/tools/editor/terrain_editor.hpp index 66d934c0..ada50149 100644 --- a/tools/editor/terrain_editor.hpp +++ b/tools/editor/terrain_editor.hpp @@ -30,6 +30,9 @@ public: // Raycast against terrain, returns true if hit bool raycastTerrain(const rendering::Ray& ray, glm::vec3& hitPos) const; + // Sample terrain normal at a world XY position (for object alignment) + glm::vec3 sampleTerrainNormal(const glm::vec3& worldPos) const; + // Apply brush at current position (call per-frame while painting) void applyBrush(float deltaTime);