feat: update asset browser
This commit is contained in:
@@ -8,6 +8,12 @@ set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
include(CTest)
|
||||
|
||||
option(DESTRUM_ENABLE_WARNINGS "Enable warnings for first-party targets" ON)
|
||||
option(DESTRUM_STRICT_ASSET_COOKING "Fail the build when an asset falls back to its source instead of being cooked" ON)
|
||||
|
||||
set(DESTRUM_CHEF_FLAGS)
|
||||
if (DESTRUM_STRICT_ASSET_COOKING)
|
||||
list(APPEND DESTRUM_CHEF_FLAGS --strict)
|
||||
endif()
|
||||
|
||||
function(destrum_enable_warnings target)
|
||||
if (NOT DESTRUM_ENABLE_WARNINGS)
|
||||
@@ -55,6 +61,12 @@ add_dependencies(CookAssets
|
||||
_internal_cook_engine_assets
|
||||
)
|
||||
|
||||
add_custom_target(ValidateAssets)
|
||||
add_dependencies(ValidateAssets
|
||||
_internal_validate_game_assets
|
||||
_internal_validate_engine_assets
|
||||
)
|
||||
|
||||
option(ENABLE_SANITIZERS "Enable Address/Undefined sanitizers" OFF)
|
||||
|
||||
if (ENABLE_SANITIZERS AND CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU")
|
||||
|
||||
+1
-1
Submodule TheChef updated: 1981a21481...3d4b50529e
@@ -232,10 +232,21 @@ add_custom_target(_internal_cook_engine_assets
|
||||
COMMAND TheChef
|
||||
--input "${ASSETS_SRC_DIR}"
|
||||
--output "${ASSETS_RUNTIME_DIR}"
|
||||
${DESTRUM_CHEF_FLAGS}
|
||||
DEPENDS TheChef ${ENGINE_ASSET_SOURCES}
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
add_custom_target(_internal_validate_engine_assets
|
||||
COMMAND TheChef
|
||||
--input "${ASSETS_SRC_DIR}"
|
||||
--output "${ASSETS_RUNTIME_DIR}"
|
||||
--validate
|
||||
${DESTRUM_CHEF_FLAGS}
|
||||
DEPENDS _internal_cook_engine_assets
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
find_program(SPIRV_VAL_EXECUTABLE NAMES spirv-val spirv-val.exe)
|
||||
if (SPIRV_VAL_EXECUTABLE)
|
||||
file(GLOB_RECURSE ENGINE_SHADER_SOURCES CONFIGURE_DEPENDS
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"allowFallback": [
|
||||
"run.fbx"
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ set(EDITOR_SRC
|
||||
src/PlayMode.cpp
|
||||
src/UndoStack.cpp
|
||||
src/ShaderHotReload.cpp
|
||||
src/SceneTree.cpp
|
||||
src/SceneInspector.cpp
|
||||
src/AssetBrowser.cpp
|
||||
src/TransformGizmo.cpp
|
||||
|
||||
@@ -1,37 +1,115 @@
|
||||
#ifndef EDITOR_ASSETBROWSER_H
|
||||
#define EDITOR_ASSETBROWSER_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <future>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <glm/vec3.hpp>
|
||||
|
||||
#include <destrum/Graphics/GPUImage.h>
|
||||
|
||||
class GfxDevice;
|
||||
class ImguiPass;
|
||||
struct FSMount;
|
||||
|
||||
class AssetBrowser final {
|
||||
public:
|
||||
AssetBrowser(GfxDevice& gfxDevice, ImguiPass& imguiPass);
|
||||
~AssetBrowser();
|
||||
|
||||
AssetBrowser(const AssetBrowser&) = delete;
|
||||
AssetBrowser& operator=(const AssetBrowser&) = delete;
|
||||
|
||||
std::optional<std::string> Draw(bool* open = nullptr);
|
||||
|
||||
void ConfigureCooking(std::filesystem::path cooker, std::vector<std::string> schemes);
|
||||
|
||||
enum class Kind { Folder, Model, Scene, Prefab, Material, Image, Audio, Shader, Font, Text, Other };
|
||||
|
||||
private:
|
||||
struct Entry {
|
||||
std::string name;
|
||||
Kind kind{Kind::Other};
|
||||
bool directory{false};
|
||||
std::uintmax_t size{0};
|
||||
std::filesystem::path fullPath;
|
||||
std::filesystem::path previewPath;
|
||||
glm::vec3 tint{1.0f};
|
||||
};
|
||||
|
||||
struct Location {
|
||||
std::string scheme;
|
||||
std::filesystem::path folder;
|
||||
};
|
||||
|
||||
struct ThumbnailPixels {
|
||||
int width{0};
|
||||
int height{0};
|
||||
int sourceWidth{0};
|
||||
int sourceHeight{0};
|
||||
std::vector<unsigned char> rgba;
|
||||
};
|
||||
|
||||
struct Thumbnail {
|
||||
GPUImage image;
|
||||
std::filesystem::file_time_type writeTime;
|
||||
int sourceWidth{0};
|
||||
int sourceHeight{0};
|
||||
bool failed{false};
|
||||
};
|
||||
|
||||
void Refresh();
|
||||
void StartCook();
|
||||
void PollCook();
|
||||
void Navigate(const std::string& scheme, const std::filesystem::path& folder);
|
||||
void Navigate(std::string scheme, std::filesystem::path folder, bool record = true);
|
||||
void GoBack();
|
||||
void GoForward();
|
||||
void GoUp();
|
||||
[[nodiscard]] std::string VirtualPath(const std::string& name) const;
|
||||
void DrawEntry(const Entry& entry, std::optional<std::string>& opened, bool& navigated);
|
||||
|
||||
void DrawToolbar();
|
||||
void DrawBreadcrumbs(float width);
|
||||
void DrawFolderTree();
|
||||
void DrawFolderNode(const FSMount& mount, const std::filesystem::path& folder, const std::string& label);
|
||||
void DrawEntries(std::optional<std::string>& opened);
|
||||
void DrawGrid(const std::vector<const Entry*>& entries, std::optional<std::string>& opened);
|
||||
void DrawList(const std::vector<const Entry*>& entries, std::optional<std::string>& opened);
|
||||
void DrawStatusBar();
|
||||
void HandleEntryInput(const Entry& entry, std::optional<std::string>& opened, bool& navigated);
|
||||
void Open(const Entry& entry, std::optional<std::string>& opened, bool& navigated);
|
||||
void DrawFolderMenu();
|
||||
void DrawEntryIcon(const Entry& entry, float x, float y, float size);
|
||||
[[nodiscard]] const std::vector<std::string>& Subfolders(const FSMount& mount, const std::filesystem::path& folder);
|
||||
|
||||
[[nodiscard]] const Thumbnail* RequestThumbnail(const std::filesystem::path& path);
|
||||
[[nodiscard]] static ThumbnailPixels DecodeThumbnail(const std::filesystem::path& path);
|
||||
void PollThumbnails();
|
||||
void DestroyThumbnail(Thumbnail& thumbnail);
|
||||
|
||||
GfxDevice& m_GfxDevice;
|
||||
ImguiPass& m_ImguiPass;
|
||||
|
||||
std::string m_Scheme;
|
||||
std::filesystem::path m_Folder;
|
||||
std::vector<Entry> m_Entries;
|
||||
std::string m_Filter;
|
||||
std::string m_Selected;
|
||||
std::size_t m_VisibleCount{0};
|
||||
bool m_Dirty{true};
|
||||
bool m_RevealFolder{true};
|
||||
float m_TileSize{88.0f};
|
||||
|
||||
std::vector<Location> m_History;
|
||||
std::size_t m_HistoryIndex{0};
|
||||
std::unordered_map<std::string, std::vector<std::string>> m_Subfolders;
|
||||
|
||||
std::unordered_map<std::string, Thumbnail> m_Thumbnails;
|
||||
std::unordered_map<std::string, std::future<ThumbnailPixels>> m_PendingThumbnails;
|
||||
|
||||
std::filesystem::path m_Cooker;
|
||||
std::vector<std::string> m_CookSchemes;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include <editor/RectGizmo.h>
|
||||
#include <editor/ResourcesInspector.h>
|
||||
#include <editor/SceneInspector.h>
|
||||
#include <editor/SceneTree.h>
|
||||
#include <editor/ShaderHotReload.h>
|
||||
#include <editor/TransformGizmo.h>
|
||||
#include <editor/UndoStack.h>
|
||||
@@ -54,6 +55,7 @@ public:
|
||||
|
||||
private:
|
||||
struct Panels {
|
||||
bool sceneTree{true};
|
||||
bool inspector{true};
|
||||
bool assets{true};
|
||||
bool stats{true};
|
||||
@@ -118,6 +120,7 @@ private:
|
||||
EditorCamera m_Camera;
|
||||
PlayMode m_PlayMode;
|
||||
UndoStack m_UndoStack;
|
||||
SceneTree m_SceneTree;
|
||||
SceneInspector m_Inspector;
|
||||
AssetBrowser m_AssetBrowser;
|
||||
ResourcesInspector m_ResourcesInspector;
|
||||
@@ -137,6 +140,7 @@ private:
|
||||
bool m_ItemWasActive{false};
|
||||
bool m_EditPending{false};
|
||||
bool m_CameraHadInput{false};
|
||||
bool m_DockLayoutChecked{false};
|
||||
};
|
||||
|
||||
#endif // EDITOR_EDITOR_H
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
@@ -15,44 +14,17 @@ class Scene;
|
||||
|
||||
class SceneInspector final {
|
||||
public:
|
||||
void Draw(Scene& scene, ObjectId& selectedObjectId, const std::function<void()>& drawSceneSettings, bool* open);
|
||||
void Draw(Scene& scene, ObjectId selectedObjectId, const std::function<void()>& drawSceneSettings, bool* open);
|
||||
|
||||
private:
|
||||
struct PendingMove {
|
||||
ObjectId object{InvalidObjectId};
|
||||
ObjectId parent{InvalidObjectId};
|
||||
ObjectId anchor{InvalidObjectId};
|
||||
bool after{false};
|
||||
};
|
||||
|
||||
void DrawHierarchy(Scene& scene, ObjectId& selectedObjectId);
|
||||
void DrawNode(Scene& scene, GameObject& object, ObjectId& selectedObjectId, std::unordered_set<const GameObject*>& visited);
|
||||
void DrawRenameField(GameObject& object);
|
||||
void DrawNodeDragDrop(GameObject& object);
|
||||
void DrawObjectMenu(Scene& scene, GameObject* object, ObjectId& selectedObjectId);
|
||||
void HandleShortcuts(Scene& scene, ObjectId& selectedObjectId);
|
||||
void ApplyPendingActions(Scene& scene, ObjectId& selectedObjectId);
|
||||
|
||||
void DrawObject(GameObject& object);
|
||||
void DrawTransform(GameObject& object);
|
||||
void DrawComponents(GameObject& object);
|
||||
void DrawComponentMenu(GameObject& object, Component& component, std::size_t index, bool removable);
|
||||
void DrawAddComponent(GameObject& object);
|
||||
|
||||
void BeginRename(const GameObject& object);
|
||||
|
||||
std::string m_Filter;
|
||||
std::string m_ComponentFilter;
|
||||
|
||||
ObjectId m_RenamingId{InvalidObjectId};
|
||||
std::string m_RenameBuffer;
|
||||
bool m_FocusRename{false};
|
||||
|
||||
ObjectId m_OpenNextFrame{InvalidObjectId};
|
||||
PendingMove m_PendingMove;
|
||||
ObjectId m_PendingDelete{InvalidObjectId};
|
||||
ObjectId m_PendingDuplicate{InvalidObjectId};
|
||||
|
||||
std::string m_ClipboardType;
|
||||
nlohmann::json m_Clipboard;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#ifndef EDITOR_SCENETREE_H
|
||||
#define EDITOR_SCENETREE_H
|
||||
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
|
||||
#include <destrum/ObjectModel/ObjectId.h>
|
||||
|
||||
class GameObject;
|
||||
class Scene;
|
||||
|
||||
class SceneTree final {
|
||||
public:
|
||||
void Draw(Scene& scene, ObjectId& selectedObjectId, bool* open);
|
||||
|
||||
private:
|
||||
struct PendingMove {
|
||||
ObjectId object{InvalidObjectId};
|
||||
ObjectId parent{InvalidObjectId};
|
||||
ObjectId anchor{InvalidObjectId};
|
||||
bool after{false};
|
||||
};
|
||||
|
||||
void DrawHierarchy(Scene& scene, ObjectId& selectedObjectId);
|
||||
void DrawNode(Scene& scene, GameObject& object, ObjectId& selectedObjectId, std::unordered_set<const GameObject*>& visited);
|
||||
void DrawRenameField(GameObject& object);
|
||||
void DrawNodeDragDrop(GameObject& object);
|
||||
void DrawObjectMenu(Scene& scene, GameObject* object, ObjectId& selectedObjectId);
|
||||
void HandleShortcuts(Scene& scene, ObjectId& selectedObjectId);
|
||||
void ApplyPendingActions(Scene& scene, ObjectId& selectedObjectId);
|
||||
|
||||
void BeginRename(const GameObject& object);
|
||||
|
||||
std::string m_Filter;
|
||||
|
||||
ObjectId m_RenamingId{InvalidObjectId};
|
||||
std::string m_RenameBuffer;
|
||||
bool m_FocusRename{false};
|
||||
|
||||
ObjectId m_OpenNextFrame{InvalidObjectId};
|
||||
PendingMove m_PendingMove;
|
||||
ObjectId m_PendingDelete{InvalidObjectId};
|
||||
ObjectId m_PendingDuplicate{InvalidObjectId};
|
||||
};
|
||||
|
||||
#endif // EDITOR_SCENETREE_H
|
||||
+1053
-66
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@ Editor::Editor(App& app, EditorSettings settings)
|
||||
: m_App(app),
|
||||
m_Settings(std::move(settings)),
|
||||
m_PlayMode(app),
|
||||
m_AssetBrowser(app.getGfxDevice(), app.getImguiPass()),
|
||||
m_ScenePath(m_Settings.scenePath)
|
||||
{
|
||||
}
|
||||
@@ -119,11 +120,14 @@ void Editor::onImGui(float)
|
||||
DrawViewport();
|
||||
|
||||
Scene* scene = CurrentScene();
|
||||
if (scene != nullptr && m_Panels.inspector) {
|
||||
if (scene != nullptr && m_Panels.sceneTree) {
|
||||
ObjectId selected = scene->GetSelectedObjectId();
|
||||
m_Inspector.Draw(*scene, selected, [scene] { scene->DrawSettingsImGui(); }, &m_Panels.inspector);
|
||||
m_SceneTree.Draw(*scene, selected, &m_Panels.sceneTree);
|
||||
scene->SetSelectedObject(selected);
|
||||
}
|
||||
if (scene != nullptr && m_Panels.inspector) {
|
||||
m_Inspector.Draw(*scene, scene->GetSelectedObjectId(), [scene] { scene->DrawSettingsImGui(); }, &m_Panels.inspector);
|
||||
}
|
||||
if (m_Panels.assets) {
|
||||
if (const auto opened = m_AssetBrowser.Draw(&m_Panels.assets)) {
|
||||
OnAssetDropped(*opened);
|
||||
|
||||
@@ -33,6 +33,33 @@ namespace {
|
||||
std::ranges::sort(scenes);
|
||||
return scenes;
|
||||
}
|
||||
|
||||
// Layouts saved while the scene tree was part of the inspector have no spot for it, split one off the inspector
|
||||
void DockSceneTreeIntoSavedLayout(ImGuiID dockspaceId)
|
||||
{
|
||||
const ImGuiWindowSettings* inspector = ImGui::FindWindowSettingsByID(ImHashStr("Inspector"));
|
||||
if (ImGui::FindWindowSettingsByID(ImHashStr("Scene Tree")) != nullptr || inspector == nullptr) {
|
||||
return;
|
||||
}
|
||||
const ImGuiID oldNode = inspector->DockId;
|
||||
const ImGuiDockNode* node = ImGui::DockBuilderGetNode(oldNode);
|
||||
if (node == nullptr || node->IsSplitNode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImGuiID inspectorNode = 0;
|
||||
const ImGuiID sceneTreeNode = ImGui::DockBuilderSplitNode(oldNode, ImGuiDir_Up, 0.4f, nullptr, &inspectorNode);
|
||||
// Windows aren't bound to their nodes yet, the ones tabbed with the inspector would point at the split node
|
||||
ImGuiContext& context = *ImGui::GetCurrentContext();
|
||||
for (ImGuiWindowSettings* settings = context.SettingsWindows.begin(); settings != nullptr;
|
||||
settings = context.SettingsWindows.next_chunk(settings)) {
|
||||
if (settings->DockId == oldNode) {
|
||||
settings->DockId = inspectorNode;
|
||||
}
|
||||
}
|
||||
ImGui::DockBuilderDockWindow("Scene Tree", sceneTreeNode);
|
||||
ImGui::DockBuilderFinish(dockspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
void Editor::DrawDockSpace()
|
||||
@@ -42,20 +69,26 @@ void Editor::DrawDockSpace()
|
||||
const ImGuiViewport* mainViewport = ImGui::GetMainViewport();
|
||||
ImGui::DockSpaceOverViewport(dockspaceId, mainViewport);
|
||||
if (hasSavedLayout) {
|
||||
if (!std::exchange(m_DockLayoutChecked, true)) {
|
||||
DockSceneTreeIntoSavedLayout(dockspaceId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
m_DockLayoutChecked = true;
|
||||
|
||||
// First run without an imgui.ini: scene in the middle, inspector right, tools left, assets below
|
||||
// First run without an imgui.ini: scene in the middle, scene tree and inspector right, tools left, assets below
|
||||
ImGui::DockBuilderRemoveNode(dockspaceId);
|
||||
ImGui::DockBuilderAddNode(dockspaceId, ImGuiDockNodeFlags_DockSpace);
|
||||
ImGui::DockBuilderSetNodeSize(dockspaceId, mainViewport->WorkSize);
|
||||
|
||||
ImGuiID center = dockspaceId;
|
||||
const ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.25f, nullptr, ¢er);
|
||||
ImGuiID right = ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.25f, nullptr, ¢er);
|
||||
const ImGuiID left = ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.2f, nullptr, ¢er);
|
||||
const ImGuiID bottom = ImGui::DockBuilderSplitNode(center, ImGuiDir_Down, 0.28f, nullptr, ¢er);
|
||||
const ImGuiID rightTop = ImGui::DockBuilderSplitNode(right, ImGuiDir_Up, 0.4f, nullptr, &right);
|
||||
|
||||
ImGui::DockBuilderDockWindow("Viewport", center);
|
||||
ImGui::DockBuilderDockWindow("Scene Tree", rightTop);
|
||||
ImGui::DockBuilderDockWindow("Inspector", right);
|
||||
ImGui::DockBuilderDockWindow("Stats", left);
|
||||
ImGui::DockBuilderDockWindow("Physics", left);
|
||||
@@ -155,6 +188,7 @@ void Editor::DrawViewMenu()
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::MenuItem("Scene Tree", nullptr, &m_Panels.sceneTree);
|
||||
ImGui::MenuItem("Inspector", nullptr, &m_Panels.inspector);
|
||||
ImGui::MenuItem("Assets", nullptr, &m_Panels.assets);
|
||||
ImGui::MenuItem("Stats", nullptr, &m_Panels.stats);
|
||||
|
||||
@@ -11,78 +11,15 @@
|
||||
|
||||
#include <destrum/Components/UI/Canvas.h>
|
||||
#include <destrum/Components/UI/RectTransform.h>
|
||||
#include <destrum/Components/UI/UIImage.h>
|
||||
#include <destrum/Components/UI/UIText.h>
|
||||
#include <destrum/ObjectModel/Component.h>
|
||||
#include <destrum/ObjectModel/GameObject.h>
|
||||
#include <destrum/ObjectModel/Transform.h>
|
||||
#include <destrum/Scene/Scene.h>
|
||||
#include <destrum/Serialization/ComponentFactory.h>
|
||||
#include <destrum/Serialization/ComponentRegistry.h>
|
||||
#include <destrum/Serialization/PrefabSerializer.h>
|
||||
#include <destrum/Util/ImGuiUtils.h>
|
||||
|
||||
namespace {
|
||||
[[nodiscard]] bool IsInspectable(const GameObject* object)
|
||||
{
|
||||
return object != nullptr && !object->IsBeingDestroyed();
|
||||
}
|
||||
|
||||
[[nodiscard]] GameObject* ParentOf(const GameObject& object)
|
||||
{
|
||||
const Transform* parent = object.GetTransform().GetParent();
|
||||
return parent != nullptr ? parent->GetOwner() : nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool HasInspectableChildren(const GameObject& object)
|
||||
{
|
||||
return std::ranges::any_of(object.GetTransform().GetChildren(), [](const Transform* child) {
|
||||
return child != nullptr && IsInspectable(child->GetOwner());
|
||||
});
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<GameObject*> CollectObjects(const Scene& scene)
|
||||
{
|
||||
std::vector<GameObject*> objects;
|
||||
objects.reserve(scene.GetObjects().size() + scene.GetPendingAdditions().size());
|
||||
for (const auto* source : {&scene.GetObjects(), &scene.GetPendingAdditions()}) {
|
||||
for (const auto& object : *source) {
|
||||
if (IsInspectable(object.get())) {
|
||||
objects.push_back(object.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool IsRoot(const GameObject& object)
|
||||
{
|
||||
return !IsInspectable(ParentOf(object));
|
||||
}
|
||||
|
||||
[[nodiscard]] GameObject* NextSibling(const Scene& scene, const GameObject& object)
|
||||
{
|
||||
if (const Transform* parent = object.GetTransform().GetParent()) {
|
||||
const auto& siblings = parent->GetChildren();
|
||||
const auto it = std::ranges::find(siblings, &object.GetTransform());
|
||||
for (auto next = it == siblings.end() ? it : it + 1; next != siblings.end(); ++next) {
|
||||
if (IsInspectable((*next)->GetOwner())) {
|
||||
return (*next)->GetOwner();
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const auto objects = CollectObjects(scene);
|
||||
const auto it = std::ranges::find(objects, &object);
|
||||
for (auto next = it == objects.end() ? it : it + 1; next != objects.end(); ++next) {
|
||||
if (IsRoot(**next)) {
|
||||
return *next;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool ContainsIgnoreCase(std::string_view text, std::string_view query)
|
||||
{
|
||||
const auto it = std::search(text.begin(), text.end(), query.begin(), query.end(), [](char a, char b) {
|
||||
@@ -91,60 +28,6 @@ namespace {
|
||||
return it != text.end();
|
||||
}
|
||||
|
||||
[[nodiscard]] const char* DisplayName(const GameObject& object)
|
||||
{
|
||||
return object.GetName().empty() ? "<unnamed>" : object.GetName().c_str();
|
||||
}
|
||||
|
||||
GameObject* CreateChild(Scene& scene, GameObject* parent, const std::string& name)
|
||||
{
|
||||
GameObject* object = scene.CreateGameObject(name);
|
||||
if (parent != nullptr) {
|
||||
object->GetTransform().SetParent(&parent->GetTransform(), false);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
GameObject* CreateUIChild(Scene& scene, GameObject* parent, const std::string& name)
|
||||
{
|
||||
if (parent == nullptr || Canvas::FindFor(*parent) == nullptr) {
|
||||
parent = CreateChild(scene, parent, "Canvas");
|
||||
parent->AddComponent<Canvas>();
|
||||
}
|
||||
GameObject* object = CreateChild(scene, parent, name);
|
||||
object->AddComponent<RectTransform>();
|
||||
return object;
|
||||
}
|
||||
|
||||
// Returns the created object so the caller can select and rename it
|
||||
GameObject* DrawCreateMenu(Scene& scene, GameObject* parent)
|
||||
{
|
||||
GameObject* created = nullptr;
|
||||
if (ImGui::MenuItem("Empty")) {
|
||||
created = CreateChild(scene, parent, "GameObject");
|
||||
}
|
||||
if (ImGui::BeginMenu("UI")) {
|
||||
if (ImGui::MenuItem("Canvas")) {
|
||||
created = CreateChild(scene, parent, "Canvas");
|
||||
created->AddComponent<Canvas>();
|
||||
}
|
||||
if (ImGui::MenuItem("Rect")) {
|
||||
created = CreateUIChild(scene, parent, "Rect");
|
||||
}
|
||||
if (ImGui::MenuItem("Image")) {
|
||||
created = CreateUIChild(scene, parent, "Image");
|
||||
created->AddComponent<UIImage>();
|
||||
}
|
||||
if (ImGui::MenuItem("Text")) {
|
||||
created = CreateUIChild(scene, parent, "Text");
|
||||
created->GetComponent<RectTransform>()->SetSize({300.0f, 60.0f});
|
||||
created->AddComponent<UIText>("Text", 32.0f);
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
// Right aligned controls drawn over a collapsing header, x is from the start of the line
|
||||
[[nodiscard]] float RightAlignedX(float lineStartX, float lineWidth, float itemsWidth)
|
||||
{
|
||||
@@ -152,364 +35,24 @@ namespace {
|
||||
}
|
||||
}
|
||||
|
||||
void SceneInspector::Draw(Scene& scene, ObjectId& selectedObjectId, const std::function<void()>& drawSceneSettings, bool* open)
|
||||
void SceneInspector::Draw(Scene& scene, ObjectId selectedObjectId, const std::function<void()>& drawSceneSettings, bool* open)
|
||||
{
|
||||
RegisterEngineComponents();
|
||||
|
||||
if (scene.FindGameObject(selectedObjectId) == nullptr) {
|
||||
selectedObjectId = InvalidObjectId;
|
||||
}
|
||||
|
||||
ImGui::SetNextWindowSize(ImVec2(720.0f, 560.0f), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(420.0f, 560.0f), ImGuiCond_FirstUseEver);
|
||||
if (!ImGui::Begin("Inspector", open)) {
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
HandleShortcuts(scene, selectedObjectId);
|
||||
|
||||
if (ImGui::BeginChild("Hierarchy", ImVec2(std::max(220.0f, ImGui::GetContentRegionAvail().x * 0.4f), 0.0f),
|
||||
ImGuiChildFlags_Borders | ImGuiChildFlags_ResizeX)) {
|
||||
DrawHierarchy(scene, selectedObjectId);
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
if (ImGui::BeginChild("Properties", ImVec2(0.0f, 0.0f), ImGuiChildFlags_Borders)) {
|
||||
if (GameObject* selected = scene.FindGameObject(selectedObjectId)) {
|
||||
GameObject* selected = scene.FindGameObject(selectedObjectId);
|
||||
if (selected != nullptr && !selected->IsBeingDestroyed()) {
|
||||
DrawObject(*selected);
|
||||
} else if (drawSceneSettings) {
|
||||
drawSceneSettings();
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::End();
|
||||
|
||||
ApplyPendingActions(scene, selectedObjectId);
|
||||
}
|
||||
|
||||
void SceneInspector::HandleShortcuts(Scene& scene, ObjectId& selectedObjectId)
|
||||
{
|
||||
if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) || ImGui::GetIO().WantTextInput ||
|
||||
selectedObjectId == InvalidObjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_Delete, false)) {
|
||||
m_PendingDelete = selectedObjectId;
|
||||
}
|
||||
if (ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_D)) {
|
||||
m_PendingDuplicate = selectedObjectId;
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_F2, false)) {
|
||||
if (const GameObject* selected = scene.FindGameObject(selectedObjectId)) {
|
||||
BeginRename(*selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SceneInspector::DrawHierarchy(Scene& scene, ObjectId& selectedObjectId)
|
||||
{
|
||||
if (ImGui::Button("+")) {
|
||||
ImGui::OpenPopup("CreateObject");
|
||||
}
|
||||
if (ImGui::BeginPopup("CreateObject")) {
|
||||
if (GameObject* created = DrawCreateMenu(scene, nullptr)) {
|
||||
selectedObjectId = created->GetId();
|
||||
BeginRename(*created);
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
ImGuiUtils::InputString("##Search", m_Filter, 0, "Search");
|
||||
|
||||
if (!ImGui::BeginChild("Tree")) {
|
||||
ImGui::EndChild();
|
||||
return;
|
||||
}
|
||||
|
||||
const std::vector<GameObject*> objects = CollectObjects(scene);
|
||||
std::unordered_set<const GameObject*> visited;
|
||||
visited.reserve(objects.size());
|
||||
|
||||
if (m_Filter.empty()) {
|
||||
for (GameObject* object : objects) {
|
||||
if (IsRoot(*object)) {
|
||||
DrawNode(scene, *object, selectedObjectId, visited);
|
||||
}
|
||||
}
|
||||
// Anything not reachable from a root is stuck in a parent cycle, still show it
|
||||
std::unordered_set<const GameObject*> reachable;
|
||||
std::vector<const GameObject*> pending;
|
||||
for (const GameObject* object : objects) {
|
||||
if (IsRoot(*object)) {
|
||||
pending.push_back(object);
|
||||
}
|
||||
}
|
||||
while (!pending.empty()) {
|
||||
const GameObject* object = pending.back();
|
||||
pending.pop_back();
|
||||
if (!reachable.insert(object).second) {
|
||||
continue;
|
||||
}
|
||||
for (const Transform* child : object->GetTransform().GetChildren()) {
|
||||
if (child != nullptr && IsInspectable(child->GetOwner())) {
|
||||
pending.push_back(child->GetOwner());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (GameObject* object : objects) {
|
||||
if (!reachable.contains(object)) {
|
||||
DrawNode(scene, *object, selectedObjectId, visited);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (GameObject* object : objects) {
|
||||
if (ContainsIgnoreCase(object->GetName(), m_Filter)) {
|
||||
DrawNode(scene, *object, selectedObjectId, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The empty space below the tree: click to deselect, drop to move to the root, right click to create
|
||||
const ImVec2 remaining = ImGui::GetContentRegionAvail();
|
||||
ImGui::InvisibleButton("##Background", ImVec2(std::max(remaining.x, 1.0f), std::max(remaining.y, ImGui::GetFrameHeight())));
|
||||
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
|
||||
selectedObjectId = InvalidObjectId;
|
||||
}
|
||||
if (ImGui::BeginDragDropTarget()) {
|
||||
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(ImGuiUtils::GameObjectPayload)) {
|
||||
m_PendingMove = {.object = *static_cast<const ObjectId*>(payload->Data)};
|
||||
}
|
||||
ImGui::EndDragDropTarget();
|
||||
}
|
||||
if (ImGui::BeginPopupContextItem("BackgroundMenu")) {
|
||||
DrawObjectMenu(scene, nullptr, selectedObjectId);
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
void SceneInspector::DrawNode(Scene& scene, GameObject& object, ObjectId& selectedObjectId,
|
||||
std::unordered_set<const GameObject*>& visited)
|
||||
{
|
||||
if (!IsInspectable(&object) || !visited.insert(&object).second) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool flat = !m_Filter.empty();
|
||||
const bool hasChildren = !flat && HasInspectableChildren(object);
|
||||
const bool renaming = m_RenamingId == object.GetId();
|
||||
|
||||
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanFullWidth |
|
||||
ImGuiTreeNodeFlags_DrawLinesToNodes;
|
||||
if (object.GetId() == selectedObjectId) {
|
||||
flags |= ImGuiTreeNodeFlags_Selected;
|
||||
}
|
||||
if (!hasChildren) {
|
||||
flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen;
|
||||
}
|
||||
if (m_OpenNextFrame == object.GetId()) {
|
||||
ImGui::SetNextItemOpen(true);
|
||||
m_OpenNextFrame = InvalidObjectId;
|
||||
}
|
||||
|
||||
const bool inactive = !object.IsActiveInHierarchy();
|
||||
if (inactive) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
|
||||
}
|
||||
const bool open = ImGui::TreeNodeEx(reinterpret_cast<void*>(static_cast<std::uintptr_t>(object.GetId())), flags, "%s",
|
||||
renaming ? "" : DisplayName(object));
|
||||
if (inactive) {
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
|
||||
if ((ImGui::IsItemClicked(ImGuiMouseButton_Left) || ImGui::IsItemClicked(ImGuiMouseButton_Right)) &&
|
||||
!ImGui::IsItemToggledOpen()) {
|
||||
selectedObjectId = object.GetId();
|
||||
}
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && !ImGui::IsItemToggledOpen()) {
|
||||
BeginRename(object);
|
||||
}
|
||||
|
||||
DrawNodeDragDrop(object);
|
||||
|
||||
// Leaf nodes push no ID, without this every sibling shares one popup and draws it again
|
||||
ImGui::PushID(reinterpret_cast<void*>(static_cast<std::uintptr_t>(object.GetId())));
|
||||
if (ImGui::BeginPopupContextItem("ObjectMenu")) {
|
||||
DrawObjectMenu(scene, &object, selectedObjectId);
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImGui::PopID();
|
||||
|
||||
if (renaming) {
|
||||
ImGui::SameLine();
|
||||
DrawRenameField(object);
|
||||
}
|
||||
|
||||
if (open && hasChildren) {
|
||||
for (Transform* child : object.GetTransform().GetChildren()) {
|
||||
if (child != nullptr && IsInspectable(child->GetOwner())) {
|
||||
DrawNode(scene, *child->GetOwner(), selectedObjectId, visited);
|
||||
}
|
||||
}
|
||||
ImGui::TreePop();
|
||||
}
|
||||
}
|
||||
|
||||
void SceneInspector::DrawNodeDragDrop(GameObject& object)
|
||||
{
|
||||
const ObjectId id = object.GetId();
|
||||
if (ImGui::BeginDragDropSource()) {
|
||||
ImGui::SetDragDropPayload(ImGuiUtils::GameObjectPayload, &id, sizeof(id));
|
||||
ImGui::TextUnformatted(DisplayName(object));
|
||||
ImGui::EndDragDropSource();
|
||||
}
|
||||
|
||||
if (!ImGui::BeginDragDropTarget()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(
|
||||
ImGuiUtils::GameObjectPayload,
|
||||
ImGuiDragDropFlags_AcceptBeforeDelivery | ImGuiDragDropFlags_AcceptNoDrawDefaultRect);
|
||||
if (payload != nullptr) {
|
||||
const ObjectId dragged = *static_cast<const ObjectId*>(payload->Data);
|
||||
const ImVec2 min = ImGui::GetItemRectMin();
|
||||
const ImVec2 max = ImGui::GetItemRectMax();
|
||||
const float zone = (max.y - min.y) * 0.25f;
|
||||
const float mouseY = ImGui::GetMousePos().y;
|
||||
const bool before = mouseY < min.y + zone;
|
||||
const bool after = mouseY > max.y - zone;
|
||||
|
||||
ImDrawList* drawList = ImGui::GetWindowDrawList();
|
||||
const ImU32 color = ImGui::GetColorU32(ImGuiCol_DragDropTarget);
|
||||
if (before) {
|
||||
drawList->AddLine(min, ImVec2(max.x, min.y), color, 2.0f);
|
||||
} else if (after) {
|
||||
drawList->AddLine(ImVec2(min.x, max.y), max, color, 2.0f);
|
||||
} else {
|
||||
drawList->AddRect(min, max, color, 0.0f, 0, 2.0f);
|
||||
}
|
||||
|
||||
if (payload->IsDelivery() && dragged != id) {
|
||||
const GameObject* parent = ParentOf(object);
|
||||
const ObjectId parentId = IsInspectable(parent) ? parent->GetId() : InvalidObjectId;
|
||||
if (before || after) {
|
||||
m_PendingMove = {.object = dragged, .parent = parentId, .anchor = id, .after = after};
|
||||
} else {
|
||||
m_PendingMove = {.object = dragged, .parent = id};
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::EndDragDropTarget();
|
||||
}
|
||||
|
||||
void SceneInspector::DrawRenameField(GameObject& object)
|
||||
{
|
||||
if (m_FocusRename) {
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
m_FocusRename = false;
|
||||
}
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
const bool submitted = ImGuiUtils::InputString("##Rename", m_RenameBuffer,
|
||||
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll);
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
|
||||
m_RenamingId = InvalidObjectId;
|
||||
return;
|
||||
}
|
||||
if (submitted || ImGui::IsItemDeactivated()) {
|
||||
if (!m_RenameBuffer.empty()) {
|
||||
object.SetName(m_RenameBuffer);
|
||||
}
|
||||
m_RenamingId = InvalidObjectId;
|
||||
}
|
||||
}
|
||||
|
||||
void SceneInspector::BeginRename(const GameObject& object)
|
||||
{
|
||||
m_RenamingId = object.GetId();
|
||||
m_RenameBuffer = object.GetName();
|
||||
m_FocusRename = true;
|
||||
}
|
||||
|
||||
void SceneInspector::DrawObjectMenu(Scene& scene, GameObject* object, ObjectId& selectedObjectId)
|
||||
{
|
||||
if (ImGui::BeginMenu(object != nullptr ? "Create Child" : "Create")) {
|
||||
if (GameObject* created = DrawCreateMenu(scene, object)) {
|
||||
selectedObjectId = created->GetId();
|
||||
if (object != nullptr) {
|
||||
m_OpenNextFrame = object->GetId();
|
||||
}
|
||||
BeginRename(*created);
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
if (object == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("Rename", "F2")) {
|
||||
BeginRename(*object);
|
||||
}
|
||||
if (ImGui::MenuItem("Duplicate", "Ctrl+D")) {
|
||||
m_PendingDuplicate = object->GetId();
|
||||
}
|
||||
if (ImGui::MenuItem("Unparent", nullptr, false, !IsRoot(*object))) {
|
||||
m_PendingMove = {.object = object->GetId()};
|
||||
}
|
||||
bool active = object->IsActive();
|
||||
if (ImGui::MenuItem("Active", nullptr, &active)) {
|
||||
object->SetActive(active);
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("Delete", "Del")) {
|
||||
m_PendingDelete = object->GetId();
|
||||
}
|
||||
}
|
||||
|
||||
void SceneInspector::ApplyPendingActions(Scene& scene, ObjectId& selectedObjectId)
|
||||
{
|
||||
if (m_PendingMove.object != InvalidObjectId) {
|
||||
GameObject* object = scene.FindGameObject(m_PendingMove.object);
|
||||
GameObject* parent = scene.FindGameObject(m_PendingMove.parent);
|
||||
GameObject* insertBefore = scene.FindGameObject(m_PendingMove.anchor);
|
||||
if (insertBefore != nullptr && m_PendingMove.after) {
|
||||
insertBefore = NextSibling(scene, *insertBefore);
|
||||
}
|
||||
if (insertBefore == object && object != nullptr) {
|
||||
insertBefore = NextSibling(scene, *object);
|
||||
}
|
||||
if (object != nullptr && scene.MoveInHierarchy(*object, parent, insertBefore) && parent != nullptr) {
|
||||
m_OpenNextFrame = parent->GetId();
|
||||
}
|
||||
m_PendingMove = {};
|
||||
}
|
||||
|
||||
if (m_PendingDuplicate != InvalidObjectId) {
|
||||
if (GameObject* object = scene.FindGameObject(m_PendingDuplicate)) {
|
||||
if (const GameObject* duplicate = PrefabSerializer::Duplicate(*object)) {
|
||||
selectedObjectId = duplicate->GetId();
|
||||
}
|
||||
}
|
||||
m_PendingDuplicate = InvalidObjectId;
|
||||
}
|
||||
|
||||
if (m_PendingDelete != InvalidObjectId) {
|
||||
if (GameObject* object = scene.FindGameObject(m_PendingDelete)) {
|
||||
object->Destroy();
|
||||
}
|
||||
if (selectedObjectId == m_PendingDelete) {
|
||||
selectedObjectId = InvalidObjectId;
|
||||
}
|
||||
m_PendingDelete = InvalidObjectId;
|
||||
}
|
||||
}
|
||||
|
||||
void SceneInspector::DrawObject(GameObject& object)
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
#include <editor/SceneTree.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cfloat>
|
||||
#include <vector>
|
||||
|
||||
#include <imgui.h>
|
||||
|
||||
#include <destrum/Components/UI/Canvas.h>
|
||||
#include <destrum/Components/UI/RectTransform.h>
|
||||
#include <destrum/Components/UI/UIImage.h>
|
||||
#include <destrum/Components/UI/UIText.h>
|
||||
#include <destrum/ObjectModel/GameObject.h>
|
||||
#include <destrum/ObjectModel/Transform.h>
|
||||
#include <destrum/Scene/Scene.h>
|
||||
#include <destrum/Serialization/PrefabSerializer.h>
|
||||
#include <destrum/Util/ImGuiUtils.h>
|
||||
|
||||
namespace {
|
||||
[[nodiscard]] bool IsInspectable(const GameObject* object)
|
||||
{
|
||||
return object != nullptr && !object->IsBeingDestroyed();
|
||||
}
|
||||
|
||||
[[nodiscard]] GameObject* ParentOf(const GameObject& object)
|
||||
{
|
||||
const Transform* parent = object.GetTransform().GetParent();
|
||||
return parent != nullptr ? parent->GetOwner() : nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool HasInspectableChildren(const GameObject& object)
|
||||
{
|
||||
return std::ranges::any_of(object.GetTransform().GetChildren(), [](const Transform* child) {
|
||||
return child != nullptr && IsInspectable(child->GetOwner());
|
||||
});
|
||||
}
|
||||
|
||||
[[nodiscard]] std::vector<GameObject*> CollectObjects(const Scene& scene)
|
||||
{
|
||||
std::vector<GameObject*> objects;
|
||||
objects.reserve(scene.GetObjects().size() + scene.GetPendingAdditions().size());
|
||||
for (const auto* source : {&scene.GetObjects(), &scene.GetPendingAdditions()}) {
|
||||
for (const auto& object : *source) {
|
||||
if (IsInspectable(object.get())) {
|
||||
objects.push_back(object.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool IsRoot(const GameObject& object)
|
||||
{
|
||||
return !IsInspectable(ParentOf(object));
|
||||
}
|
||||
|
||||
[[nodiscard]] GameObject* NextSibling(const Scene& scene, const GameObject& object)
|
||||
{
|
||||
if (const Transform* parent = object.GetTransform().GetParent()) {
|
||||
const auto& siblings = parent->GetChildren();
|
||||
const auto it = std::ranges::find(siblings, &object.GetTransform());
|
||||
for (auto next = it == siblings.end() ? it : it + 1; next != siblings.end(); ++next) {
|
||||
if (IsInspectable((*next)->GetOwner())) {
|
||||
return (*next)->GetOwner();
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const auto objects = CollectObjects(scene);
|
||||
const auto it = std::ranges::find(objects, &object);
|
||||
for (auto next = it == objects.end() ? it : it + 1; next != objects.end(); ++next) {
|
||||
if (IsRoot(**next)) {
|
||||
return *next;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool ContainsIgnoreCase(std::string_view text, std::string_view query)
|
||||
{
|
||||
const auto it = std::search(text.begin(), text.end(), query.begin(), query.end(), [](char a, char b) {
|
||||
return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b));
|
||||
});
|
||||
return it != text.end();
|
||||
}
|
||||
|
||||
[[nodiscard]] const char* DisplayName(const GameObject& object)
|
||||
{
|
||||
return object.GetName().empty() ? "<unnamed>" : object.GetName().c_str();
|
||||
}
|
||||
|
||||
GameObject* CreateChild(Scene& scene, GameObject* parent, const std::string& name)
|
||||
{
|
||||
GameObject* object = scene.CreateGameObject(name);
|
||||
if (parent != nullptr) {
|
||||
object->GetTransform().SetParent(&parent->GetTransform(), false);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
GameObject* CreateUIChild(Scene& scene, GameObject* parent, const std::string& name)
|
||||
{
|
||||
if (parent == nullptr || Canvas::FindFor(*parent) == nullptr) {
|
||||
parent = CreateChild(scene, parent, "Canvas");
|
||||
parent->AddComponent<Canvas>();
|
||||
}
|
||||
GameObject* object = CreateChild(scene, parent, name);
|
||||
object->AddComponent<RectTransform>();
|
||||
return object;
|
||||
}
|
||||
|
||||
// Returns the created object so the caller can select and rename it
|
||||
GameObject* DrawCreateMenu(Scene& scene, GameObject* parent)
|
||||
{
|
||||
GameObject* created = nullptr;
|
||||
if (ImGui::MenuItem("Empty")) {
|
||||
created = CreateChild(scene, parent, "GameObject");
|
||||
}
|
||||
if (ImGui::BeginMenu("UI")) {
|
||||
if (ImGui::MenuItem("Canvas")) {
|
||||
created = CreateChild(scene, parent, "Canvas");
|
||||
created->AddComponent<Canvas>();
|
||||
}
|
||||
if (ImGui::MenuItem("Rect")) {
|
||||
created = CreateUIChild(scene, parent, "Rect");
|
||||
}
|
||||
if (ImGui::MenuItem("Image")) {
|
||||
created = CreateUIChild(scene, parent, "Image");
|
||||
created->AddComponent<UIImage>();
|
||||
}
|
||||
if (ImGui::MenuItem("Text")) {
|
||||
created = CreateUIChild(scene, parent, "Text");
|
||||
created->GetComponent<RectTransform>()->SetSize({300.0f, 60.0f});
|
||||
created->AddComponent<UIText>("Text", 32.0f);
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
void SceneTree::Draw(Scene& scene, ObjectId& selectedObjectId, bool* open)
|
||||
{
|
||||
if (scene.FindGameObject(selectedObjectId) == nullptr) {
|
||||
selectedObjectId = InvalidObjectId;
|
||||
}
|
||||
|
||||
ImGui::SetNextWindowSize(ImVec2(320.0f, 560.0f), ImGuiCond_FirstUseEver);
|
||||
if (!ImGui::Begin("Scene Tree", open)) {
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
HandleShortcuts(scene, selectedObjectId);
|
||||
DrawHierarchy(scene, selectedObjectId);
|
||||
|
||||
ImGui::End();
|
||||
|
||||
ApplyPendingActions(scene, selectedObjectId);
|
||||
}
|
||||
|
||||
void SceneTree::HandleShortcuts(Scene& scene, ObjectId& selectedObjectId)
|
||||
{
|
||||
if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) || ImGui::GetIO().WantTextInput ||
|
||||
selectedObjectId == InvalidObjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_Delete, false)) {
|
||||
m_PendingDelete = selectedObjectId;
|
||||
}
|
||||
if (ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_D)) {
|
||||
m_PendingDuplicate = selectedObjectId;
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_F2, false)) {
|
||||
if (const GameObject* selected = scene.FindGameObject(selectedObjectId)) {
|
||||
BeginRename(*selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SceneTree::DrawHierarchy(Scene& scene, ObjectId& selectedObjectId)
|
||||
{
|
||||
if (ImGui::Button("+")) {
|
||||
ImGui::OpenPopup("CreateObject");
|
||||
}
|
||||
if (ImGui::BeginPopup("CreateObject")) {
|
||||
if (GameObject* created = DrawCreateMenu(scene, nullptr)) {
|
||||
selectedObjectId = created->GetId();
|
||||
BeginRename(*created);
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
ImGuiUtils::InputString("##Search", m_Filter, 0, "Search");
|
||||
|
||||
if (!ImGui::BeginChild("Tree")) {
|
||||
ImGui::EndChild();
|
||||
return;
|
||||
}
|
||||
|
||||
const std::vector<GameObject*> objects = CollectObjects(scene);
|
||||
std::unordered_set<const GameObject*> visited;
|
||||
visited.reserve(objects.size());
|
||||
|
||||
if (m_Filter.empty()) {
|
||||
for (GameObject* object : objects) {
|
||||
if (IsRoot(*object)) {
|
||||
DrawNode(scene, *object, selectedObjectId, visited);
|
||||
}
|
||||
}
|
||||
// Anything not reachable from a root is stuck in a parent cycle, still show it
|
||||
std::unordered_set<const GameObject*> reachable;
|
||||
std::vector<const GameObject*> pending;
|
||||
for (const GameObject* object : objects) {
|
||||
if (IsRoot(*object)) {
|
||||
pending.push_back(object);
|
||||
}
|
||||
}
|
||||
while (!pending.empty()) {
|
||||
const GameObject* object = pending.back();
|
||||
pending.pop_back();
|
||||
if (!reachable.insert(object).second) {
|
||||
continue;
|
||||
}
|
||||
for (const Transform* child : object->GetTransform().GetChildren()) {
|
||||
if (child != nullptr && IsInspectable(child->GetOwner())) {
|
||||
pending.push_back(child->GetOwner());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (GameObject* object : objects) {
|
||||
if (!reachable.contains(object)) {
|
||||
DrawNode(scene, *object, selectedObjectId, visited);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (GameObject* object : objects) {
|
||||
if (ContainsIgnoreCase(object->GetName(), m_Filter)) {
|
||||
DrawNode(scene, *object, selectedObjectId, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The empty space below the tree: click to deselect, drop to move to the root, right click to create
|
||||
const ImVec2 remaining = ImGui::GetContentRegionAvail();
|
||||
ImGui::InvisibleButton("##Background", ImVec2(std::max(remaining.x, 1.0f), std::max(remaining.y, ImGui::GetFrameHeight())));
|
||||
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
|
||||
selectedObjectId = InvalidObjectId;
|
||||
}
|
||||
if (ImGui::BeginDragDropTarget()) {
|
||||
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(ImGuiUtils::GameObjectPayload)) {
|
||||
m_PendingMove = {.object = *static_cast<const ObjectId*>(payload->Data)};
|
||||
}
|
||||
ImGui::EndDragDropTarget();
|
||||
}
|
||||
if (ImGui::BeginPopupContextItem("BackgroundMenu")) {
|
||||
DrawObjectMenu(scene, nullptr, selectedObjectId);
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
void SceneTree::DrawNode(Scene& scene, GameObject& object, ObjectId& selectedObjectId,
|
||||
std::unordered_set<const GameObject*>& visited)
|
||||
{
|
||||
if (!IsInspectable(&object) || !visited.insert(&object).second) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool flat = !m_Filter.empty();
|
||||
const bool hasChildren = !flat && HasInspectableChildren(object);
|
||||
const bool renaming = m_RenamingId == object.GetId();
|
||||
|
||||
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_SpanFullWidth |
|
||||
ImGuiTreeNodeFlags_DrawLinesToNodes;
|
||||
if (object.GetId() == selectedObjectId) {
|
||||
flags |= ImGuiTreeNodeFlags_Selected;
|
||||
}
|
||||
if (!hasChildren) {
|
||||
flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen;
|
||||
}
|
||||
if (m_OpenNextFrame == object.GetId()) {
|
||||
ImGui::SetNextItemOpen(true);
|
||||
m_OpenNextFrame = InvalidObjectId;
|
||||
}
|
||||
|
||||
const bool inactive = !object.IsActiveInHierarchy();
|
||||
if (inactive) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
|
||||
}
|
||||
const bool open = ImGui::TreeNodeEx(reinterpret_cast<void*>(static_cast<std::uintptr_t>(object.GetId())), flags, "%s",
|
||||
renaming ? "" : DisplayName(object));
|
||||
if (inactive) {
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
|
||||
if ((ImGui::IsItemClicked(ImGuiMouseButton_Left) || ImGui::IsItemClicked(ImGuiMouseButton_Right)) &&
|
||||
!ImGui::IsItemToggledOpen()) {
|
||||
selectedObjectId = object.GetId();
|
||||
}
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && !ImGui::IsItemToggledOpen()) {
|
||||
BeginRename(object);
|
||||
}
|
||||
|
||||
DrawNodeDragDrop(object);
|
||||
|
||||
// Leaf nodes push no ID, without this every sibling shares one popup and draws it again
|
||||
ImGui::PushID(reinterpret_cast<void*>(static_cast<std::uintptr_t>(object.GetId())));
|
||||
if (ImGui::BeginPopupContextItem("ObjectMenu")) {
|
||||
DrawObjectMenu(scene, &object, selectedObjectId);
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImGui::PopID();
|
||||
|
||||
if (renaming) {
|
||||
ImGui::SameLine();
|
||||
DrawRenameField(object);
|
||||
}
|
||||
|
||||
if (open && hasChildren) {
|
||||
for (Transform* child : object.GetTransform().GetChildren()) {
|
||||
if (child != nullptr && IsInspectable(child->GetOwner())) {
|
||||
DrawNode(scene, *child->GetOwner(), selectedObjectId, visited);
|
||||
}
|
||||
}
|
||||
ImGui::TreePop();
|
||||
}
|
||||
}
|
||||
|
||||
void SceneTree::DrawNodeDragDrop(GameObject& object)
|
||||
{
|
||||
const ObjectId id = object.GetId();
|
||||
if (ImGui::BeginDragDropSource()) {
|
||||
ImGui::SetDragDropPayload(ImGuiUtils::GameObjectPayload, &id, sizeof(id));
|
||||
ImGui::TextUnformatted(DisplayName(object));
|
||||
ImGui::EndDragDropSource();
|
||||
}
|
||||
|
||||
if (!ImGui::BeginDragDropTarget()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ImGuiPayload* payload = ImGui::AcceptDragDropPayload(
|
||||
ImGuiUtils::GameObjectPayload,
|
||||
ImGuiDragDropFlags_AcceptBeforeDelivery | ImGuiDragDropFlags_AcceptNoDrawDefaultRect);
|
||||
if (payload != nullptr) {
|
||||
const ObjectId dragged = *static_cast<const ObjectId*>(payload->Data);
|
||||
const ImVec2 min = ImGui::GetItemRectMin();
|
||||
const ImVec2 max = ImGui::GetItemRectMax();
|
||||
const float zone = (max.y - min.y) * 0.25f;
|
||||
const float mouseY = ImGui::GetMousePos().y;
|
||||
const bool before = mouseY < min.y + zone;
|
||||
const bool after = mouseY > max.y - zone;
|
||||
|
||||
ImDrawList* drawList = ImGui::GetWindowDrawList();
|
||||
const ImU32 color = ImGui::GetColorU32(ImGuiCol_DragDropTarget);
|
||||
if (before) {
|
||||
drawList->AddLine(min, ImVec2(max.x, min.y), color, 2.0f);
|
||||
} else if (after) {
|
||||
drawList->AddLine(ImVec2(min.x, max.y), max, color, 2.0f);
|
||||
} else {
|
||||
drawList->AddRect(min, max, color, 0.0f, 0, 2.0f);
|
||||
}
|
||||
|
||||
if (payload->IsDelivery() && dragged != id) {
|
||||
const GameObject* parent = ParentOf(object);
|
||||
const ObjectId parentId = IsInspectable(parent) ? parent->GetId() : InvalidObjectId;
|
||||
if (before || after) {
|
||||
m_PendingMove = {.object = dragged, .parent = parentId, .anchor = id, .after = after};
|
||||
} else {
|
||||
m_PendingMove = {.object = dragged, .parent = id};
|
||||
}
|
||||
}
|
||||
}
|
||||
ImGui::EndDragDropTarget();
|
||||
}
|
||||
|
||||
void SceneTree::DrawRenameField(GameObject& object)
|
||||
{
|
||||
if (m_FocusRename) {
|
||||
ImGui::SetKeyboardFocusHere();
|
||||
m_FocusRename = false;
|
||||
}
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
const bool submitted = ImGuiUtils::InputString("##Rename", m_RenameBuffer,
|
||||
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll);
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
|
||||
m_RenamingId = InvalidObjectId;
|
||||
return;
|
||||
}
|
||||
if (submitted || ImGui::IsItemDeactivated()) {
|
||||
if (!m_RenameBuffer.empty()) {
|
||||
object.SetName(m_RenameBuffer);
|
||||
}
|
||||
m_RenamingId = InvalidObjectId;
|
||||
}
|
||||
}
|
||||
|
||||
void SceneTree::BeginRename(const GameObject& object)
|
||||
{
|
||||
m_RenamingId = object.GetId();
|
||||
m_RenameBuffer = object.GetName();
|
||||
m_FocusRename = true;
|
||||
}
|
||||
|
||||
void SceneTree::DrawObjectMenu(Scene& scene, GameObject* object, ObjectId& selectedObjectId)
|
||||
{
|
||||
if (ImGui::BeginMenu(object != nullptr ? "Create Child" : "Create")) {
|
||||
if (GameObject* created = DrawCreateMenu(scene, object)) {
|
||||
selectedObjectId = created->GetId();
|
||||
if (object != nullptr) {
|
||||
m_OpenNextFrame = object->GetId();
|
||||
}
|
||||
BeginRename(*created);
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
|
||||
if (object == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("Rename", "F2")) {
|
||||
BeginRename(*object);
|
||||
}
|
||||
if (ImGui::MenuItem("Duplicate", "Ctrl+D")) {
|
||||
m_PendingDuplicate = object->GetId();
|
||||
}
|
||||
if (ImGui::MenuItem("Unparent", nullptr, false, !IsRoot(*object))) {
|
||||
m_PendingMove = {.object = object->GetId()};
|
||||
}
|
||||
bool active = object->IsActive();
|
||||
if (ImGui::MenuItem("Active", nullptr, &active)) {
|
||||
object->SetActive(active);
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("Delete", "Del")) {
|
||||
m_PendingDelete = object->GetId();
|
||||
}
|
||||
}
|
||||
|
||||
void SceneTree::ApplyPendingActions(Scene& scene, ObjectId& selectedObjectId)
|
||||
{
|
||||
if (m_PendingMove.object != InvalidObjectId) {
|
||||
GameObject* object = scene.FindGameObject(m_PendingMove.object);
|
||||
GameObject* parent = scene.FindGameObject(m_PendingMove.parent);
|
||||
GameObject* insertBefore = scene.FindGameObject(m_PendingMove.anchor);
|
||||
if (insertBefore != nullptr && m_PendingMove.after) {
|
||||
insertBefore = NextSibling(scene, *insertBefore);
|
||||
}
|
||||
if (insertBefore == object && object != nullptr) {
|
||||
insertBefore = NextSibling(scene, *object);
|
||||
}
|
||||
if (object != nullptr && scene.MoveInHierarchy(*object, parent, insertBefore) && parent != nullptr) {
|
||||
m_OpenNextFrame = parent->GetId();
|
||||
}
|
||||
m_PendingMove = {};
|
||||
}
|
||||
|
||||
if (m_PendingDuplicate != InvalidObjectId) {
|
||||
if (GameObject* object = scene.FindGameObject(m_PendingDuplicate)) {
|
||||
if (const GameObject* duplicate = PrefabSerializer::Duplicate(*object)) {
|
||||
selectedObjectId = duplicate->GetId();
|
||||
}
|
||||
}
|
||||
m_PendingDuplicate = InvalidObjectId;
|
||||
}
|
||||
|
||||
if (m_PendingDelete != InvalidObjectId) {
|
||||
if (GameObject* object = scene.FindGameObject(m_PendingDelete)) {
|
||||
object->Destroy();
|
||||
}
|
||||
if (selectedObjectId == m_PendingDelete) {
|
||||
selectedObjectId = InvalidObjectId;
|
||||
}
|
||||
m_PendingDelete = InvalidObjectId;
|
||||
}
|
||||
}
|
||||
@@ -89,10 +89,21 @@ add_custom_target(_internal_cook_game_assets
|
||||
COMMAND TheChef
|
||||
--input "${ASSETS_SRC_DIR}"
|
||||
--output "${ASSETS_RUNTIME_DIR}"
|
||||
${DESTRUM_CHEF_FLAGS}
|
||||
DEPENDS TheChef ${GAME_ASSET_SOURCES}
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
add_custom_target(_internal_validate_game_assets
|
||||
COMMAND TheChef
|
||||
--input "${ASSETS_SRC_DIR}"
|
||||
--output "${ASSETS_RUNTIME_DIR}"
|
||||
--validate
|
||||
${DESTRUM_CHEF_FLAGS}
|
||||
DEPENDS _internal_cook_game_assets
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
add_dependencies(lightkeeper _internal_cook_game_assets)
|
||||
|
||||
destrum_cook_engine_assets(lightkeeper "${CMAKE_CURRENT_BINARY_DIR}")
|
||||
|
||||
@@ -11,6 +11,7 @@ add_executable(destrum_tests
|
||||
editor_tests.cpp
|
||||
audio_tests.cpp
|
||||
jobs_tests.cpp
|
||||
cooker_tests.cpp
|
||||
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Activatable.cpp
|
||||
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/Interactable.cpp
|
||||
${CMAKE_SOURCE_DIR}/lightkeeper/src/components/InteractableButton.cpp
|
||||
@@ -32,13 +33,15 @@ target_compile_definitions(destrum_tests PRIVATE
|
||||
SDL_MAIN_HANDLED
|
||||
DESTRUM_ENGINE_ASSETS_SRC_DIR="${CMAKE_SOURCE_DIR}/destrum/assets_src"
|
||||
LIGHTKEEPER_ASSETS_SRC_DIR="${CMAKE_SOURCE_DIR}/lightkeeper/assets_src"
|
||||
DESTRUM_THECHEF_PATH="$<TARGET_FILE:TheChef>"
|
||||
)
|
||||
add_dependencies(destrum_tests TheChef)
|
||||
target_include_directories(destrum_tests PRIVATE "${CMAKE_SOURCE_DIR}/lightkeeper/include")
|
||||
target_link_libraries(destrum_tests PRIVATE destrum::destrum destrum::editor)
|
||||
|
||||
destrum_enable_warnings(destrum_tests)
|
||||
destrum_copy_runtime_dlls(destrum_tests)
|
||||
|
||||
foreach(suite core serialization physics assets graphics ui gameplay editor audio jobs)
|
||||
foreach(suite core serialization physics assets graphics ui gameplay editor audio jobs cooker)
|
||||
add_test(NAME destrum_tests.${suite} COMMAND destrum_tests ${suite})
|
||||
endforeach()
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "TestUtils.h"
|
||||
|
||||
using namespace test;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
struct CookDirs {
|
||||
fs::path root;
|
||||
fs::path input;
|
||||
fs::path output;
|
||||
};
|
||||
|
||||
CookDirs makeDirs(const char* name)
|
||||
{
|
||||
CookDirs dirs;
|
||||
dirs.root = fs::temp_directory_path() / name;
|
||||
fs::remove_all(dirs.root);
|
||||
dirs.input = dirs.root / "src";
|
||||
dirs.output = dirs.root / "out";
|
||||
fs::create_directories(dirs.input);
|
||||
return dirs;
|
||||
}
|
||||
|
||||
void writeText(const fs::path& path, const std::string& text)
|
||||
{
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream{path, std::ios::binary} << text;
|
||||
}
|
||||
|
||||
// 2x2 uncompressed 32 bit TGA, top left origin
|
||||
void writeTga(const fs::path& path)
|
||||
{
|
||||
const std::uint8_t header[18] = {0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 32, 0x28};
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream file(path, std::ios::binary);
|
||||
file.write(reinterpret_cast<const char*>(header), sizeof(header));
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
const std::uint8_t bgra[4] = {0, 0, 255, 255};
|
||||
file.write(reinterpret_cast<const char*>(bgra), sizeof(bgra));
|
||||
}
|
||||
}
|
||||
|
||||
int runChef(const CookDirs& dirs, const std::string& flags = {})
|
||||
{
|
||||
const fs::path log = dirs.root / "chef.log";
|
||||
std::string command = "\"" + std::string(DESTRUM_THECHEF_PATH) + "\" --input \"" + dirs.input.string() +
|
||||
"\" --output \"" + dirs.output.string() + "\" " + flags + " > \"" + log.string() +
|
||||
"\" 2>&1";
|
||||
#ifdef _WIN32
|
||||
// cmd.exe strips the outer quotes, the inner ones keep paths with spaces intact
|
||||
command = "\"" + command + "\"";
|
||||
#endif
|
||||
return std::system(command.c_str());
|
||||
}
|
||||
|
||||
void expectChef(const CookDirs& dirs, const std::string& flags, bool succeeds, const char* message)
|
||||
{
|
||||
if ((runChef(dirs, flags) == 0) != succeeds) {
|
||||
std::cerr << readFile(dirs.root / "chef.log") << '\n';
|
||||
check(false, message);
|
||||
}
|
||||
}
|
||||
|
||||
nlohmann::json readManifest(const CookDirs& dirs)
|
||||
{
|
||||
return nlohmann::json::parse(readFile(dirs.output / "manifest.json"));
|
||||
}
|
||||
|
||||
const nlohmann::json* findEntry(const nlohmann::json& manifest, const std::string& src)
|
||||
{
|
||||
for (const auto& entry : manifest["assets"]) {
|
||||
if (entry["src"] == src) {
|
||||
return &entry;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Puts the output well before its dependency, so the next cook has to rebuild it without sleeping
|
||||
fs::file_time_type makeStale(const fs::path& output, const fs::path& dependency)
|
||||
{
|
||||
const auto stale = fs::last_write_time(dependency) - std::chrono::hours{1};
|
||||
fs::last_write_time(output, stale);
|
||||
return stale;
|
||||
}
|
||||
|
||||
bool hasTempFiles(const fs::path& dir)
|
||||
{
|
||||
for (const auto& entry : fs::recursive_directory_iterator(dir)) {
|
||||
if (entry.path().extension() == ".tmp") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void writeShaders(const CookDirs& dirs)
|
||||
{
|
||||
writeText(dirs.input / "shaders" / "include" / "common.glsl", "vec4 tint() { return vec4(1.0); }\n");
|
||||
writeText(dirs.input / "shaders" / "plain.frag",
|
||||
"#version 450\n"
|
||||
"#extension GL_GOOGLE_include_directive : require\n"
|
||||
"#include \"common.glsl\"\n"
|
||||
"layout(location = 0) out vec4 color;\n"
|
||||
"void main() { color = tint(); }\n");
|
||||
}
|
||||
|
||||
void writeModel(const CookDirs& dirs)
|
||||
{
|
||||
writeText(dirs.input / "models" / "tri.obj",
|
||||
"mtllib tri.mtl\n"
|
||||
"v 0 0 0\nv 1 0 0\nv 0 1 0\n"
|
||||
"vt 0 0\nvt 1 0\nvt 0 1\n"
|
||||
"vn 0 0 1\n"
|
||||
"usemtl red\n"
|
||||
"f 1/1/1 2/2/1 3/3/1\n");
|
||||
writeText(dirs.input / "models" / "tri.mtl", "newmtl red\nKd 1 0 0\n");
|
||||
}
|
||||
|
||||
void testCookRecordsEveryOutput()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_outputs");
|
||||
writeShaders(dirs);
|
||||
writeModel(dirs);
|
||||
writeTga(dirs.input / "textures" / "red.tga");
|
||||
writeText(dirs.input / "data" / "notes.txt", "hello");
|
||||
|
||||
expectChef(dirs, "", true, "a clean cook must succeed");
|
||||
|
||||
check(fs::exists(dirs.output / "shaders" / "plain.frag.spv"), "entry shaders compile to SPIR-V");
|
||||
check(fs::exists(dirs.output / "models" / "tri.obj.dmodel"), "models cook to .dmodel");
|
||||
check(fs::exists(dirs.output / "models" / "tri.obj"), "source models are still copied");
|
||||
check(fs::exists(dirs.output / "textures" / "red.tga.dtex"), "LDR images cook to .dtex");
|
||||
check(readFile(dirs.output / "data" / "notes.txt") == "hello", "unknown files are copied");
|
||||
check(!hasTempFiles(dirs.output), "a finished cook leaves no temporary files");
|
||||
|
||||
const auto manifest = readManifest(dirs);
|
||||
const auto* shader = findEntry(manifest, "shaders/plain.frag");
|
||||
check(shader && (*shader)["deps"] == nlohmann::json::array({"shaders/include/common.glsl"}),
|
||||
"shader includes are recorded as dependencies");
|
||||
const auto* model = findEntry(manifest, "models/tri.obj");
|
||||
check(model && (*model)["out"] == "models/tri.obj.dmodel", "the cooked model is the primary output");
|
||||
check(model && (*model)["outputs"].size() == 2, "a model owns its copy and its cooked file");
|
||||
check(model && (*model)["deps"] == nlohmann::json::array({"models/tri.mtl"}),
|
||||
".obj material libraries are recorded as dependencies");
|
||||
check(findEntry(manifest, "shaders/include/common.glsl") != nullptr, "includes are listed too");
|
||||
|
||||
expectChef(dirs, "--validate --strict", true, "a fresh cook must validate");
|
||||
}
|
||||
|
||||
void testUnchangedSourcesAreSkipped()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_unchanged");
|
||||
writeShaders(dirs);
|
||||
writeModel(dirs);
|
||||
writeTga(dirs.input / "red.tga");
|
||||
expectChef(dirs, "", true, "the first cook must succeed");
|
||||
|
||||
const std::vector<fs::path> outputs = {
|
||||
dirs.output / "shaders" / "plain.frag.spv",
|
||||
dirs.output / "models" / "tri.obj.dmodel",
|
||||
dirs.output / "red.tga.dtex",
|
||||
dirs.output / "red.tga",
|
||||
};
|
||||
std::vector<fs::file_time_type> times;
|
||||
for (const auto& output : outputs) {
|
||||
times.push_back(fs::last_write_time(output));
|
||||
}
|
||||
|
||||
expectChef(dirs, "", true, "a second cook must succeed");
|
||||
for (std::size_t i = 0; i < outputs.size(); ++i) {
|
||||
check(fs::last_write_time(outputs[i]) == times[i], "an unchanged source must not be cooked again");
|
||||
}
|
||||
}
|
||||
|
||||
void testDependencyChangesRecook()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_dependencies");
|
||||
writeShaders(dirs);
|
||||
writeModel(dirs);
|
||||
expectChef(dirs, "", true, "the first cook must succeed");
|
||||
|
||||
const fs::path spv = dirs.output / "shaders" / "plain.frag.spv";
|
||||
const auto staleSpv = makeStale(spv, dirs.input / "shaders" / "include" / "common.glsl");
|
||||
const fs::path model = dirs.output / "models" / "tri.obj.dmodel";
|
||||
const auto staleModel = makeStale(model, dirs.input / "models" / "tri.mtl");
|
||||
// The entry files themselves stay older than the outputs, only the dependencies are newer
|
||||
fs::last_write_time(dirs.input / "shaders" / "plain.frag", staleSpv - std::chrono::hours{1});
|
||||
fs::last_write_time(dirs.input / "models" / "tri.obj", staleModel - std::chrono::hours{1});
|
||||
|
||||
expectChef(dirs, "", true, "recooking must succeed");
|
||||
check(fs::last_write_time(spv) != staleSpv, "a changed include must recompile the shader");
|
||||
check(fs::last_write_time(model) != staleModel, "a changed material library must recook the model");
|
||||
}
|
||||
|
||||
void testReplacedSourceIsCopiedAgain()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_replaced");
|
||||
const fs::path source = dirs.input / "notes.txt";
|
||||
writeText(source, "new");
|
||||
expectChef(dirs, "", true, "the first cook must succeed");
|
||||
|
||||
// Restoring an older file must still replace the copy, even though it isn't newer
|
||||
writeText(source, "old!");
|
||||
fs::last_write_time(source, fs::last_write_time(dirs.output / "notes.txt") - std::chrono::hours{24});
|
||||
expectChef(dirs, "", true, "recooking must succeed");
|
||||
check(readFile(dirs.output / "notes.txt") == "old!", "a replaced source must be copied again");
|
||||
}
|
||||
|
||||
void testDeletedSourcesAreRemoved()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_deleted");
|
||||
writeTga(dirs.input / "textures" / "old" / "gone.tga");
|
||||
writeText(dirs.input / "keep.txt", "keep");
|
||||
writeText(dirs.output / "hand_made.txt", "not ours");
|
||||
expectChef(dirs, "", true, "the first cook must succeed");
|
||||
check(fs::exists(dirs.output / "textures" / "old" / "gone.tga.dtex"), "the texture must be cooked first");
|
||||
|
||||
fs::remove_all(dirs.input / "textures");
|
||||
expectChef(dirs, "", true, "cooking after a delete must succeed");
|
||||
|
||||
check(!fs::exists(dirs.output / "textures" / "old" / "gone.tga.dtex"), "cooked outputs of deleted sources are removed");
|
||||
check(!fs::exists(dirs.output / "textures" / "old" / "gone.tga"), "copies of deleted sources are removed");
|
||||
check(!fs::exists(dirs.output / "textures"), "folders emptied by the cleanup are removed");
|
||||
check(fs::exists(dirs.output / "keep.txt"), "outputs of existing sources stay");
|
||||
check(fs::exists(dirs.output / "hand_made.txt"), "files the cooker never wrote are left alone");
|
||||
check(findEntry(readManifest(dirs), "textures/old/gone.tga") == nullptr, "deleted sources leave the manifest");
|
||||
|
||||
expectChef(dirs, "--validate", false, "validation must report files the cooker didn't write");
|
||||
fs::remove(dirs.output / "hand_made.txt");
|
||||
expectChef(dirs, "--validate", true, "validation passes once the stray file is gone");
|
||||
}
|
||||
|
||||
void testFailedCookKeepsLastGoodOutput()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_failure");
|
||||
writeShaders(dirs);
|
||||
expectChef(dirs, "", true, "the first cook must succeed");
|
||||
|
||||
const fs::path spv = dirs.output / "shaders" / "plain.frag.spv";
|
||||
const std::string goodSpv = readFile(spv);
|
||||
|
||||
writeText(dirs.input / "shaders" / "plain.frag", "#version 450\nvoid main() { this is not glsl }\n");
|
||||
fs::last_write_time(dirs.input / "shaders" / "plain.frag", fs::last_write_time(spv) + std::chrono::hours{1});
|
||||
writeText(dirs.input / "added.txt", "added");
|
||||
|
||||
expectChef(dirs, "", false, "a shader that doesn't compile must fail the cook");
|
||||
check(readFile(spv) == goodSpv, "a failed compile must leave the previous SPIR-V untouched");
|
||||
check(fs::exists(dirs.output / "added.txt"), "one failing asset must not stop the others");
|
||||
check(!hasTempFiles(dirs.output), "a failed cook leaves no temporary files");
|
||||
|
||||
const auto manifest = readManifest(dirs);
|
||||
const auto* shader = findEntry(manifest, "shaders/plain.frag");
|
||||
check(shader && (*shader)["out"] == "shaders/plain.frag.spv", "the failed shader keeps its previous entry");
|
||||
check(findEntry(manifest, "added.txt") != nullptr, "the manifest is still written after a failure");
|
||||
|
||||
expectChef(dirs, "", false, "the broken shader must be retried, and fail again");
|
||||
writeShaders(dirs);
|
||||
expectChef(dirs, "", true, "fixing the shader must make the cook succeed");
|
||||
}
|
||||
|
||||
void testFallbacksAndStrictMode()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_fallback");
|
||||
writeText(dirs.input / "broken.png", "this is not a png");
|
||||
|
||||
expectChef(dirs, "", true, "a fallback is only a warning by default");
|
||||
check(fs::exists(dirs.output / "broken.png"), "the source is used when cooking fails");
|
||||
check(!fs::exists(dirs.output / "broken.png.dtex"), "no cooked file is written when cooking fails");
|
||||
const auto manifest = readManifest(dirs);
|
||||
const auto* entry = findEntry(manifest, "broken.png");
|
||||
check(entry && entry->contains("fallback"), "the manifest records the fallback");
|
||||
|
||||
expectChef(dirs, "--strict", false, "strict mode must fail on a remembered fallback");
|
||||
expectChef(dirs, "--validate --strict", false, "strict validation must report the fallback");
|
||||
|
||||
writeText(dirs.input / "chef.json", R"({"allowFallback": ["broken.png"]})");
|
||||
expectChef(dirs, "--strict", true, "strict mode must accept allowed fallbacks");
|
||||
expectChef(dirs, "--validate --strict", true, "strict validation must accept allowed fallbacks");
|
||||
check(!fs::exists(dirs.output / "chef.json"), "the cooker config isn't an asset");
|
||||
}
|
||||
|
||||
void testValidateFindsMissingOutputs()
|
||||
{
|
||||
const CookDirs dirs = makeDirs("destrum_cooker_validate");
|
||||
writeShaders(dirs);
|
||||
writeText(dirs.input / "notes.txt", "hello");
|
||||
expectChef(dirs, "", true, "the first cook must succeed");
|
||||
|
||||
fs::remove(dirs.output / "shaders" / "plain.frag.spv");
|
||||
expectChef(dirs, "--validate", false, "validation must report missing outputs");
|
||||
|
||||
expectChef(dirs, "", true, "recooking must succeed");
|
||||
writeText(dirs.input / "new.txt", "new");
|
||||
expectChef(dirs, "--validate", false, "validation must report sources that weren't cooked");
|
||||
}
|
||||
}
|
||||
|
||||
void RunCookerTests()
|
||||
{
|
||||
testCookRecordsEveryOutput();
|
||||
testUnchangedSourcesAreSkipped();
|
||||
testDependencyChangesRecook();
|
||||
testReplacedSourceIsCopiedAgain();
|
||||
testDeletedSourcesAreRemoved();
|
||||
testFailedCookKeepsLastGoodOutput();
|
||||
testFallbacksAndStrictMode();
|
||||
testValidateFindsMissingOutputs();
|
||||
}
|
||||
@@ -12,6 +12,7 @@ void RunGameplayTests();
|
||||
void RunEditorTests();
|
||||
void RunAudioTests();
|
||||
void RunJobsTests();
|
||||
void RunCookerTests();
|
||||
|
||||
namespace {
|
||||
struct Suite {
|
||||
@@ -30,6 +31,7 @@ namespace {
|
||||
{"editor", RunEditorTests},
|
||||
{"audio", RunAudioTests},
|
||||
{"jobs", RunJobsTests},
|
||||
{"cooker", RunCookerTests},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user