mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-06 00:53:52 +00:00
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:
parent
acb519a243
commit
115fe8436f
5 changed files with 136 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue