Files
2026-10-25 03:29:04 -03:00

1132 lines
56 KiB
C++

#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include "editor.h"
#include "imgui_stdlib.h"
#include "rlImGui.h"
#ifdef IMGUI_HAS_DOCK
#include "imgui_internal.h"
#endif
namespace hpp {
namespace fs = std::filesystem;
static const ImVec4 kAccent = ImVec4(0.95f, 0.50f, 0.17f, 1.0f);
void applyTheme() {
ImGuiStyle& st = ImGui::GetStyle();
st.WindowRounding = 4; st.ChildRounding = 4; st.FrameRounding = 4; st.PopupRounding = 4;
st.ScrollbarRounding = 6; st.GrabRounding = 3; st.TabRounding = 4;
st.WindowPadding = ImVec2(8, 8); st.FramePadding = ImVec2(8, 5); st.ItemSpacing = ImVec2(8, 6);
st.WindowBorderSize = 1; st.FrameBorderSize = 0; st.ScrollbarSize = 13;
ImVec4* c = st.Colors;
c[ImGuiCol_Text] = ImVec4(0.88f, 0.90f, 0.94f, 1); c[ImGuiCol_TextDisabled] = ImVec4(0.50f, 0.54f, 0.62f, 1);
c[ImGuiCol_WindowBg] = ImVec4(0.094f, 0.102f, 0.122f, 1); c[ImGuiCol_ChildBg] = ImVec4(0.082f, 0.090f, 0.108f, 1);
c[ImGuiCol_PopupBg] = ImVec4(0.110f, 0.122f, 0.145f, 0.98f);
c[ImGuiCol_Border] = ImVec4(0.20f, 0.22f, 0.27f, 1); c[ImGuiCol_BorderShadow] = ImVec4(0, 0, 0, 0);
c[ImGuiCol_FrameBg] = ImVec4(0.135f, 0.150f, 0.180f, 1); c[ImGuiCol_FrameBgHovered] = ImVec4(0.17f, 0.19f, 0.23f, 1);
c[ImGuiCol_FrameBgActive] = ImVec4(0.20f, 0.22f, 0.27f, 1);
c[ImGuiCol_TitleBg] = ImVec4(0.075f, 0.082f, 0.098f, 1); c[ImGuiCol_TitleBgActive] = ImVec4(0.105f, 0.115f, 0.140f, 1);
c[ImGuiCol_TitleBgCollapsed] = ImVec4(0.075f, 0.082f, 0.098f, 1); c[ImGuiCol_MenuBarBg] = ImVec4(0.075f, 0.082f, 0.098f, 1);
c[ImGuiCol_ScrollbarBg] = ImVec4(0.07f, 0.08f, 0.09f, 1); c[ImGuiCol_ScrollbarGrab] = ImVec4(0.22f, 0.25f, 0.30f, 1);
c[ImGuiCol_CheckMark] = kAccent; c[ImGuiCol_SliderGrab] = kAccent; c[ImGuiCol_SliderGrabActive] = ImVec4(1.0f, 0.62f, 0.28f, 1);
c[ImGuiCol_Button] = ImVec4(0.16f, 0.18f, 0.22f, 1); c[ImGuiCol_ButtonHovered] = ImVec4(0.23f, 0.26f, 0.32f, 1);
c[ImGuiCol_ButtonActive] = ImVec4(0.93f, 0.48f, 0.16f, 1);
c[ImGuiCol_Header] = ImVec4(0.18f, 0.20f, 0.25f, 1); c[ImGuiCol_HeaderHovered] = ImVec4(0.25f, 0.28f, 0.35f, 1);
c[ImGuiCol_HeaderActive] = ImVec4(0.80f, 0.42f, 0.15f, 1);
c[ImGuiCol_Separator] = ImVec4(0.20f, 0.22f, 0.27f, 1);
c[ImGuiCol_Tab] = ImVec4(0.11f, 0.12f, 0.15f, 1); c[ImGuiCol_TabHovered] = ImVec4(0.80f, 0.42f, 0.15f, 1);
#if IMGUI_VERSION_NUM >= 19090
c[ImGuiCol_TabSelected] = ImVec4(0.18f, 0.20f, 0.25f, 1);
#else
c[ImGuiCol_TabActive] = ImVec4(0.18f, 0.20f, 0.25f, 1);
#endif
c[ImGuiCol_ResizeGrip] = ImVec4(0.2f, 0.22f, 0.27f, 1); c[ImGuiCol_ResizeGripHovered] = kAccent;
#ifdef IMGUI_HAS_DOCK
c[ImGuiCol_DockingPreview] = ImVec4(0.95f, 0.50f, 0.17f, 0.45f);
#endif
}
// ============================================================ file dialog
static std::string lowerStr(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char ch) { return (char)std::tolower(ch); });
return s;
}
void FileDialog::open(const std::string& title, const std::string& startDir, std::vector<std::string> exts, bool save, Callback cb,
const std::string& defaultName) {
title_ = title;
exts_ = std::move(exts);
save_ = save;
cb_ = std::move(cb);
name_ = defaultName;
std::error_code ec;
fs::path p = startDir.empty() ? fs::current_path(ec) : fs::path(startDir);
if (!fs::is_directory(p, ec)) p = fs::current_path(ec);
dir_ = p.string();
pending_ = true;
}
void FileDialog::draw() {
if (pending_) {
ImGui::OpenPopup(title_.c_str());
pending_ = false;
}
ImGui::SetNextWindowSize(ImVec2(660, 440), ImGuiCond_Appearing);
bool open = true;
if (!ImGui::BeginPopupModal(title_.c_str(), &open)) return;
ImGui::SetNextItemWidth(-64);
ImGui::InputText("##dir", &dir_);
ImGui::SameLine();
if (ImGui::Button("Up")) {
fs::path p(dir_);
if (p.has_parent_path() && p.parent_path() != p) dir_ = p.parent_path().string();
}
std::vector<std::string> dirs, files;
try {
std::error_code ec;
for (fs::directory_iterator it(dir_, fs::directory_options::skip_permission_denied, ec), end; !ec && it != end; it.increment(ec)) {
std::string name = it->path().filename().string();
if (name.empty() || name[0] == '.') continue;
std::error_code e2;
if (it->is_directory(e2)) {
dirs.push_back(name);
} else {
std::string ext = lowerStr(it->path().extension().string());
bool ok = exts_.empty() || std::find(exts_.begin(), exts_.end(), ext) != exts_.end();
if (ok) files.push_back(name);
}
}
} catch (...) {
}
std::sort(dirs.begin(), dirs.end(), [](const std::string& a, const std::string& b) { return lowerStr(a) < lowerStr(b); });
std::sort(files.begin(), files.end(), [](const std::string& a, const std::string& b) { return lowerStr(a) < lowerStr(b); });
bool confirm = false;
ImGui::BeginChild("##files", ImVec2(0, -ImGui::GetFrameHeightWithSpacing() * 2.2f), true);
for (const std::string& d : dirs) {
if (ImGui::Selectable(("[dir] " + d).c_str(), false, ImGuiSelectableFlags_AllowDoubleClick) && ImGui::IsMouseDoubleClicked(0)) {
dir_ = (fs::path(dir_) / d).string();
break;
}
}
for (const std::string& f : files) {
if (ImGui::Selectable(f.c_str(), name_ == f, ImGuiSelectableFlags_AllowDoubleClick)) {
name_ = f;
if (ImGui::IsMouseDoubleClicked(0)) confirm = true;
}
}
ImGui::EndChild();
ImGui::SetNextItemWidth(-190);
if (ImGui::InputText("##name", &name_, ImGuiInputTextFlags_EnterReturnsTrue)) confirm = true;
ImGui::SameLine();
if (ImGui::Button(save_ ? "Save" : "Open", ImVec2(80, 0))) confirm = true;
ImGui::SameLine();
bool cancel = ImGui::Button("Cancel", ImVec2(80, 0));
if (confirm && !name_.empty()) {
fs::path full = fs::path(dir_) / name_;
if (save_ && full.extension().empty() && !exts_.empty()) full += exts_[0];
Callback cb = cb_;
ImGui::CloseCurrentPopup();
ImGui::EndPopup();
if (cb) cb(full.string());
return;
}
if (cancel) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
// ============================================================ helpers
void firstPlace(float fx, float fy, float fw, float fh) { // initial window rectangle (fractions of the work area)
ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + fx * vp->WorkSize.x, vp->WorkPos.y + fy * vp->WorkSize.y), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(fw * vp->WorkSize.x, fh * vp->WorkSize.y), ImGuiCond_FirstUseEver);
}
static std::string dirOf(const std::string& path) {
if (path.empty()) return "";
return fs::path(path).parent_path().string();
}
void Editor::guarded(std::function<void()> act) {
if (!dirty) { act(); return; }
pendingAction = std::move(act);
askDiscard = true;
}
void Editor::scanAssets() {
modelFiles.clear();
textureFiles.clear();
std::error_code ec;
auto scan = [&](const char* sub, const std::vector<std::string>& exts, std::vector<std::string>& out) {
fs::path base = fs::path(assetRoot) / sub;
if (!fs::is_directory(base, ec)) return;
for (fs::recursive_directory_iterator it(base, fs::directory_options::skip_permission_denied, ec), end; !ec && it != end; it.increment(ec)) {
if (!it->is_regular_file(ec)) continue;
std::string ext = lowerStr(it->path().extension().string());
if (std::find(exts.begin(), exts.end(), ext) == exts.end()) continue;
out.push_back(fs::relative(it->path(), assetRoot, ec).generic_string());
}
std::sort(out.begin(), out.end());
};
scan("models", {".obj", ".gltf", ".glb", ".iqm", ".vox", ".m3d"}, modelFiles);
scan("materials", {".png", ".jpg", ".jpeg", ".tga", ".bmp"}, textureFiles);
filesScanned = true;
}
std::string Editor::relativeToAssets(const std::string& path) const {
std::error_code ec;
fs::path rel = fs::relative(path, assetRoot, ec);
std::string r = rel.generic_string();
if (ec || r.empty() || r.rfind("..", 0) == 0) return fs::path(path).generic_string();
return r;
}
void Editor::openModelPicker(std::function<void(const std::string&)> cb) {
fileDialog.open("Choose model", assetRoot + "/models", {".obj", ".gltf", ".glb", ".iqm", ".vox", ".m3d"}, false,
[this, cb](const std::string& p) { cb(relativeToAssets(p)); });
}
// ============================================================ frame
void Editor::frame() {
if (!ImGui::IsAnyItemActive()) inEdit = false;
if (!filesScanned) scanAssets();
scene();
drawUI();
}
void Editor::buildLayout(ImGuiID dock) {
#ifdef IMGUI_HAS_DOCK
ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::DockBuilderRemoveNode(dock);
ImGui::DockBuilderAddNode(dock, ImGuiDockNodeFlags_DockSpace);
ImGui::DockBuilderSetNodeSize(dock, vp->WorkSize);
ImGuiID mainId = dock, leftId, rightId, bottomId, colL, colR, tl, bl, tr, br;
leftId = ImGui::DockBuilderSplitNode(mainId, ImGuiDir_Left, 0.17f, nullptr, &mainId);
rightId = ImGui::DockBuilderSplitNode(mainId, ImGuiDir_Right, 0.23f, nullptr, &mainId);
bottomId = ImGui::DockBuilderSplitNode(mainId, ImGuiDir_Down, 0.25f, nullptr, &mainId);
colL = ImGui::DockBuilderSplitNode(mainId, ImGuiDir_Left, 0.5f, nullptr, &colR);
tl = ImGui::DockBuilderSplitNode(colL, ImGuiDir_Up, 0.5f, nullptr, &bl);
tr = ImGui::DockBuilderSplitNode(colR, ImGuiDir_Up, 0.5f, nullptr, &br);
ImGui::DockBuilderDockWindow("3D", tl);
ImGui::DockBuilderDockWindow("Top", tr);
ImGui::DockBuilderDockWindow("Front", bl);
ImGui::DockBuilderDockWindow("Side", br);
ImGui::DockBuilderDockWindow("Outliner", leftId);
ImGui::DockBuilderDockWindow("Inspector", rightId);
ImGui::DockBuilderDockWindow("Assets", bottomId);
ImGui::DockBuilderDockWindow("Log", bottomId);
ImGui::DockBuilderFinish(dock);
#else
(void)dock;
#endif
}
void Editor::drawUI() {
ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(vp->WorkPos);
ImGui::SetNextWindowSize(vp->WorkSize);
ImGuiWindowFlags hostFlags = ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus |
ImGuiWindowFlags_NoNavFocus | ImGuiWindowFlags_NoScrollbar;
#ifdef IMGUI_HAS_DOCK
hostFlags |= ImGuiWindowFlags_NoDocking;
#endif
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::Begin("##host", nullptr, hostFlags);
ImGui::PopStyleVar(3);
drawMenu();
drawToolbar();
#ifdef IMGUI_HAS_DOCK
ImGuiID dock = ImGui::GetID("hpp_dockspace");
if (resetLayout || ImGui::DockBuilderGetNode(dock) == nullptr) {
buildLayout(dock);
resetLayout = false;
}
ImGui::DockSpace(dock, ImVec2(0, 0), ImGuiDockNodeFlags_None);
#endif
ImGui::End();
shortcuts();
for (Viewport& v : views) drawViewport(v);
drawOutliner();
drawInspector();
drawAssets();
drawLog();
drawHelp();
fileDialog.draw();
if (showDemo) ImGui::ShowDemoWindow(&showDemo);
if (askDiscard) {
ImGui::OpenPopup("Unsaved changes");
askDiscard = false;
}
if (ImGui::BeginPopupModal("Unsaved changes", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text("The map has unsaved changes. Discard them?");
if (ImGui::Button("Discard", ImVec2(110, 0))) {
ImGui::CloseCurrentPopup();
auto act = std::move(pendingAction);
pendingAction = nullptr;
if (act) act();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(110, 0))) {
ImGui::CloseCurrentPopup();
pendingAction = nullptr;
}
ImGui::EndPopup();
}
}
// ============================================================ menu + toolbar
void Editor::drawMenu() {
if (!ImGui::BeginMenuBar()) return;
auto startDir = [&]() { return docPath.empty() ? std::string() : dirOf(docPath); };
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("New map", "Ctrl+N")) guarded([this]() { newMap(false); });
if (ImGui::MenuItem("New demo map")) guarded([this]() { newMap(true); });
if (ImGui::MenuItem("Open...", "Ctrl+O"))
guarded([this, startDir]() {
fileDialog.open("Open map", startDir(), {".hmap"}, false, [this](const std::string& p) { openMap(p); });
});
if (ImGui::MenuItem("Save", "Ctrl+S")) {
if (docPath.empty()) fileDialog.open("Save map as", startDir(), {".hmap"}, true, [this](const std::string& p) { saveMap(p); }, "untitled.hmap");
else saveMap(docPath);
}
if (ImGui::MenuItem("Save as...", "Ctrl+Shift+S"))
fileDialog.open("Save map as", startDir(), {".hmap"}, true, [this](const std::string& p) { saveMap(p); }, "untitled.hmap");
ImGui::MenuItem("Embed compiled data when saving", nullptr, &saveCompiledToo);
ImGui::Separator();
if (ImGui::BeginMenu("Import")) {
if (ImGui::MenuItem("OBJ as editable mesh..."))
fileDialog.open("Import OBJ", assetRoot + "/models", {".obj"}, false, [this](const std::string& p) { importObjFile(p); });
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Export")) {
if (ImGui::MenuItem("FBX (.fbx)..."))
fileDialog.open("Export FBX", startDir(), {".fbx"}, true, [this](const std::string& p) { exportFbx(p); }, "map.fbx");
if (ImGui::MenuItem("OBJ (.obj)..."))
fileDialog.open("Export OBJ", startDir(), {".obj"}, true, [this](const std::string& p) { exportObj(p); }, "map.obj");
if (ImGui::MenuItem("Runtime map (.hmap, compiled only)..."))
fileDialog.open("Export runtime map", startDir(), {".hmap"}, true, [this](const std::string& p) { exportCompiled(p); }, "map_runtime.hmap");
ImGui::Separator();
ImGui::TextDisabled("Export options");
ImGui::SetNextItemWidth(110);
ImGui::DragFloat("Scale", &fbxOpt.scale, 0.001f, 0.0001f, 1000.0f, "%.4f");
ImGui::MenuItem("Entities as null nodes", nullptr, &fbxOpt.entities);
ImGui::MenuItem("Baked vertex colours", nullptr, &fbxOpt.vertexColors);
ImGui::MenuItem("Bake lighting before export", nullptr, &compileOpt.bakeLighting);
ImGui::EndMenu();
}
ImGui::Separator();
if (ImGui::MenuItem("Quit")) guarded([this]() { wantQuit = true; });
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Edit")) {
if (ImGui::MenuItem("Undo", "Ctrl+Z", false, !undoStack.empty())) undo();
if (ImGui::MenuItem("Redo", "Ctrl+Y", false, !redoStack.empty())) redo();
ImGui::Separator();
if (ImGui::MenuItem("Duplicate", "Ctrl+D", false, !sel.empty())) duplicateSelection();
if (ImGui::MenuItem("Delete", "Del", false, !sel.empty())) deleteSelection();
if (ImGui::MenuItem("Select all", "Ctrl+A")) selectAll();
if (ImGui::MenuItem("Deselect", "Esc", false, !sel.empty())) clearSelection();
ImGui::Separator();
if (ImGui::MenuItem("Hide selected", "H", false, !sel.empty())) hideSelection(true);
if (ImGui::MenuItem("Unhide all", "Alt+H")) unhideAll();
ImGui::Separator();
if (ImGui::MenuItem("Convert brushes to mesh", nullptr, false, !sel.empty())) convertSelectionToMesh();
if (ImGui::BeginMenu("Tie to entity", !sel.empty())) {
for (const hmap::EntityClass& c : entdefs.classes)
if (c.brush && ImGui::MenuItem((c.label + " (" + c.name + ")").c_str())) tieSelectionToEntity(c.name);
ImGui::EndMenu();
}
if (ImGui::MenuItem("Untie from entity", nullptr, false, !sel.empty())) untieSelection();
if (ImGui::MenuItem("Flip mesh normals", nullptr, false, !sel.empty())) flipSelection();
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Create")) {
static const char* names[] = {"Box", "Wedge (ramp)", "Cylinder", "Cone", "Stairs", "Sphere (mesh)", "Torus (mesh)", "Terrain patch (mesh)"};
for (int i = 0; i < 8; ++i)
if (ImGui::MenuItem(names[i], nullptr, (int)shape == i)) { shape = (Shape)i; tool = Tool::Block; }
ImGui::Separator();
ImGui::MenuItem("Create as editable mesh", nullptr, &createAsMesh);
ImGui::SetNextItemWidth(120);
ImGui::SliderInt("Sides", &shapeSides, 3, 48);
ImGui::SetNextItemWidth(120);
ImGui::SliderInt("Stair steps", &stairSteps, 2, 32);
ImGui::SetNextItemWidth(120);
ImGui::DragFloat("Default height", &blockHeight, 1.0f, 8.0f, 4096.0f, "%.0f");
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Map")) {
if (ImGui::MenuItem("Build + preview", "F9")) compileNow();
ImGui::MenuItem("Bake vertex lighting", nullptr, &compileOpt.bakeLighting);
ImGui::MenuItem("Shadows", nullptr, &compileOpt.shadows);
ImGui::MenuItem("CSG (remove hidden faces)", nullptr, &compileOpt.csg);
ImGui::SetNextItemWidth(110);
ImGui::DragFloat("Light grid", &compileOpt.lightEdge, 1.0f, 8.0f, 256.0f, "%.0f units");
if (hasCompiled && ImGui::MenuItem("Discard build")) { hasCompiled = false; compiledStale = false; if (shading == Shading::Baked) shading = Shading::Lit; }
ImGui::EndMenu();
}
if (ImGui::BeginMenu("View")) {
ImGui::MenuItem("3D", nullptr, &views[0].open);
ImGui::MenuItem("Top", nullptr, &views[1].open);
ImGui::MenuItem("Front", nullptr, &views[2].open);
ImGui::MenuItem("Side", nullptr, &views[3].open);
ImGui::Separator();
ImGui::MenuItem("Outliner", nullptr, &panelOutliner);
ImGui::MenuItem("Inspector", nullptr, &panelInspector);
ImGui::MenuItem("Assets", nullptr, &panelAssets);
ImGui::MenuItem("Log", nullptr, &panelLog);
ImGui::Separator();
static const char* sh[] = {"Wireframe", "Flat", "Lit (dynamic)", "Baked (after Build)"};
for (int i = 0; i < 4; ++i)
if (ImGui::MenuItem(sh[i], nullptr, (int)shading == i)) shading = (Shading)i;
ImGui::Separator();
ImGui::MenuItem("Snap to grid", "G", &snapOn);
if (ImGui::MenuItem("Focus selection", "F")) focusSelection();
if (ImGui::MenuItem("Reset window layout")) resetLayout = true;
ImGui::MenuItem("Dear ImGui demo", nullptr, &showDemo);
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Help")) {
if (ImGui::MenuItem("Controls", "F1")) showHelp = true;
ImGui::EndMenu();
}
ImGui::SameLine(ImGui::GetWindowWidth() - 330);
ImGui::TextDisabled("%s%s", docPath.empty() ? "untitled" : fs::path(docPath).filename().string().c_str(), dirty ? " *" : "");
ImGui::EndMenuBar();
}
void Editor::drawToolbar() {
ImGui::BeginChild("##toolbar", ImVec2(0, 42), false, ImGuiWindowFlags_NoScrollbar);
ImGui::SetCursorPos(ImVec2(8, 7));
auto toggle = [&](const char* label, bool on, const char* tip) {
if (on) {
ImGui::PushStyleColor(ImGuiCol_Button, kAccent);
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.09f, 0.06f, 0.03f, 1));
}
bool pressed = ImGui::Button(label);
if (on) ImGui::PopStyleColor(2);
if (tip && ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip);
return pressed;
};
if (toggle("Select", tool == Tool::Select, "Select, move, resize (1)")) tool = Tool::Select;
ImGui::SameLine();
if (toggle("Block", tool == Tool::Block, "Create geometry (2)")) tool = Tool::Block;
ImGui::SameLine();
if (toggle("Entity", tool == Tool::Entity, "Place entities and props (3)")) tool = Tool::Entity;
ImGui::SameLine();
if (toggle("Clip", tool == Tool::Clip, "Cut brushes with a plane (4)")) tool = Tool::Clip;
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
if (toggle("Object", selMode == SelMode::Object, "Select whole objects")) selMode = SelMode::Object;
ImGui::SameLine();
if (toggle("Face", selMode == SelMode::Face, "Select faces to edit textures (3D view)")) selMode = SelMode::Face;
ImGui::SameLine();
if (toggle("Vertex", selMode == SelMode::Vertex, "Edit mesh vertices")) selMode = SelMode::Vertex;
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
ImGui::Checkbox("Snap", &snapOn);
ImGui::SameLine();
static const char* gridNames[] = {"1", "2", "4", "8", "16", "32", "64", "128", "256"};
static const float gridVals[] = {1, 2, 4, 8, 16, 32, 64, 128, 256};
int gi = 4;
for (int i = 0; i < 9; ++i)
if (std::fabs(gridVals[i] - grid) < 0.01f) gi = i;
ImGui::SetNextItemWidth(64);
if (ImGui::Combo("##grid", &gi, gridNames, 9)) grid = gridVals[gi];
ImGui::SameLine();
static const char* shadeNames[] = {"Wireframe", "Flat", "Lit", "Baked"};
int sh = (int)shading;
ImGui::SetNextItemWidth(104);
if (ImGui::Combo("##shading", &sh, shadeNames, 4)) shading = (Shading)sh;
ImGui::SameLine();
if (ImGui::Button("Build F9")) compileNow();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("CSG + BSP + lighting preview (\"Baked\" shading)");
ImGui::SameLine();
ImGui::Checkbox("Bake light", &compileOpt.bakeLighting);
ImGui::SameLine();
const char* hint = "";
switch (tool) {
case Tool::Select: hint = "Click or drag to select | drag to move | handles resize | Shift adds"; break;
case Tool::Block: hint = "Drag in a 2D view, or click a surface in 3D to place the chosen shape"; break;
case Tool::Entity: hint = "Click to place the entity chosen in Assets > Entities"; break;
case Tool::Clip: hint = "Click two points in a 2D view | Tab: side | Enter: cut | Esc: cancel"; break;
}
ImGui::TextDisabled("%s", hint);
ImGui::EndChild();
}
// ============================================================ shortcuts
void Editor::shortcuts() {
ImGuiIO& io = ImGui::GetIO();
if (io.WantTextInput) return;
auto pressed = [&](ImGuiKey k) { return ImGui::IsKeyPressed(k, false); };
bool ctrl = io.KeyCtrl, shift = io.KeyShift, alt = io.KeyAlt;
auto startDir = [&]() { return docPath.empty() ? std::string() : dirOf(docPath); };
if (ctrl && pressed(ImGuiKey_Z)) { if (shift) redo(); else undo(); }
if (ctrl && pressed(ImGuiKey_Y)) redo();
if (ctrl && pressed(ImGuiKey_S)) {
if (shift || docPath.empty()) fileDialog.open("Save map as", startDir(), {".hmap"}, true, [this](const std::string& p) { saveMap(p); }, "untitled.hmap");
else saveMap(docPath);
}
if (ctrl && pressed(ImGuiKey_O))
guarded([this, startDir]() { fileDialog.open("Open map", startDir(), {".hmap"}, false, [this](const std::string& p) { openMap(p); }); });
if (ctrl && pressed(ImGuiKey_N)) guarded([this]() { newMap(false); });
if (ctrl && pressed(ImGuiKey_D)) duplicateSelection();
if (ctrl && pressed(ImGuiKey_A)) selectAll();
if (ctrl && pressed(ImGuiKey_T)) {
const hmap::EntityClass* c = entdefs.find(curEntity);
tieSelectionToEntity(c && c->brush ? curEntity : "func_detail");
}
if (pressed(ImGuiKey_Delete)) deleteSelection();
if (pressed(ImGuiKey_F9)) compileNow();
if (pressed(ImGuiKey_F1)) showHelp = !showHelp;
if (ctrl) return;
if (pressed(ImGuiKey_1)) tool = Tool::Select;
if (pressed(ImGuiKey_2)) tool = Tool::Block;
if (pressed(ImGuiKey_3)) tool = Tool::Entity;
if (pressed(ImGuiKey_4)) tool = Tool::Clip;
if (pressed(ImGuiKey_H)) { if (alt) unhideAll(); else if (!sel.empty()) hideSelection(true); }
if (pressed(ImGuiKey_F)) focusSelection();
if (pressed(ImGuiKey_G)) snapOn = !snapOn;
if (pressed(ImGuiKey_LeftBracket)) grid = std::max(1.0f, grid * 0.5f);
if (pressed(ImGuiKey_RightBracket)) grid = std::min(512.0f, grid * 2.0f);
if (pressed(ImGuiKey_Tab) && tool == Tool::Clip) clipMode = (clipMode + 1) % 3;
if (pressed(ImGuiKey_Enter) && tool == Tool::Clip && clipHasA && clipHasB) {
int da = views[clipView].depthAxis();
Vec3 depthDir(0, 0, 0);
depthDir[da] = 1.0f;
Vec3 n = hmap::normalize(hmap::cross(clipB - clipA, depthDir));
if (hmap::length(n) > 0.5f) clipSelection(Plane::fromNormalPoint(n, clipA), clipMode);
clipHasA = clipHasB = false;
}
if (pressed(ImGuiKey_Escape)) {
if (drag.kind != DragState::None) {
if (drag.kind == DragState::Move || drag.kind == DragState::Resize || drag.kind == DragState::Gizmo) {
restoreSnap(drag.snap);
if (drag.tookUndo && !undoStack.empty()) undoStack.pop_back();
touch();
}
drag = DragState();
} else if (clipHasA) {
clipHasA = clipHasB = false;
} else if (tool != Tool::Select) {
tool = Tool::Select;
} else {
clearSelection();
}
}
}
// ============================================================ outliner
void Editor::drawOutliner() {
if (!panelOutliner) return;
firstPlace(0.0f, 0.04f, 0.17f, 0.76f);
if (!ImGui::Begin("Outliner", &panelOutliner)) { ImGui::End(); return; }
ImGuiIO& io = ImGui::GetIO();
static ImGuiTextFilter filter;
filter.Draw("##filter", -1);
bool add = io.KeyCtrl || io.KeyShift;
auto row = [&](const ObjRef& ref, const std::string& label, bool hidden) {
bool s = sel.has(ref);
ImGui::PushID((int)ref.id * 4 + (int)ref.type);
if (hidden) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
if (ImGui::Selectable(label.c_str(), s, ImGuiSelectableFlags_AllowDoubleClick)) {
select(ref, add);
if (ImGui::IsMouseDoubleClicked(0)) focusSelection();
}
if (hidden) ImGui::PopStyleColor();
if (ImGui::BeginPopupContextItem()) {
if (!sel.has(ref)) select(ref, false);
if (ImGui::MenuItem(hidden ? "Show" : "Hide")) hideSelection(!hidden);
if (ImGui::MenuItem("Duplicate")) duplicateSelection();
if (ImGui::MenuItem("Delete")) deleteSelection();
ImGui::EndPopup();
}
ImGui::PopID();
};
char buf[128];
if (ImGui::CollapsingHeader(("Brushes (" + std::to_string(doc.brushes.size()) + ")").c_str(), ImGuiTreeNodeFlags_DefaultOpen)) {
auto label = [&](const hmap::Brush& b) {
const hmap::Entity* o = b.owner ? doc.findEntity(b.owner) : nullptr;
std::snprintf(buf, sizeof buf, "Brush %u (%zu faces)%s%s", b.id, b.faces.size(), o ? " -> " : "", o ? o->classname.c_str() : "");
return std::string(buf);
};
if (filter.IsActive()) {
for (const hmap::Brush& b : doc.brushes) { std::string l = label(b); if (filter.PassFilter(l.c_str())) row({ObjType::Brush, b.id}, l, b.flags & hmap::OBJ_HIDDEN); }
} else {
ImGuiListClipper clip;
clip.Begin((int)doc.brushes.size());
while (clip.Step())
for (int i = clip.DisplayStart; i < clip.DisplayEnd; ++i) { const hmap::Brush& b = doc.brushes[(size_t)i]; row({ObjType::Brush, b.id}, label(b), b.flags & hmap::OBJ_HIDDEN); }
}
}
if (ImGui::CollapsingHeader(("Meshes (" + std::to_string(doc.meshes.size()) + ")").c_str(), ImGuiTreeNodeFlags_DefaultOpen)) {
for (const hmap::MeshObject& m : doc.meshes) {
std::snprintf(buf, sizeof buf, "%s #%u (%zu polys)", m.name.empty() ? "Mesh" : m.name.c_str(), m.id, m.polys.size());
if (!filter.PassFilter(buf)) continue;
row({ObjType::Mesh, m.id}, buf, m.flags & hmap::OBJ_HIDDEN);
}
}
if (ImGui::CollapsingHeader(("Entities (" + std::to_string(doc.entities.size()) + ")").c_str(), ImGuiTreeNodeFlags_DefaultOpen)) {
for (const hmap::Entity& e : doc.entities) {
std::string name = e.get("targetname");
std::snprintf(buf, sizeof buf, "%s%s%s #%u", e.classname.c_str(), name.empty() ? "" : " ", name.c_str(), e.id);
if (!filter.PassFilter(buf)) continue;
const hmap::EntityClass* c = classOf(e);
if (c && c->brush) { // brush entity: select the geometry it owns
ImGui::PushID((int)e.id);
if (ImGui::Selectable((std::string(buf) + " [brush entity]").c_str(), false)) {
sel.clear();
for (const hmap::Brush& b : doc.brushes) if (b.owner == e.id) sel.objs.push_back({ObjType::Brush, b.id});
for (const hmap::MeshObject& m : doc.meshes) if (m.owner == e.id) sel.objs.push_back({ObjType::Mesh, m.id});
}
ImGui::PopID();
} else {
row({ObjType::Entity, e.id}, buf, e.flags & hmap::OBJ_HIDDEN);
}
}
}
ImGui::End();
}
// ============================================================ inspector
bool Editor::propEditor(hmap::Entity& e, const hmap::PropDef& p) {
std::string val = e.get(p.key, p.def);
bool changed = false;
ImGui::PushID(p.key.c_str());
const std::string& t = p.type;
if (t == "int") {
int v = std::atoi(val.c_str());
if (ImGui::InputInt(p.label.c_str(), &v)) { val = std::to_string(v); changed = true; }
} else if (t == "float") {
float v = (float)std::atof(val.c_str());
if (ImGui::DragFloat(p.label.c_str(), &v, 0.05f)) { char b[48]; std::snprintf(b, sizeof b, "%g", v); val = b; changed = true; }
} else if (t == "bool") {
bool v = val != "0" && val != "false" && !val.empty();
if (ImGui::Checkbox(p.label.c_str(), &v)) { val = v ? "1" : "0"; changed = true; }
} else if (t == "color255") {
float c[3] = {1, 1, 1};
float r, g, b;
if (std::sscanf(val.c_str(), "%f %f %f", &r, &g, &b) == 3) { c[0] = r / 255.0f; c[1] = g / 255.0f; c[2] = b / 255.0f; }
if (ImGui::ColorEdit3(p.label.c_str(), c, ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_PickerHueWheel)) {
char b2[64];
std::snprintf(b2, sizeof b2, "%d %d %d", (int)std::lround(c[0] * 255), (int)std::lround(c[1] * 255), (int)std::lround(c[2] * 255));
val = b2;
changed = true;
}
} else if (t == "vec3") {
float v[3] = {0, 0, 0};
std::sscanf(val.c_str(), "%f %f %f", &v[0], &v[1], &v[2]);
if (ImGui::DragFloat3(p.label.c_str(), v, 0.05f)) { char b[96]; std::snprintf(b, sizeof b, "%g %g %g", v[0], v[1], v[2]); val = b; changed = true; }
} else if (t == "choice") {
std::vector<std::pair<std::string, std::string>> items;
size_t pos = 0;
while (pos <= p.choices.size()) {
size_t comma = p.choices.find(',', pos);
std::string item = p.choices.substr(pos, comma == std::string::npos ? std::string::npos : comma - pos);
size_t eq = item.find('=');
if (!item.empty()) items.push_back({item.substr(0, eq), eq == std::string::npos ? item : item.substr(eq + 1)});
if (comma == std::string::npos) break;
pos = comma + 1;
}
std::string cur = val;
for (auto& it : items) if (it.first == val) cur = it.second;
if (ImGui::BeginCombo(p.label.c_str(), cur.c_str())) {
for (auto& it : items)
if (ImGui::Selectable(it.second.c_str(), it.first == val)) { val = it.first; changed = true; }
ImGui::EndCombo();
}
} else if (t == "model") {
ImGui::SetNextItemWidth(-90);
if (ImGui::InputText(p.label.c_str(), &val)) changed = true;
ImGui::SameLine();
if (ImGui::SmallButton("Browse")) {
uint32_t id = e.id;
openModelPicker([this, id](const std::string& rel) {
if (auto* ent = doc.findEntity(id)) { pushUndo(); ent->set("model", rel); touch(); }
});
}
} else { // string, target, material, anything else
if (ImGui::InputText(p.label.c_str(), &val)) changed = true;
}
if (changed) { editBegin(); e.set(p.key, val); touch(); }
ImGui::PopID();
return changed;
}
void Editor::entityInspector(hmap::Entity& e, bool brushEntity) {
const hmap::EntityClass* cls = classOf(e);
ImGui::PushID((int)e.id + 100000);
ImGui::SeparatorText(brushEntity ? "Brush entity" : "Entity");
if (ImGui::BeginCombo("Class", e.classname.c_str())) {
for (const hmap::EntityClass& c : entdefs.classes) {
if (c.brush != brushEntity) continue;
if (ImGui::Selectable((c.label + " (" + c.name + ")").c_str(), c.name == e.classname)) {
editBegin();
e.classname = c.name;
entdefs.applyDefaults(e);
touch();
}
}
ImGui::EndCombo();
}
std::string name = e.get("targetname");
if (ImGui::InputText("Name", &name)) {
editBegin();
if (name.empty()) e.erase("targetname"); else e.set("targetname", name);
touch();
}
if (!brushEntity) {
float o[3] = {e.origin.x, e.origin.y, e.origin.z}, a[3] = {e.angles.x, e.angles.y, e.angles.z}, s[3] = {e.scale.x, e.scale.y, e.scale.z};
if (ImGui::DragFloat3("Origin", o, 1.0f)) { editBegin(); e.origin = {o[0], o[1], o[2]}; touch(); }
if (ImGui::DragFloat3("Angles", a, 0.5f)) { editBegin(); e.angles = {a[0], a[1], a[2]}; touch(); }
if (ImGui::IsItemHovered()) ImGui::SetTooltip("pitch, yaw, roll in degrees");
if (cls && cls->hasModel && ImGui::DragFloat3("Scale", s, 0.01f, 0.01f, 100.0f)) { editBegin(); e.scale = {s[0], s[1], s[2]}; touch(); }
}
if (cls)
for (const hmap::PropDef& p : cls->props) propEditor(e, p);
// key-values that are not part of the class definition
std::vector<std::string> extra;
for (const hmap::KeyValue& kv : e.props) {
bool known = kv.key == "targetname";
if (cls) for (const hmap::PropDef& p : cls->props) known = known || p.key == kv.key;
if (!known) extra.push_back(kv.key);
}
if (!extra.empty()) ImGui::SeparatorText("Custom key-values");
for (const std::string& k : extra) {
ImGui::PushID(k.c_str());
std::string v = e.get(k);
ImGui::SetNextItemWidth(-60);
if (ImGui::InputText(k.c_str(), &v)) { editBegin(); e.set(k, v); touch(); }
ImGui::SameLine();
if (ImGui::SmallButton("X")) { pushUndo(); e.erase(k); touch(); ImGui::PopID(); break; }
ImGui::PopID();
}
static std::string newKey, newVal;
ImGui::SetNextItemWidth(90);
ImGui::InputTextWithHint("##nk", "key", &newKey);
ImGui::SameLine();
ImGui::SetNextItemWidth(-60);
ImGui::InputTextWithHint("##nv", "value", &newVal);
ImGui::SameLine();
if (ImGui::SmallButton("Add") && !newKey.empty()) { pushUndo(); e.set(newKey, newVal); newKey.clear(); newVal.clear(); touch(); }
ImGui::PopID();
}
void Editor::faceInspector() {
ObjRef p = sel.primary();
if (p.type == ObjType::Entity) return;
ImGui::SeparatorText("Surface");
if (sel.faces.empty()) {
ImGui::TextDisabled("Switch to Face mode (toolbar) and click a face\nin the 3D view. Alt+click paints directly.");
return;
}
hmap::Brush* b = p.type == ObjType::Brush ? doc.findBrush(p.id) : nullptr;
hmap::MeshObject* m = p.type == ObjType::Mesh ? doc.findMesh(p.id) : nullptr;
if (!b && !m) return;
auto uvOf = [&](int f) -> hmap::UVInfo* {
if (b) return (size_t)f < b->faces.size() ? &b->faces[(size_t)f].uv : nullptr;
return (size_t)f < m->polys.size() ? &m->polys[(size_t)f].proj : nullptr;
};
auto matOf = [&](int f) -> int* {
if (b) return (size_t)f < b->faces.size() ? &b->faces[(size_t)f].material : nullptr;
return (size_t)f < m->polys.size() ? &m->polys[(size_t)f].material : nullptr;
};
int f0 = sel.faces[0];
hmap::UVInfo* uv0 = uvOf(f0);
int* mat0 = matOf(f0);
if (!uv0 || !mat0) return;
ImGui::Text("%zu face(s) selected", sel.faces.size());
if (m && !m->polys[(size_t)f0].uv.empty()) ImGui::TextDisabled("This polygon has explicit UVs; offset/scale ignored.");
int mi = *mat0;
std::string matName = (mi >= 0 && (size_t)mi < doc.materials.size()) ? doc.materials[(size_t)mi].name : "?";
if (ImGui::BeginCombo("Material", matName.c_str())) {
for (size_t i = 0; i < doc.materials.size(); ++i)
if (ImGui::Selectable(doc.materials[i].name.c_str(), (int)i == mi)) {
editBegin();
for (int f : sel.faces) if (int* mp = matOf(f)) *mp = (int)i;
touch();
}
ImGui::EndCombo();
}
if (ImGui::Button("Use current")) { editBegin(); for (int f : sel.faces) if (int* mp = matOf(f)) *mp = curMaterial; touch(); }
ImGui::SameLine();
if (ImGui::Button("Pick")) curMaterial = mi;
float off[2] = {uv0->offset.x, uv0->offset.y}, sc[2] = {uv0->scale.x, uv0->scale.y}, rot = uv0->rotation;
if (ImGui::DragFloat2("Offset", off, 0.5f)) { editBegin(); for (int f : sel.faces) if (auto* u = uvOf(f)) u->offset = {off[0], off[1]}; touch(); }
if (ImGui::DragFloat2("Scale", sc, 0.01f, -64.0f, 64.0f)) { editBegin(); for (int f : sel.faces) if (auto* u = uvOf(f)) u->scale = {sc[0], sc[1]}; touch(); }
if (ImGui::DragFloat("Rotation", &rot, 0.5f)) { editBegin(); for (int f : sel.faces) if (auto* u = uvOf(f)) u->rotation = rot; touch(); }
if (ImGui::Button("Reset")) { pushUndo(); for (int f : sel.faces) if (auto* u = uvOf(f)) *u = hmap::UVInfo(); touch(); }
ImGui::SameLine();
if (ImGui::Button("Rotate 90")) { pushUndo(); for (int f : sel.faces) if (auto* u = uvOf(f)) u->rotation = std::fmod(u->rotation + 90.0f, 360.0f); touch(); }
ImGui::SameLine();
if (ImGui::Button("Flip U")) { pushUndo(); for (int f : sel.faces) if (auto* u = uvOf(f)) u->scale.x = -u->scale.x; touch(); }
ImGui::SameLine();
if (ImGui::Button("Flip V")) { pushUndo(); for (int f : sel.faces) if (auto* u = uvOf(f)) u->scale.y = -u->scale.y; touch(); }
if (b && ImGui::Button("Fit texture to face")) {
pushUndo();
const SceneCache& sc2 = scene();
for (size_t bi = 0; bi < doc.brushes.size(); ++bi) {
if (doc.brushes[bi].id != b->id) continue;
for (int f : sel.faces) {
if ((size_t)f >= sc2.brushGeo[bi].polys.size() || sc2.brushGeo[bi].polys[(size_t)f].size() < 3) continue;
hmap::Face& face = b->faces[(size_t)f];
Vec3 u, v;
hmap::uvAxes(face.plane.n, face.uv, u, v);
float u0 = 1e30f, u1 = -1e30f, v0 = 1e30f, v1 = -1e30f;
for (const Vec3& pt : sc2.brushGeo[bi].polys[(size_t)f]) {
u0 = std::min(u0, hmap::dot(pt, u)); u1 = std::max(u1, hmap::dot(pt, u));
v0 = std::min(v0, hmap::dot(pt, v)); v1 = std::max(v1, hmap::dot(pt, v));
}
const hmap::Material& mat = doc.materials[(size_t)std::max(0, std::min(face.material, (int)doc.materials.size() - 1))];
face.uv.scale = {std::max(0.001f, (u1 - u0) / mat.tileSize.x), std::max(0.001f, (v1 - v0) / mat.tileSize.y)};
face.uv.offset = {-u0, -v0};
}
}
touch();
}
}
void Editor::transformInspector() {
if (!ImGui::CollapsingHeader("Transform")) return;
static float move[3] = {0, 0, 0}, scl[3] = {1, 1, 1}, rotDeg = 90;
static int axis = 1;
Vec3 c = selectionCenter();
ImGui::DragFloat3("##move", move, 1.0f);
ImGui::SameLine();
if (ImGui::Button("Move")) { pushUndo(); applyMatrix(Mat34::translate({move[0], move[1], move[2]}), true); }
static const char* axes[] = {"X", "Y", "Z"};
ImGui::SetNextItemWidth(46);
ImGui::Combo("##axis", &axis, axes, 3);
ImGui::SameLine();
ImGui::SetNextItemWidth(80);
ImGui::DragFloat("##deg", &rotDeg, 1.0f, -360.0f, 360.0f, "%.0f deg");
ImGui::SameLine();
if (ImGui::Button("Rotate")) {
Vec3 ax(0, 0, 0);
ax[axis] = 1.0f;
pushUndo();
applyMatrix(Mat34::translate(c) * Mat34::rotate(ax, rotDeg) * Mat34::translate(-c), false);
}
ImGui::DragFloat3("##scale", scl, 0.01f, 0.01f, 100.0f);
ImGui::SameLine();
if (ImGui::Button("Scale")) {
pushUndo();
applyMatrix(Mat34::translate(c) * Mat34::scale({scl[0], scl[1], scl[2]}) * Mat34::translate(-c), false);
}
ImGui::TextUnformatted("Mirror");
for (int a = 0; a < 3; ++a) {
ImGui::SameLine();
if (ImGui::Button(axes[a])) {
Vec3 s(1, 1, 1);
s[a] = -1.0f;
pushUndo();
applyMatrix(Mat34::translate(c) * Mat34::scale(s) * Mat34::translate(-c), false);
}
}
}
void Editor::drawInspector() {
if (!panelInspector) return;
firstPlace(0.78f, 0.04f, 0.22f, 0.76f);
if (!ImGui::Begin("Inspector", &panelInspector)) { ImGui::End(); return; }
if (sel.empty()) {
ImGui::SeparatorText("Map");
std::string mapName = doc.name;
if (ImGui::InputText("Name", &mapName)) { editBegin(); doc.name = mapName; dirty = true; }
float amb[3] = {doc.settings.ambient.x, doc.settings.ambient.y, doc.settings.ambient.z};
if (ImGui::ColorEdit3("Ambient light", amb, ImGuiColorEditFlags_NoInputs)) { editBegin(); doc.settings.ambient = {amb[0], amb[1], amb[2]}; touch(); }
ImGui::DragFloat("Units per metre", &doc.settings.unitsPerMeter, 0.5f, 1.0f, 1024.0f);
ImGui::TextDisabled("Used for the FBX unit scale. 64 = 1 m is a good default.");
ImGui::SeparatorText("Statistics");
ImGui::Text("%zu brushes, %zu meshes, %zu entities", doc.brushes.size(), doc.meshes.size(), doc.entities.size());
ImGui::Text("%zu materials, %zu triangles in view", doc.materials.size(), renderer.editTriangles);
if (hasCompiled) {
const hmap::CompileStats& s = compiled.stats;
ImGui::Text("Build: %u tris after CSG, BSP %u nodes / %u leaves", s.triangles, s.bspNodes, s.bspLeaves);
if (compiledStale) ImGui::TextColored(ImVec4(1, 0.7f, 0.3f, 1), "Build is out of date (F9)");
}
ImGui::SeparatorText("Quick start");
ImGui::TextWrapped("1. Pick Block (2) and drag in Top/Front/Side, or click a surface in 3D.\n2. Select (1) to move with the gizmo, resize with handles.\n3. Entity (3) places lights, props and spawns.\n4. F9 builds the BSP and bakes lighting.\n5. File > Export > FBX.");
ImGui::End();
return;
}
ObjRef p = sel.primary();
ImGui::Text("%zu selected - %s", sel.objs.size(), p.type == ObjType::Brush ? "Brush" : p.type == ObjType::Mesh ? "Mesh" : "Entity");
ImGui::Separator();
if (p.type == ObjType::Entity) {
if (hmap::Entity* e = doc.findEntity(p.id)) entityInspector(*e, false);
} else {
if (p.type == ObjType::Brush) {
if (hmap::Brush* b = doc.findBrush(p.id)) {
static const char* contents[] = {"Solid", "Water", "Player clip", "Trigger", "Sky"};
static const uint32_t cval[] = {hmap::CONTENTS_SOLID, hmap::CONTENTS_WATER, hmap::CONTENTS_CLIP, hmap::CONTENTS_TRIGGER, hmap::CONTENTS_SKY};
int ci = 0;
for (int i = 0; i < 5; ++i) if (b->contents == cval[i]) ci = i;
if (ImGui::Combo("Contents", &ci, contents, 5)) {
editBegin();
for (const ObjRef& r : sel.objs) if (r.type == ObjType::Brush) if (auto* bb = doc.findBrush(r.id)) bb->contents = cval[ci];
touch();
}
ImGui::TextDisabled("%zu faces", b->faces.size());
}
} else if (hmap::MeshObject* m = doc.findMesh(p.id)) {
std::string meshName = m->name;
if (ImGui::InputText("Name", &meshName)) { editBegin(); m->name = meshName; touch(); }
bool collide = !(m->flags & hmap::OBJ_NOCOLLIDE);
if (ImGui::Checkbox("Collides / casts shadows", &collide)) {
editBegin();
if (collide) m->flags &= ~(uint32_t)hmap::OBJ_NOCOLLIDE; else m->flags |= hmap::OBJ_NOCOLLIDE;
touch();
}
ImGui::TextDisabled("%zu vertices, %zu polygons", m->verts.size(), m->polys.size());
static float amp = 24, freq = 0.01f, ext = 16;
static int seed = 1;
if (ImGui::TreeNode("Mesh tools")) {
ImGui::SetNextItemWidth(70); ImGui::DragFloat("Amplitude", &amp, 0.5f, 0, 512);
ImGui::SetNextItemWidth(70); ImGui::DragFloat("Frequency", &freq, 0.0005f, 0.0005f, 1.0f, "%.4f");
ImGui::SetNextItemWidth(70); ImGui::DragInt("Seed", &seed, 0.2f);
if (ImGui::Button("Add noise (terrain)")) { pushUndo(); hmap::noiseMesh(*m, amp, freq, (uint32_t)seed); touch(); }
ImGui::SetNextItemWidth(70); ImGui::DragFloat("Extrude by", &ext, 0.5f, -512, 512);
if (ImGui::Button("Extrude selected faces")) {
pushUndo();
int n = 0;
for (int f : std::vector<int>(sel.faces)) n += hmap::extrudePoly(*m, f, ext);
if (!n) { undoStack.pop_back(); log("Extrude: select faces first (Face mode)"); }
touch();
}
if (ImGui::Button("Flip normals")) flipSelection();
ImGui::TreePop();
}
}
// geometry owned by a brush entity: show the entity's properties
uint32_t owner = 0;
if (p.type == ObjType::Brush) { if (auto* b = doc.findBrush(p.id)) owner = b->owner; }
else if (auto* m = doc.findMesh(p.id)) owner = m->owner;
if (owner) { if (hmap::Entity* oe = doc.findEntity(owner)) entityInspector(*oe, true); }
faceInspector();
}
ImGui::SeparatorText("Actions");
if (ImGui::Button("Duplicate")) duplicateSelection();
ImGui::SameLine();
if (ImGui::Button("Delete")) deleteSelection();
ImGui::SameLine();
if (ImGui::Button("Hide")) hideSelection(true);
static float hollowT = 16;
bool hasBrush = false;
for (const ObjRef& r : sel.objs) hasBrush = hasBrush || r.type == ObjType::Brush;
if (hasBrush) {
ImGui::SetNextItemWidth(70);
ImGui::DragFloat("##hollow", &hollowT, 0.5f, 1.0f, 512.0f, "%.0f");
ImGui::SameLine();
if (ImGui::Button("Hollow")) hollowSelection(hollowT);
ImGui::SameLine();
if (ImGui::Button("To mesh")) convertSelectionToMesh();
if (ImGui::BeginCombo("Tie to entity", "choose class...")) {
for (const hmap::EntityClass& c : entdefs.classes)
if (c.brush && ImGui::Selectable((c.label + " (" + c.name + ")").c_str())) tieSelectionToEntity(c.name);
ImGui::EndCombo();
}
}
transformInspector();
if (ImGui::CollapsingHeader("Paint")) {
ImGui::Text("Current material: %s", curMaterial >= 0 && (size_t)curMaterial < doc.materials.size() ? doc.materials[(size_t)curMaterial].name.c_str() : "-");
if (ImGui::Button("Apply to selection")) applyMaterialToSelection(curMaterial);
ImGui::TextDisabled("Alt+click in 3D paints one face (Shift = whole object).");
}
ImGui::End();
}
// ============================================================ assets
void Editor::drawAssets() {
if (!panelAssets) return;
firstPlace(0.17f, 0.82f, 0.61f, 0.17f);
if (!ImGui::Begin("Assets", &panelAssets)) { ImGui::End(); return; }
if (ImGui::BeginTabBar("##assettabs")) {
if (ImGui::BeginTabItem("Materials")) {
if (ImGui::Button("Add texture...")) {
fileDialog.open("Add texture", assetRoot + "/materials", {".png", ".jpg", ".jpeg", ".tga", ".bmp"}, false, [this](const std::string& path) {
pushUndo();
std::string rel = relativeToAssets(path);
std::string name = fs::path(path).stem().string();
curMaterial = doc.addMaterial(name, rel);
touch();
});
}
ImGui::SameLine();
if (ImGui::Button("Add all from assets/materials")) {
scanAssets();
pushUndo();
for (const std::string& f : textureFiles) doc.addMaterial(fs::path(f).stem().string(), f);
touch();
}
ImGui::BeginChild("##matgrid", ImVec2(ImGui::GetContentRegionAvail().x * 0.66f, 0), false);
float avail = ImGui::GetContentRegionAvail().x;
int perRow = std::max(1, (int)(avail / 68.0f));
for (size_t i = 0; i < doc.materials.size(); ++i) {
const hmap::Material& m = doc.materials[i];
Texture2D t = renderer.texture(m, assetRoot);
bool cur = (int)i == curMaterial;
if (cur) ImGui::PushStyleColor(ImGuiCol_Button, kAccent);
ImGui::PushID((int)i);
ImGui::BeginGroup();
if (rlImGuiImageButtonSize("##mat", &t, Vector2{ 56, 56 })) curMaterial = (int)i;
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", m.name.c_str());
if (ImGui::IsMouseDoubleClicked(0) && !sel.empty()) applyMaterialToSelection((int)i);
}
ImGui::ColorButton("##tint", ImVec4(m.color.x, m.color.y, m.color.z, 1), ImGuiColorEditFlags_NoTooltip | ImGuiColorEditFlags_NoPicker, ImVec2(56, 6));
ImGui::EndGroup();
ImGui::PopID();
if (cur) ImGui::PopStyleColor();
if ((int)(i + 1) % perRow != 0 && i + 1 < doc.materials.size()) ImGui::SameLine();
}
ImGui::EndChild();
ImGui::SameLine();
ImGui::BeginChild("##matprops", ImVec2(0, 0), true);
if (curMaterial >= 0 && (size_t)curMaterial < doc.materials.size()) {
hmap::Material& m = doc.materials[(size_t)curMaterial];
ImGui::Text("%s", m.name.c_str());
std::string texPath = m.texture;
if (ImGui::InputText("Texture", &texPath)) { editBegin(); m.texture = texPath; touch(); }
float ts[2] = {m.tileSize.x, m.tileSize.y};
if (ImGui::DragFloat2("Tile size", ts, 1.0f, 1.0f, 4096.0f)) { editBegin(); m.tileSize = {ts[0], ts[1]}; touch(); }
float col[3] = {m.color.x, m.color.y, m.color.z};
if (ImGui::ColorEdit3("Tint", col, ImGuiColorEditFlags_NoInputs)) { editBegin(); m.color = {col[0], col[1], col[2]}; touch(); }
}
ImGui::EndChild();
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Models")) {
if (ImGui::Button("Rescan")) scanAssets();
ImGui::SameLine();
if (ImGui::Button("Browse...")) openModelPicker([this](const std::string& rel) { curModel = rel; curEntity = "prop_static"; tool = Tool::Entity; });
ImGui::SameLine();
ImGui::TextDisabled("Pick a model, then click in a viewport with the Entity tool (obj, gltf, glb, iqm, vox, m3d).");
ImGui::BeginChild("##models", ImVec2(0, 0), true);
if (modelFiles.empty()) ImGui::TextDisabled("No models found in %s/models", assetRoot.c_str());
for (const std::string& f : modelFiles)
if (ImGui::Selectable(f.c_str(), f == curModel)) {
curModel = f;
const hmap::EntityClass* c = entdefs.find(curEntity);
if (!c || !c->hasModel) curEntity = "prop_static";
tool = Tool::Entity;
}
ImGui::EndChild();
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Entities")) {
ImGui::BeginChild("##ents", ImVec2(ImGui::GetContentRegionAvail().x * 0.5f, 0), true);
for (int pass = 0; pass < 2; ++pass) {
ImGui::SeparatorText(pass == 0 ? "Point entities" : "Brush entities");
for (const hmap::EntityClass& c : entdefs.classes) {
if (c.brush != (pass == 1)) continue;
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(c.color.x * 0.6f + 0.4f, c.color.y * 0.6f + 0.4f, c.color.z * 0.6f + 0.4f, 1));
bool pressed = ImGui::Selectable((c.label + " (" + c.name + ")").c_str(), c.name == curEntity);
ImGui::PopStyleColor();
if (pressed) {
curEntity = c.name;
if (!c.brush) tool = Tool::Entity;
else if (!sel.empty()) tieSelectionToEntity(c.name);
}
}
}
ImGui::EndChild();
ImGui::SameLine();
ImGui::BeginChild("##entinfo", ImVec2(0, 0), true);
if (const hmap::EntityClass* c = entdefs.find(curEntity)) {
ImGui::Text("%s", c->label.c_str());
ImGui::TextDisabled("%s", c->name.c_str());
ImGui::Separator();
if (c->brush) ImGui::TextWrapped("Brush entity. Select brushes or meshes, then click this class (or Ctrl+T) to tie them to a new %s.", c->name.c_str());
else ImGui::TextWrapped("Point entity. Click in a viewport to place it.%s", c->hasModel ? " Choose a model in the Models tab first." : "");
ImGui::Spacing();
for (const hmap::PropDef& p : c->props) ImGui::BulletText("%s [%s] default \"%s\"", p.label.c_str(), p.type.c_str(), p.def.c_str());
ImGui::Spacing();
ImGui::TextDisabled("Add your own classes in assets/entities.def");
}
ImGui::EndChild();
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
ImGui::End();
}
void Editor::drawLog() {
if (!panelLog) return;
firstPlace(0.17f, 0.82f, 0.61f, 0.17f);
if (!ImGui::Begin("Log", &panelLog)) { ImGui::End(); return; }
if (ImGui::Button("Clear")) logLines.clear();
ImGui::SameLine();
ImGui::TextDisabled("%zu messages", logLines.size());
ImGui::BeginChild("##logscroll", ImVec2(0, 0), true);
for (const std::string& l : logLines) ImGui::TextUnformatted(l.c_str());
if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f) ImGui::SetScrollHereY(1.0f);
ImGui::EndChild();
ImGui::End();
}
void Editor::drawHelp() {
if (!showHelp) return;
ImGui::SetNextWindowSize(ImVec2(620, 520), ImGuiCond_Appearing);
if (!ImGui::Begin("Controls", &showHelp)) { ImGui::End(); return; }
ImGui::SeparatorText("Navigation");
ImGui::BulletText("3D view: hold Right Mouse + W A S D, Q/E = down/up, Shift = fast, wheel = fly speed");
ImGui::BulletText("3D view: wheel = dolly, Middle drag = pan");
ImGui::BulletText("2D views: wheel = zoom to cursor, Right/Middle drag = pan, F = frame selection");
ImGui::SeparatorText("Tools (1 Select 2 Block 3 Entity 4 Clip)");
ImGui::BulletText("Select: click, Shift/Ctrl+click adds, drag empty space = box select, drag = move");
ImGui::BulletText("Select: drag the orange handles in a 2D view to resize, drag gizmo arrows in 3D to move");
ImGui::BulletText("Block: drag a rectangle in a 2D view (height = Create > Default height), or click a surface in 3D");
ImGui::BulletText("Entity: click to place the class chosen in Assets > Entities (props use Assets > Models)");
ImGui::BulletText("Clip: two clicks in a 2D view, Tab = side, Enter = cut");
ImGui::SeparatorText("Surfaces");
ImGui::BulletText("Alt+click in 3D paints the current material; Alt+Shift+click paints the whole object");
ImGui::BulletText("Face mode: click faces, edit offset / scale / rotation in the Inspector, \"Fit\" stretches to the face");
ImGui::BulletText("Vertex mode (meshes): click or box-select vertices, drag to move; Extrude in Inspector > Mesh tools");
ImGui::SeparatorText("Shortcuts");
ImGui::BulletText("Ctrl+Z / Ctrl+Y undo/redo Ctrl+D duplicate Del delete Ctrl+A select all Esc cancel/deselect");
ImGui::BulletText("H hide selection, Alt+H unhide all G snap [ and ] grid size Ctrl+T tie to brush entity");
ImGui::BulletText("Ctrl+S save Ctrl+O open Ctrl+N new F9 build (CSG + BSP + baked light) F1 this window");
ImGui::SeparatorText("Geometry model");
ImGui::TextWrapped("Brushes are convex solids (planes) compiled into a BSP tree with CSG - ideal for level structure and collision. "
"Meshes are free-form polygon objects for curved or organic detail; they render through the same shader and "
"can collide and cast shadows. Use Hollow, Clip and Tie-to-entity on brushes; Vertex mode and Extrude on meshes.");
ImGui::End();
}
} // namespace hpp