#include #include #include #include #include #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 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 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 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& exts, std::vector& 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 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> 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 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", &, 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(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