feat(editor): terrain-aligned objects, batch convert, WCP import+load

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
This commit is contained in:
Kelsi 2026-05-05 14:22:21 -07:00
parent acb519a243
commit 115fe8436f
5 changed files with 136 additions and 0 deletions

View file

@ -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<char>(std::tolower(static_cast<unsigned char>(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);

View file

@ -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();

View file

@ -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();
}

View file

@ -223,6 +223,32 @@ std::vector<int> 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<TerrainEditor*>(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;

View file

@ -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);