feat(editor): multi-select objects, time-of-day lighting, WOT loading

- Multi-select: Ctrl+Shift+Click adds objects to selection, transforms
  (move/rotate/scale/delete) operate on all selected objects at once
- Time-of-day slider (0-24h) with automatic sun angle, light color,
  ambient, fog, and sky color transitions (dawn/day/dusk/night)
- View > Sky/Lighting menu: color pickers for light/ambient/fog, fog
  distance sliders, preset buttons (Dawn/Noon/Dusk/Night)
- loadADT prefers WOT/WHM open format from custom_zones/output dirs
- Selection count display when multiple objects selected
- setSkyPreset now delegates to setTimeOfDay for consistency
This commit is contained in:
Kelsi 2026-05-05 13:47:23 -07:00
parent d44eaec487
commit ddf97e9b8a
7 changed files with 147 additions and 30 deletions

View file

@ -428,15 +428,25 @@ void EditorApp::processEvents() {
showToast("Start point set — click terrain for end");
}
}
// Ctrl+click = select object (any mode)
// Ctrl+click = select (Ctrl+Shift+click = add to selection)
else if ((event.key.keysym.mod & KMOD_CTRL) || (SDL_GetModState() & KMOD_CTRL)) {
bool additive = (SDL_GetModState() & KMOD_SHIFT) != 0;
auto ext = window_->getVkContext()->getSwapchainExtent();
rendering::Ray ray = camera_.getCamera().screenToWorldRay(
static_cast<float>(event.button.x),
static_cast<float>(event.button.y),
static_cast<float>(ext.width),
static_cast<float>(ext.height));
objectPlacer_.selectAt(ray, 200.0f);
if (additive) {
int prevSel = objectPlacer_.getSelectedIndex();
int hit = objectPlacer_.selectAt(ray, 200.0f);
if (hit >= 0) {
if (prevSel >= 0) objectPlacer_.addToSelection(prevSel);
objectPlacer_.addToSelection(hit);
}
} else {
objectPlacer_.selectAt(ray, 200.0f);
}
} else if (mode_ == EditorMode::NPC) {
auto ext = window_->getVkContext()->getSwapchainExtent();
rendering::Ray ray = camera_.getCamera().screenToWorldRay(
@ -1049,18 +1059,9 @@ void EditorApp::updateToasts(float dt) {
void EditorApp::setSkyPreset(int preset) {
switch (preset) {
case 0: // Day
viewport_.setClearColor(0.4f, 0.6f, 0.9f);
viewport_.setLightDir(glm::normalize(glm::vec3(0.5f, -1.0f, 0.8f)));
break;
case 1: // Dusk
viewport_.setClearColor(0.6f, 0.3f, 0.2f);
viewport_.setLightDir(glm::normalize(glm::vec3(0.8f, -0.3f, 0.1f)));
break;
case 2: // Night
viewport_.setClearColor(0.05f, 0.05f, 0.12f);
viewport_.setLightDir(glm::normalize(glm::vec3(0.2f, -0.5f, 0.8f)));
break;
case 0: viewport_.setTimeOfDay(12.0f); break;
case 1: viewport_.setTimeOfDay(18.0f); break;
case 2: viewport_.setTimeOfDay(22.0f); break;
}
}

View file

@ -53,6 +53,7 @@ public:
NpcPresets& getNpcPresets() { return npcPresets_; }
QuestEditor& getQuestEditor() { return questEditor_; }
AssetBrowser& getAssetBrowser() { return assetBrowser_; }
EditorViewport& getViewport() { return viewport_; }
rendering::TerrainRenderer* getTerrainRenderer();
rendering::M2Renderer* getM2Renderer() { return viewport_.getM2Renderer(); }
pipeline::AssetManager* getAssetManager() { return assetManager_.get(); }

View file

@ -382,9 +382,21 @@ void EditorUI::renderMenuBar(EditorApp& app) {
if (ImGui::MenuItem("Center on Terrain", "Home")) app.centerOnTerrain();
ImGui::Separator();
if (ImGui::BeginMenu("Sky / Lighting")) {
if (ImGui::MenuItem("Day")) app.setSkyPreset(0);
if (ImGui::MenuItem("Dusk")) app.setSkyPreset(1);
if (ImGui::MenuItem("Night")) app.setSkyPreset(2);
auto& vp = app.getViewport();
float tod = vp.getTimeOfDay();
if (ImGui::SliderFloat("Time of Day", &tod, 0.0f, 24.0f, "%.1fh"))
vp.setTimeOfDay(tod);
ImGui::Separator();
if (ImGui::MenuItem("Dawn (6:30)")) vp.setTimeOfDay(6.5f);
if (ImGui::MenuItem("Noon (12:00)")) vp.setTimeOfDay(12.0f);
if (ImGui::MenuItem("Dusk (18:00)")) vp.setTimeOfDay(18.0f);
if (ImGui::MenuItem("Night (22:00)")) vp.setTimeOfDay(22.0f);
ImGui::Separator();
ImGui::ColorEdit3("Light", &vp.getLightColor().x, ImGuiColorEditFlags_Float);
ImGui::ColorEdit3("Ambient", &vp.getAmbientColor().x, ImGuiColorEditFlags_Float);
ImGui::ColorEdit3("Fog", &vp.getFogColor().x, ImGuiColorEditFlags_Float);
ImGui::SliderFloat("Fog Near", &vp.getFogNear(), 100.0f, 10000.0f);
ImGui::SliderFloat("Fog Far", &vp.getFogFar(), 500.0f, 20000.0f);
ImGui::EndMenu();
}
ImGui::Separator();
@ -1424,7 +1436,10 @@ void EditorUI::renderObjectPanel(EditorApp& app) {
}
if (auto* sel = placer.getSelected()) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 0.9f, 0.3f, 1));
ImGui::Text("Selected: %s", sel->path.c_str());
if (placer.isMultiSelected())
ImGui::Text("Selected: %zu objects", placer.selectionCount());
else
ImGui::Text("Selected: %s", sel->path.c_str());
ImGui::PopStyleColor();
bool changed = false;

View file

@ -730,6 +730,41 @@ void EditorViewport::destroyPerFrameResources() {
}
}
void EditorViewport::setTimeOfDay(float t) {
timeOfDay_ = std::clamp(t, 0.0f, 24.0f);
float hour = timeOfDay_;
// Sun angle: noon=overhead, 6am/6pm=horizon, night=below
float sunAngle = (hour - 6.0f) / 12.0f * 3.14159f;
lightDir_ = glm::normalize(glm::vec3(std::cos(sunAngle) * 0.5f, -1.0f, std::sin(sunAngle)));
// Dawn/dusk warm tones, noon white, night blue
if (hour >= 6.0f && hour <= 8.0f) {
float t2 = (hour - 6.0f) / 2.0f;
lightColor_ = glm::mix(glm::vec3(1.0f, 0.5f, 0.2f), glm::vec3(1.0f, 0.95f, 0.85f), t2);
ambientColor_ = glm::mix(glm::vec3(0.15f, 0.1f, 0.2f), glm::vec3(0.3f, 0.3f, 0.35f), t2);
fogColor_ = glm::mix(glm::vec3(0.5f, 0.3f, 0.3f), glm::vec3(0.6f, 0.7f, 0.8f), t2);
} else if (hour >= 17.0f && hour <= 19.0f) {
float t2 = (hour - 17.0f) / 2.0f;
lightColor_ = glm::mix(glm::vec3(1.0f, 0.95f, 0.85f), glm::vec3(1.0f, 0.4f, 0.15f), t2);
ambientColor_ = glm::mix(glm::vec3(0.3f, 0.3f, 0.35f), glm::vec3(0.1f, 0.08f, 0.15f), t2);
fogColor_ = glm::mix(glm::vec3(0.6f, 0.7f, 0.8f), glm::vec3(0.4f, 0.25f, 0.3f), t2);
} else if (hour < 6.0f || hour > 19.0f) {
lightColor_ = glm::vec3(0.15f, 0.15f, 0.25f);
ambientColor_ = glm::vec3(0.05f, 0.05f, 0.1f);
fogColor_ = glm::vec3(0.1f, 0.1f, 0.15f);
} else {
lightColor_ = glm::vec3(1.0f, 0.95f, 0.85f);
ambientColor_ = glm::vec3(0.3f, 0.3f, 0.35f);
fogColor_ = glm::vec3(0.6f, 0.7f, 0.8f);
}
// Sky/clear color follows fog
clearR_ = fogColor_.x * 0.7f;
clearG_ = fogColor_.y * 0.7f;
clearB_ = fogColor_.z * 0.7f;
}
void EditorViewport::updatePerFrameUBO() {
uint32_t frame = vkCtx_->getCurrentFrame();
@ -738,11 +773,11 @@ void EditorViewport::updatePerFrameUBO() {
data.projection = camera_->getProjectionMatrix();
data.lightSpaceMatrix = glm::mat4(1.0f);
data.lightDir = glm::vec4(lightDir_, 0.0f);
data.lightColor = glm::vec4(1.0f, 0.95f, 0.85f, 0.0f);
data.ambientColor = glm::vec4(0.3f, 0.3f, 0.35f, 0.0f);
data.lightColor = glm::vec4(lightColor_, 0.0f);
data.ambientColor = glm::vec4(ambientColor_, 0.0f);
data.viewPos = glm::vec4(camera_->getPosition(), 0.0f);
data.fogColor = glm::vec4(0.6f, 0.7f, 0.8f, 0.0f);
data.fogParams = glm::vec4(5000.0f, 10000.0f, 0.0f, 0.0f);
data.fogColor = glm::vec4(fogColor_, 0.0f);
data.fogParams = glm::vec4(fogNear_, fogFar_, 0.0f, 0.0f);
data.shadowParams = glm::vec4(0.0f, 0.0f, 0.0f, 0.0f);
std::memcpy(perFrameUBOMapped_[frame], &data, sizeof(data));

View file

@ -62,6 +62,14 @@ public:
void setLightDir(const glm::vec3& d) { lightDir_ = d; }
glm::vec3 getLightDir() const { return lightDir_; }
void setTimeOfDay(float t);
float getTimeOfDay() const { return timeOfDay_; }
glm::vec3& getLightColor() { return lightColor_; }
glm::vec3& getAmbientColor() { return ambientColor_; }
glm::vec3& getFogColor() { return fogColor_; }
float& getFogNear() { return fogNear_; }
float& getFogFar() { return fogFar_; }
rendering::TerrainRenderer* getTerrainRenderer() { return terrainRenderer_.get(); }
rendering::M2Renderer* getM2Renderer() { return m2Renderer_.get(); }
@ -95,6 +103,11 @@ private:
bool wireframe_ = false;
float clearR_ = 0.15f, clearG_ = 0.15f, clearB_ = 0.2f;
glm::vec3 lightDir_ = glm::normalize(glm::vec3(0.5f, -1.0f, 0.3f));
glm::vec3 lightColor_ = glm::vec3(1.0f, 0.95f, 0.85f);
glm::vec3 ambientColor_ = glm::vec3(0.3f, 0.3f, 0.35f);
glm::vec3 fogColor_ = glm::vec3(0.6f, 0.7f, 0.8f);
float fogNear_ = 5000.0f, fogFar_ = 10000.0f;
float timeOfDay_ = 12.0f;
// Ghost preview state
std::string ghostModelPath_;

View file

@ -75,7 +75,32 @@ int ObjectPlacer::selectAt(const rendering::Ray& ray, float maxDist) {
return bestIdx;
}
void ObjectPlacer::addToSelection(int idx) {
if (idx < 0 || idx >= static_cast<int>(objects_.size())) return;
for (int si : selectedIndices_) { if (si == idx) return; }
selectedIndices_.push_back(idx);
objects_[idx].selected = true;
selectedIdx_ = idx;
}
void ObjectPlacer::toggleSelection(int idx) {
if (idx < 0 || idx >= static_cast<int>(objects_.size())) return;
auto it = std::find(selectedIndices_.begin(), selectedIndices_.end(), idx);
if (it != selectedIndices_.end()) {
objects_[idx].selected = false;
selectedIndices_.erase(it);
selectedIdx_ = selectedIndices_.empty() ? -1 : selectedIndices_.back();
} else {
addToSelection(idx);
}
}
void ObjectPlacer::clearSelection() {
for (int idx : selectedIndices_) {
if (idx >= 0 && idx < static_cast<int>(objects_.size()))
objects_[idx].selected = false;
}
selectedIndices_.clear();
if (selectedIdx_ >= 0 && selectedIdx_ < static_cast<int>(objects_.size()))
objects_[selectedIdx_].selected = false;
selectedIdx_ = -1;
@ -87,22 +112,43 @@ PlacedObject* ObjectPlacer::getSelected() {
}
void ObjectPlacer::moveSelected(const glm::vec3& delta) {
if (auto* obj = getSelected()) obj->position += delta;
if (selectedIndices_.size() > 1) {
for (int idx : selectedIndices_) objects_[idx].position += delta;
} else if (auto* obj = getSelected()) {
obj->position += delta;
}
}
void ObjectPlacer::rotateSelected(const glm::vec3& deltaDeg) {
if (auto* obj = getSelected()) obj->rotation += deltaDeg;
if (selectedIndices_.size() > 1) {
for (int idx : selectedIndices_) objects_[idx].rotation += deltaDeg;
} else if (auto* obj = getSelected()) {
obj->rotation += deltaDeg;
}
}
void ObjectPlacer::scaleSelected(float delta) {
if (auto* obj = getSelected())
if (selectedIndices_.size() > 1) {
for (int idx : selectedIndices_)
objects_[idx].scale = std::max(0.1f, objects_[idx].scale + delta);
} else if (auto* obj = getSelected()) {
obj->scale = std::max(0.1f, obj->scale + delta);
}
}
void ObjectPlacer::deleteSelected() {
if (selectedIdx_ < 0 || selectedIdx_ >= static_cast<int>(objects_.size())) return;
objects_.erase(objects_.begin() + selectedIdx_);
selectedIdx_ = -1;
if (!selectedIndices_.empty()) {
std::sort(selectedIndices_.begin(), selectedIndices_.end(), std::greater<int>());
for (int idx : selectedIndices_) {
if (idx >= 0 && idx < static_cast<int>(objects_.size()))
objects_.erase(objects_.begin() + idx);
}
selectedIndices_.clear();
selectedIdx_ = -1;
} else if (selectedIdx_ >= 0 && selectedIdx_ < static_cast<int>(objects_.size())) {
objects_.erase(objects_.begin() + selectedIdx_);
selectedIdx_ = -1;
}
}
void ObjectPlacer::scatter(const glm::vec3& center, float radius, int count,

View file

@ -34,13 +34,18 @@ public:
// Place object at world position
void placeObject(const glm::vec3& position);
// Select object nearest to ray
// Select object nearest to ray (Shift adds to selection)
int selectAt(const rendering::Ray& ray, float maxDist = 50.0f);
void addToSelection(int idx);
void toggleSelection(int idx);
void clearSelection();
int getSelectedIndex() const { return selectedIdx_; }
PlacedObject* getSelected();
const std::vector<int>& getSelectedIndices() const { return selectedIndices_; }
size_t selectionCount() const { return selectedIndices_.size(); }
bool isMultiSelected() const { return selectedIndices_.size() > 1; }
// Transform selected
// Transform selected (operates on all selected objects)
void moveSelected(const glm::vec3& delta);
void rotateSelected(const glm::vec3& deltaDeg);
void scaleSelected(float delta);
@ -85,6 +90,7 @@ private:
std::vector<PlacedObject> objects_;
std::vector<int> undoStack_; // indices of recently placed objects
int selectedIdx_ = -1;
std::vector<int> selectedIndices_;
uint32_t uniqueIdCounter_ = 1;
float placementRotY_ = 0.0f;
float placementScale_ = 1.0f;