From 94fa2bb4b2020c8c1429dd34ff79a16798cb81f3 Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Sun, 18 Oct 2026 15:20:01 +0200 Subject: [PATCH] feat: add vec3 type to nhollman json / fix: scene serialisation stuff --- .../destrum/Components/Physics/Collider.h | 16 +- .../Graphics/Resources/PrimitiveMeshes.h | 9 + .../include/destrum/ObjectModel/Component.h | 2 +- .../include/destrum/Serialization/GlmJson.h | 63 +++ destrum/src/Components/LightComponent.cpp | 8 +- .../src/Components/MeshRendererComponent.cpp | 78 +++- destrum/src/Components/OrbitAndSpin.cpp | 46 +- .../src/Components/Physics/BoxCollider.cpp | 9 +- destrum/src/Components/Rotator.cpp | 32 +- destrum/src/Components/Spinner.cpp | 14 +- .../Graphics/Resources/PrimitiveMeshes.cpp | 67 ++- .../src/Serialization/ComponentRegistry.cpp | 16 +- destrum/src/Serialization/SceneSerializer.cpp | 427 +++++++++++------- lightkeeper/src/components/Door.cpp | 8 +- .../src/components/GameComponentRegistry.cpp | 16 +- lightkeeper/src/components/MovingPlatform.cpp | 8 +- lightkeeper/src/components/PrintComponent.cpp | 2 +- tests/destrum_tests.cpp | 138 ++++++ 18 files changed, 670 insertions(+), 289 deletions(-) create mode 100644 destrum/include/destrum/Serialization/GlmJson.h diff --git a/destrum/include/destrum/Components/Physics/Collider.h b/destrum/include/destrum/Components/Physics/Collider.h index b6b4302..a7a5b7d 100644 --- a/destrum/include/destrum/Components/Physics/Collider.h +++ b/destrum/include/destrum/Components/Physics/Collider.h @@ -5,6 +5,7 @@ #include #include +#include class Collider : public Component { public: @@ -31,23 +32,14 @@ public: protected: [[nodiscard]] nlohmann::json SerializeCollider() const { return { - {"centerOffset", {m_CenterOffset.x, m_CenterOffset.y, m_CenterOffset.z}}, + {"centerOffset", m_CenterOffset}, {"isTrigger", m_IsTrigger} }; } void DeserializeCollider(const nlohmann::json& data) { - if (data.contains("centerOffset")) { - const auto& offset = data.at("centerOffset"); - m_CenterOffset = { - offset.at(0).get(), - offset.at(1).get(), - offset.at(2).get() - }; - } - if (data.contains("isTrigger")) { - m_IsTrigger = data.at("isTrigger").get(); - } + m_CenterOffset = data.value("centerOffset", m_CenterOffset); + m_IsTrigger = data.value("isTrigger", m_IsTrigger); } bool m_IsTrigger{false}; diff --git a/destrum/include/destrum/Graphics/Resources/PrimitiveMeshes.h b/destrum/include/destrum/Graphics/Resources/PrimitiveMeshes.h index a0905e6..e874464 100644 --- a/destrum/include/destrum/Graphics/Resources/PrimitiveMeshes.h +++ b/destrum/include/destrum/Graphics/Resources/PrimitiveMeshes.h @@ -1,12 +1,21 @@ #ifndef PRIMITIVEMESHES_H #define PRIMITIVEMESHES_H +#include +#include +#include + #include #include namespace PrimitiveMeshes { [[nodiscard]] CPUMesh MakeBox(const glm::vec3& size); + + [[nodiscard]] std::string BoxKey(const glm::vec3& size); + [[nodiscard]] bool IsPrimitiveKey(std::string_view key); + [[nodiscard]] std::string_view StripDuplicateSuffix(std::string_view key); + [[nodiscard]] std::optional MakeFromKey(std::string_view key); } #endif // PRIMITIVEMESHES_H diff --git a/destrum/include/destrum/ObjectModel/Component.h b/destrum/include/destrum/ObjectModel/Component.h index 43bd829..8e1ac15 100644 --- a/destrum/include/destrum/ObjectModel/Component.h +++ b/destrum/include/destrum/ObjectModel/Component.h @@ -52,7 +52,7 @@ public: virtual std::string GetTypeName() const = 0; virtual nlohmann::json Serialize() const { - return {}; + return nlohmann::json::object(); } virtual void Deserialize(const nlohmann::json&) { diff --git a/destrum/include/destrum/Serialization/GlmJson.h b/destrum/include/destrum/Serialization/GlmJson.h new file mode 100644 index 0000000..b930f57 --- /dev/null +++ b/destrum/include/destrum/Serialization/GlmJson.h @@ -0,0 +1,63 @@ +#ifndef DESTRUM_GLMJSON_H +#define DESTRUM_GLMJSON_H + +#include +#include + +#include +#include +#include + +namespace destrum::json_detail { + inline void RequireNumberArray(const nlohmann::json& j, std::size_t size) { + if (!j.is_array() || j.size() != size) { + throw std::runtime_error( + "expected an array of " + std::to_string(size) + " numbers, got " + j.dump()); + } + for (const auto& element : j) { + if (!element.is_number()) { + throw std::runtime_error( + "expected an array of " + std::to_string(size) + " numbers, got " + j.dump()); + } + } + } +} + +namespace nlohmann { + template + struct adl_serializer> { + static void to_json(json& j, const glm::vec& value) { + j = json::array(); + for (glm::length_t i = 0; i < L; ++i) { + j.push_back(value[i]); + } + } + + static void from_json(const json& j, glm::vec& value) { + destrum::json_detail::RequireNumberArray(j, static_cast(L)); + for (glm::length_t i = 0; i < L; ++i) { + value[i] = j.at(static_cast(i)).get(); + } + } + }; + + // Stored as [x, y, z, w], the same order as scene transforms + template + struct adl_serializer> { + static void to_json(json& j, const glm::qua& value) { + j = json::array({value.x, value.y, value.z, value.w}); + } + + static void from_json(const json& j, glm::qua& value) { + destrum::json_detail::RequireNumberArray(j, 4); + value = glm::qua{ + j.at(3).get(), + j.at(0).get(), + j.at(1).get(), + j.at(2).get() + }; + } + }; +} + +#endif // DESTRUM_GLMJSON_H diff --git a/destrum/src/Components/LightComponent.cpp b/destrum/src/Components/LightComponent.cpp index 5f77eef..0e23fc8 100644 --- a/destrum/src/Components/LightComponent.cpp +++ b/destrum/src/Components/LightComponent.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -54,7 +55,7 @@ void LightComponent::Render(const RenderContext& ctx) nlohmann::json LightComponent::Serialize() const { return { {"type", static_cast(m_Type)}, - {"color", {m_Color.x, m_Color.y, m_Color.z}}, + {"color", m_Color}, {"intensity", m_Intensity}, {"range", m_Range}, {"innerConeAngle", m_InnerConeAngle}, @@ -70,10 +71,7 @@ void LightComponent::Deserialize(const nlohmann::json& data) { m_Type = static_cast(type); } } - if (data.contains("color")) { - const auto& value = data.at("color"); - m_Color = {value.at(0).get(), value.at(1).get(), value.at(2).get()}; - } + m_Color = data.value("color", m_Color); if (data.contains("intensity")) { SetIntensity(data.at("intensity").get()); } diff --git a/destrum/src/Components/MeshRendererComponent.cpp b/destrum/src/Components/MeshRendererComponent.cpp index c18a679..9f69609 100644 --- a/destrum/src/Components/MeshRendererComponent.cpp +++ b/destrum/src/Components/MeshRendererComponent.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -10,8 +11,11 @@ #include +#include #include +#include + MeshRendererComponent::MeshRendererComponent(GameObject& parent): Component(parent, "MeshRendererComponent") { } @@ -54,27 +58,39 @@ void MeshRendererComponent::Update(float) { } nlohmann::json MeshRendererComponent::Serialize() const { + // Only keys are saved, mesh and material ids are cache slots that change between runs + std::string savedMeshKey = meshKey; + std::string savedMaterialKey = materialKey; + if (GameState::GetInstance().HasRenderer()) { + if (const auto* resources = GameState::GetInstance().Renderer().getResources()) { + if (savedMeshKey.empty() && meshID != NULL_MESH_ID) { + savedMeshKey = resources->meshes().getMeshKey(meshID); + } + if (savedMaterialKey.empty() && materialID != NULL_MATERIAL_ID) { + savedMaterialKey = resources->materials().getMaterialKey(materialID); + } + } + } + if (savedMeshKey.empty() && meshID != NULL_MESH_ID) { + throw std::runtime_error("MeshRendererComponent has a mesh without a key, it cannot be saved"); + } + if (savedMaterialKey.empty() && materialID != NULL_MATERIAL_ID) { + throw std::runtime_error("MeshRendererComponent has a material without a key, it cannot be saved"); + } + return { - {"meshId", meshID}, - {"materialId", materialID}, - {"meshKey", meshKey}, - {"materialKey", materialKey} + {"meshKey", savedMeshKey}, + {"materialKey", savedMaterialKey} }; } void MeshRendererComponent::Deserialize(const nlohmann::json& data) { - if (data.contains("meshId")) { - meshID = data.at("meshId").get(); - } - if (data.contains("materialId")) { - materialID = data.at("materialId").get(); - } - if (data.contains("meshKey")) { - meshKey = data.at("meshKey").get(); - } - if (data.contains("materialKey")) { - materialKey = data.at("materialKey").get(); - } + meshKey = data.value("meshKey", std::string{}); + materialKey = data.value("materialKey", std::string{}); + + // Older scenes also stored raw ids, only trust them when there is no key + meshID = meshKey.empty() ? data.value("meshId", NULL_MESH_ID) : NULL_MESH_ID; + materialID = materialKey.empty() ? data.value("materialId", NULL_MATERIAL_ID) : NULL_MATERIAL_ID; } void MeshRendererComponent::ResolveReferences(const ObjectMap&) { @@ -89,7 +105,9 @@ void MeshRendererComponent::ResolveReferences(const ObjectMap&) { const auto loadAssetForKey = [&](const std::string& key) { const auto asset = AssetReference::fromCacheKey(key); - if (!asset) { + // Runtime-only keys such as "BulbMaterial#2" also contain a '#', they are not files + if (!asset || (asset->path.find("://") == std::string::npos && + !std::filesystem::exists(asset->path))) { return; } if (!GameState::GetInstance().HasGfxDevice()) { @@ -102,6 +120,20 @@ void MeshRendererComponent::ResolveReferences(const ObjectMap&) { if (!meshKey.empty()) { auto resolved = resources->meshes().findMeshByKey(meshKey); + if (!resolved && PrimitiveMeshes::IsPrimitiveKey(meshKey)) { + resolved = resources->meshes().findMeshByKey(PrimitiveMeshes::StripDuplicateSuffix(meshKey)); + if (!resolved) { + auto primitive = PrimitiveMeshes::MakeFromKey(meshKey); + if (!primitive) { + throw std::runtime_error("Invalid primitive mesh key: " + meshKey); + } + if (!GameState::GetInstance().HasGfxDevice()) { + throw std::runtime_error("Cannot build primitive mesh without a graphics device: " + meshKey); + } + resolved = resources->meshes().addMesh(GameState::GetInstance().Gfx(), *primitive); + } + meshKey = resources->meshes().getMeshKey(*resolved); + } if (!resolved) { loadAssetForKey(meshKey); resolved = resources->meshes().findMeshByKey(meshKey); @@ -129,11 +161,15 @@ void MeshRendererComponent::ResolveReferences(const ObjectMap&) { resolved = resources->materials().findMaterialByName(materialKey); } - if (!resolved) { - throw std::runtime_error("Material resource not found: " + materialKey); + if (resolved) { + materialID = *resolved; + materialKey = resources->materials().getMaterialKey(*resolved); + } else { + // Keep the key so saving again does not lose which material was meant + spdlog::warn("Material \"{}\" on \"{}\" not found, using the placeholder material", + materialKey, GetGameObject()->GetName()); + materialID = resources->materials().getPlaceholderMaterialId(); } - materialID = *resolved; - materialKey = resources->materials().getMaterialKey(*resolved); } if (meshID != NULL_MESH_ID && meshKey.empty()) { diff --git a/destrum/src/Components/OrbitAndSpin.cpp b/destrum/src/Components/OrbitAndSpin.cpp index 8f22aac..8a51d4d 100644 --- a/destrum/src/Components/OrbitAndSpin.cpp +++ b/destrum/src/Components/OrbitAndSpin.cpp @@ -7,6 +7,7 @@ #include "destrum/ObjectModel/GameObject.h" #include "destrum/ObjectModel/Transform.h" #include "destrum/Components/MeshRendererComponent.h" +#include "destrum/Serialization/GlmJson.h" #include "destrum/Util/GameState.h" #include "destrum/Util/DeltaTime.h" #include "destrum/Util/ImGuiUtils.h" @@ -128,8 +129,8 @@ void OrbitAndSpin::Start() { nlohmann::json OrbitAndSpin::Serialize() const { return { {"radius", m_Radius}, - {"center", {m_Center.x, m_Center.y, m_Center.z}}, - {"orbitAxis", {m_OrbitAxis.x, m_OrbitAxis.y, m_OrbitAxis.z}}, + {"center", m_Center}, + {"orbitAxis", m_OrbitAxis}, {"orbitSpeed", m_OrbitSpeed}, {"orbitAngle", m_OrbitAngle}, {"orbitPhase", m_OrbitPhase}, @@ -137,38 +138,27 @@ nlohmann::json OrbitAndSpin::Serialize() const { {"growSpeed", m_GrowSpeed}, {"growMin", m_GrowMin}, {"growMax", m_GrowMax}, - {"spinAxis", {m_SpinAxis.x, m_SpinAxis.y, m_SpinAxis.z}}, + {"spinAxis", m_SpinAxis}, {"spinSpeed", m_SpinSpeed}, - {"baseScale", {m_BaseScale.x, m_BaseScale.y, m_BaseScale.z}}, - {"materialId", m_MaterialID} + {"baseScale", m_BaseScale} }; } void OrbitAndSpin::Deserialize(const nlohmann::json& data) { - const auto readVec3 = [&data](const char* key, glm::vec3& value) { - if (!data.contains(key)) { - return; - } - - const auto& array = data.at(key); - value = {array.at(0).get(), array.at(1).get(), array.at(2).get()}; - }; - - if (data.contains("radius")) m_Radius = data.at("radius").get(); - readVec3("center", m_Center); - readVec3("orbitAxis", m_OrbitAxis); - if (data.contains("orbitSpeed")) m_OrbitSpeed = data.at("orbitSpeed").get(); - if (data.contains("orbitAngle")) m_OrbitAngle = data.at("orbitAngle").get(); - if (data.contains("orbitPhase")) m_OrbitPhase = data.at("orbitPhase").get(); - if (data.contains("growPhase")) m_GrowPhase = data.at("growPhase").get(); - if (data.contains("growSpeed")) m_GrowSpeed = data.at("growSpeed").get(); - if (data.contains("growMin")) m_GrowMin = data.at("growMin").get(); - if (data.contains("growMax")) m_GrowMax = data.at("growMax").get(); - readVec3("spinAxis", m_SpinAxis); - readVec3("baseScale", m_BaseScale); + m_Radius = data.value("radius", m_Radius); + m_Center = data.value("center", m_Center); + m_OrbitAxis = data.value("orbitAxis", m_OrbitAxis); + m_OrbitSpeed = data.value("orbitSpeed", m_OrbitSpeed); + m_OrbitAngle = data.value("orbitAngle", m_OrbitAngle); + m_OrbitPhase = data.value("orbitPhase", m_OrbitPhase); + m_GrowPhase = data.value("growPhase", m_GrowPhase); + m_GrowSpeed = data.value("growSpeed", m_GrowSpeed); + m_GrowMin = data.value("growMin", m_GrowMin); + m_GrowMax = data.value("growMax", m_GrowMax); + m_SpinAxis = data.value("spinAxis", m_SpinAxis); + m_BaseScale = data.value("baseScale", m_BaseScale); m_BaseScaleLoaded = data.contains("baseScale"); - if (data.contains("spinSpeed")) m_SpinSpeed = data.at("spinSpeed").get(); - if (data.contains("materialId")) m_MaterialID = data.at("materialId").get(); + m_SpinSpeed = data.value("spinSpeed", m_SpinSpeed); if (m_GrowMin > m_GrowMax) { std::swap(m_GrowMin, m_GrowMax); diff --git a/destrum/src/Components/Physics/BoxCollider.cpp b/destrum/src/Components/Physics/BoxCollider.cpp index 6ccd37a..95ac060 100644 --- a/destrum/src/Components/Physics/BoxCollider.cpp +++ b/destrum/src/Components/Physics/BoxCollider.cpp @@ -11,19 +11,14 @@ void BoxCollider::DrawDebugLines(const glm::vec4& color) const { nlohmann::json BoxCollider::Serialize() const { auto data = SerializeCollider(); - data["halfExtents"] = {m_HalfExtents.x, m_HalfExtents.y, m_HalfExtents.z}; + data["halfExtents"] = m_HalfExtents; return data; } void BoxCollider::Deserialize(const nlohmann::json& data) { DeserializeCollider(data); if (data.contains("halfExtents")) { - const auto& extents = data.at("halfExtents"); - SetHalfExtents({ - extents.at(0).get(), - extents.at(1).get(), - extents.at(2).get() - }); + SetHalfExtents(data.at("halfExtents").get()); } } diff --git a/destrum/src/Components/Rotator.cpp b/destrum/src/Components/Rotator.cpp index e202f74..284f9b2 100644 --- a/destrum/src/Components/Rotator.cpp +++ b/destrum/src/Components/Rotator.cpp @@ -5,6 +5,7 @@ #include #include // glm::quat, glm::angleAxis #include // operator*(quat, vec3) +#include #include #include @@ -102,34 +103,21 @@ nlohmann::json Rotator::Serialize() const { {"distance", m_Distance}, {"speed", m_Speed}, {"currentAngle", m_CurrentAngle}, - {"pivot", {m_Pivot.x, m_Pivot.y, m_Pivot.z}}, - {"axis", {m_Axis.x, m_Axis.y, m_Axis.z}}, - {"initialOffset", {m_InitialOffset.x, m_InitialOffset.y, m_InitialOffset.z}} + {"pivot", m_Pivot}, + {"axis", m_Axis}, + {"initialOffset", m_InitialOffset} }; } void Rotator::Deserialize(const nlohmann::json& data) { - if (data.contains("distance")) { - m_Distance = data.at("distance").get(); - } - if (data.contains("speed")) { - m_Speed = data.at("speed").get(); - } - if (data.contains("currentAngle")) { - m_CurrentAngle = data.at("currentAngle").get(); - } - if (data.contains("pivot")) { - const auto& value = data.at("pivot"); - m_Pivot = {value.at(0).get(), value.at(1).get(), value.at(2).get()}; - } + m_Distance = data.value("distance", m_Distance); + m_Speed = data.value("speed", m_Speed); + m_CurrentAngle = data.value("currentAngle", m_CurrentAngle); + m_Pivot = data.value("pivot", m_Pivot); if (data.contains("axis")) { - const auto& value = data.at("axis"); - SetAxis({value.at(0).get(), value.at(1).get(), value.at(2).get()}); - } - if (data.contains("initialOffset")) { - const auto& value = data.at("initialOffset"); - m_InitialOffset = {value.at(0).get(), value.at(1).get(), value.at(2).get()}; + SetAxis(data.at("axis").get()); } + m_InitialOffset = data.value("initialOffset", m_InitialOffset); } void Rotator::ImGuiInspector() { diff --git a/destrum/src/Components/Spinner.cpp b/destrum/src/Components/Spinner.cpp index 253baf9..c46d5df 100644 --- a/destrum/src/Components/Spinner.cpp +++ b/destrum/src/Components/Spinner.cpp @@ -3,6 +3,7 @@ #include #include "destrum/ObjectModel/Transform.h" +#include "destrum/Serialization/GlmJson.h" #include "destrum/Util/DeltaTime.h" #include "destrum/Util/ImGuiUtils.h" @@ -18,7 +19,7 @@ void Spinner::Update(float dt) nlohmann::json Spinner::Serialize() const { return { - {"axis", {m_Axis.x, m_Axis.y, m_Axis.z}}, + {"axis", m_Axis}, {"speed", m_Speed}, {"angle", m_Angle} }; @@ -26,15 +27,10 @@ nlohmann::json Spinner::Serialize() const { void Spinner::Deserialize(const nlohmann::json& data) { if (data.contains("axis")) { - const auto& value = data.at("axis"); - SetAxis({value.at(0).get(), value.at(1).get(), value.at(2).get()}); - } - if (data.contains("speed")) { - m_Speed = data.at("speed").get(); - } - if (data.contains("angle")) { - m_Angle = data.at("angle").get(); + SetAxis(data.at("axis").get()); } + m_Speed = data.value("speed", m_Speed); + m_Angle = data.value("angle", m_Angle); } void Spinner::ImGuiInspector() { diff --git a/destrum/src/Graphics/Resources/PrimitiveMeshes.cpp b/destrum/src/Graphics/Resources/PrimitiveMeshes.cpp index 952c8a8..ac29af9 100644 --- a/destrum/src/Graphics/Resources/PrimitiveMeshes.cpp +++ b/destrum/src/Graphics/Resources/PrimitiveMeshes.cpp @@ -1,7 +1,9 @@ #include #include +#include #include +#include CPUMesh PrimitiveMeshes::MakeBox(const glm::vec3& size) { @@ -22,7 +24,8 @@ CPUMesh PrimitiveMeshes::MakeBox(const glm::vec3& size) }}; CPUMesh mesh{}; - mesh.name = "Box"; + // The name becomes the mesh cache key, saved scenes rebuild the box from it + mesh.name = BoxKey(size); mesh.minPos = -size * 0.5f; mesh.maxPos = size * 0.5f; @@ -50,3 +53,65 @@ CPUMesh PrimitiveMeshes::MakeBox(const glm::vec3& size) return mesh; } + +namespace { + constexpr std::string_view PrimitiveScheme = "prim://"; + constexpr std::string_view BoxPrefix = "prim://box?size="; +} + +std::string PrimitiveMeshes::BoxKey(const glm::vec3& size) +{ + return std::format("{}{},{},{}", BoxPrefix, size.x, size.y, size.z); +} + +bool PrimitiveMeshes::IsPrimitiveKey(std::string_view key) +{ + return key.starts_with(PrimitiveScheme); +} + +std::string_view PrimitiveMeshes::StripDuplicateSuffix(std::string_view key) +{ + // MeshCache appends "#N" when the same key is added more than once + const auto separator = key.rfind('#'); + if (separator == std::string_view::npos || separator + 1 >= key.size()) { + return key; + } + for (const char c : key.substr(separator + 1)) { + if (c < '0' || c > '9') { + return key; + } + } + return key.substr(0, separator); +} + +std::optional PrimitiveMeshes::MakeFromKey(std::string_view key) +{ + key = StripDuplicateSuffix(key); + if (!key.starts_with(BoxPrefix)) { + return std::nullopt; + } + + std::string_view rest = key.substr(BoxPrefix.size()); + glm::vec3 size{0.0f}; + for (int i = 0; i < 3; ++i) { + const char* end = rest.data() + rest.size(); + float value = 0.0f; + const auto [next, error] = std::from_chars(rest.data(), end, value); + if (error != std::errc{} || !(value > 0.0f)) { + return std::nullopt; + } + size[i] = value; + rest.remove_prefix(static_cast(next - rest.data())); + if (i < 2) { + if (!rest.starts_with(',')) { + return std::nullopt; + } + rest.remove_prefix(1); + } + } + if (!rest.empty()) { + return std::nullopt; + } + + return MakeBox(size); +} diff --git a/destrum/src/Serialization/ComponentRegistry.cpp b/destrum/src/Serialization/ComponentRegistry.cpp index 428d4e3..e6eae56 100644 --- a/destrum/src/Serialization/ComponentRegistry.cpp +++ b/destrum/src/Serialization/ComponentRegistry.cpp @@ -2,17 +2,15 @@ #include #include +#include + void RegisterEngineComponents() { - static bool registered = false; - if (registered) { - return; - } - - registered = true; - + static std::once_flag registered; + std::call_once(registered, [] { #define DESTRUM_REGISTER_COMPONENT(ComponentType) \ - ComponentFactory::Register(#ComponentType); - DESTRUM_ENGINE_COMPONENTS(DESTRUM_REGISTER_COMPONENT) + ComponentFactory::Register(#ComponentType); + DESTRUM_ENGINE_COMPONENTS(DESTRUM_REGISTER_COMPONENT) #undef DESTRUM_REGISTER_COMPONENT + }); } diff --git a/destrum/src/Serialization/SceneSerializer.cpp b/destrum/src/Serialization/SceneSerializer.cpp index 92d3ac1..b9aac05 100644 --- a/destrum/src/Serialization/SceneSerializer.cpp +++ b/destrum/src/Serialization/SceneSerializer.cpp @@ -3,13 +3,14 @@ #include #include #include -#include #include +#include #include #include #include #include +#include #include #include @@ -20,12 +21,18 @@ #include #include #include +#include #include #include +#include using json = nlohmann::json; namespace { + using ValidationError = std::optional; + + constexpr int CurrentSceneVersion = 2; + [[nodiscard]] bool IsFiniteNumber(const json& value) { if (!value.is_number()) { return false; @@ -48,164 +55,221 @@ namespace { return true; } - [[nodiscard]] bool IsFiniteJson(const json& value) { - if (value.is_number()) { - return IsFiniteNumber(value); + // Returns the JSON path of the first NaN/Inf, nlohmann would write it as null + [[nodiscard]] std::optional FindNonFinite(const json& value, const std::string& path) { + if (value.is_number_float()) { + if (!std::isfinite(value.get())) { + return path; + } + return std::nullopt; } if (value.is_array()) { - for (const auto& element : value) { - if (!IsFiniteJson(element)) { - return false; + for (std::size_t i = 0; i < value.size(); ++i) { + if (auto bad = FindNonFinite(value.at(i), path + "[" + std::to_string(i) + "]")) { + return bad; } } - return true; + return std::nullopt; } if (value.is_object()) { for (auto it = value.begin(); it != value.end(); ++it) { - if (!IsFiniteJson(it.value())) { - return false; + if (auto bad = FindNonFinite(it.value(), path + "." + it.key())) { + return bad; } } - return true; } - return true; + return std::nullopt; } - [[nodiscard]] bool ValidateTransform(const json& objectJson, int version) { + [[nodiscard]] std::string DescribeObject(std::size_t index, const json& objectJson) { + std::string description = "objects[" + std::to_string(index) + "]"; + if (objectJson.is_object() && objectJson.contains("name") && objectJson.at("name").is_string()) { + description += " \"" + objectJson.at("name").get() + "\""; + } + return description; + } + + [[nodiscard]] ValidationError ValidateTransform(const json& objectJson, int version) { if (!objectJson.contains("transform")) { - return true; + return std::nullopt; } const auto& transform = objectJson.at("transform"); if (!transform.is_object()) { - return false; + return "transform must be an object"; } // Version 1 wrote an empty placeholder transform object. if (version <= 1 && transform.empty()) { - return true; + return std::nullopt; } - if (!transform.contains("position") || !transform.contains("rotation") || - !transform.contains("scale")) { - return false; + for (const char* key : {"position", "rotation", "scale"}) { + if (!transform.contains(key)) { + return std::string{"transform is missing \""} + key + "\""; + } } - if (!IsFiniteArray(transform.at("position"), 3) || - !IsFiniteArray(transform.at("scale"), 3)) { - return false; + if (!IsFiniteArray(transform.at("position"), 3)) { + return "transform.position must be 3 finite numbers"; + } + if (!IsFiniteArray(transform.at("scale"), 3)) { + return "transform.scale must be 3 finite numbers"; } const auto& rotation = transform.at("rotation"); - return IsFiniteArray(rotation, 3) || IsFiniteArray(rotation, 4); + if (!IsFiniteArray(rotation, 3) && !IsFiniteArray(rotation, 4)) { + return "transform.rotation must be 3 (euler) or 4 (quaternion) finite numbers"; + } + return std::nullopt; } - [[nodiscard]] bool ValidateSceneJson(const json& root) { - if (!root.is_object() || !root.contains("objects") || !root.at("objects").is_array()) { - return false; + [[nodiscard]] ValidationError ValidateComponents(const json& objectJson) { + if (!objectJson.contains("components")) { + return std::nullopt; + } + + const auto& components = objectJson.at("components"); + if (!components.is_array()) { + return "components must be an array"; + } + + for (std::size_t i = 0; i < components.size(); ++i) { + const auto& componentJson = components.at(i); + const std::string where = "components[" + std::to_string(i) + "]"; + + if (!componentJson.is_object() || !componentJson.contains("type") || + !componentJson.at("type").is_string()) { + return where + " must be an object with a string \"type\""; + } + + const std::string type = componentJson.at("type").get(); + const std::string named = where + " " + type; + + if (componentJson.contains("enabled") && !componentJson.at("enabled").is_boolean()) { + return named + ": \"enabled\" must be a boolean"; + } + + if (componentJson.contains("data")) { + const auto& data = componentJson.at("data"); + if (!data.is_object()) { + return named + ": \"data\" must be an object, got " + std::string{data.type_name()}; + } + if (auto bad = FindNonFinite(data, "data")) { + return named + ": " + *bad + " is not a finite number"; + } + } + + if (!ComponentFactory::IsRegistered(type)) { + return named + ": unknown component type"; + } + } + return std::nullopt; + } + + [[nodiscard]] ValidationError ValidateSceneJson(const json& root) { + if (!root.is_object()) { + return "root must be an object"; + } + if (!root.contains("objects") || !root.at("objects").is_array()) { + return "root must contain an \"objects\" array"; } if (root.contains("name") && !root.at("name").is_string()) { - return false; + return "\"name\" must be a string"; } int version = 1; if (root.contains("version")) { if (!root.at("version").is_number_integer()) { - return false; + return "\"version\" must be an integer"; } version = root.at("version").get(); - if (version < 1 || version > 2) { - return false; + if (version < 1 || version > CurrentSceneVersion) { + return "unsupported scene version " + std::to_string(version); } } + if (root.contains("currentCamera") && !root.at("currentCamera").is_number_unsigned()) { + return "\"currentCamera\" must be an object id"; + } + std::unordered_set ids; std::unordered_map parents; + std::unordered_map descriptions; - for (const auto& objectJson : root.at("objects")) { - if (!objectJson.is_object() || !objectJson.contains("id")) { - return false; + const auto& objects = root.at("objects"); + for (std::size_t index = 0; index < objects.size(); ++index) { + const auto& objectJson = objects.at(index); + const std::string where = DescribeObject(index, objectJson); + + if (!objectJson.is_object()) { + return where + " must be an object"; + } + if (!objectJson.contains("id") || !objectJson.at("id").is_number_unsigned()) { + return where + " must have a non-negative integer \"id\""; } const ObjectId id = objectJson.at("id").get(); - if (id == InvalidObjectId || - id >= std::numeric_limits::max() - 1 || - !ids.insert(id).second) { - return false; + if (id == InvalidObjectId || id >= std::numeric_limits::max() - 1) { + return where + " uses reserved id " + std::to_string(id); } + if (!ids.insert(id).second) { + return where + " reuses id " + std::to_string(id); + } + descriptions.emplace(id, where); if (objectJson.contains("name") && !objectJson.at("name").is_string()) { - return false; + return where + ": \"name\" must be a string"; } if (objectJson.contains("active") && !objectJson.at("active").is_boolean()) { - return false; + return where + ": \"active\" must be a boolean"; } - if (!ValidateTransform(objectJson, version)) { - return false; + if (auto error = ValidateTransform(objectJson, version)) { + return where + ": " + *error; } - const ObjectId parent = objectJson.value("parent", InvalidObjectId); + ObjectId parent = InvalidObjectId; + if (objectJson.contains("parent")) { + if (!objectJson.at("parent").is_number_unsigned()) { + return where + ": \"parent\" must be an object id"; + } + parent = objectJson.at("parent").get(); + } if (parent == id) { - return false; + return where + " is its own parent"; } parents.emplace(id, parent); - if (objectJson.contains("components")) { - const auto& components = objectJson.at("components"); - if (!components.is_array()) { - return false; - } - - for (const auto& componentJson : components) { - if (!componentJson.is_object() || !componentJson.contains("type") || - !componentJson.at("type").is_string()) { - return false; - } - - if (componentJson.contains("enabled") && - !componentJson.at("enabled").is_boolean()) { - return false; - } - - if (componentJson.contains("data") && - (!componentJson.at("data").is_object() || - !IsFiniteJson(componentJson.at("data")))) { - return false; - } - - if (!ComponentFactory::IsRegistered(componentJson.at("type").get())) { - return false; - } - } + if (auto error = ValidateComponents(objectJson)) { + return where + " " + *error; } } for (const auto& [id, parent] : parents) { if (parent != InvalidObjectId && !ids.contains(parent)) { - return false; + return descriptions.at(id) + " has missing parent id " + std::to_string(parent); } std::unordered_set visited; ObjectId current = id; while (current != InvalidObjectId) { if (!visited.insert(current).second) { - return false; + return descriptions.at(id) + " is part of a parent cycle"; } - const auto parentIt = parents.find(current); if (parentIt == parents.end()) { - return false; + return descriptions.at(id) + " has missing ancestor id " + std::to_string(current); } current = parentIt->second; } } - return true; + return std::nullopt; } [[nodiscard]] bool AssetFileExists(const std::string& path) { - if (path.empty()) { + if (path.empty() || PrimitiveMeshes::IsPrimitiveKey(path)) { return true; } @@ -225,8 +289,8 @@ namespace { void ValidateAssetReferences(const json& root, const std::filesystem::path& scenePath) { const auto report = [&](const std::string& objectName, const std::string& detail) { - std::cerr << "Scene asset warning (" << scenePath << "): " << objectName - << " references \"" << detail << "\" which could not be found\n"; + spdlog::warn("Scene asset warning ({}): {} references \"{}\" which could not be found", + scenePath.string(), objectName, detail); }; for (const auto& objectJson : root.at("objects")) { @@ -247,7 +311,10 @@ namespace { const std::string cacheKey = data.at(key).get(); if (cacheKey.empty()) continue; const auto asset = AssetReference::fromCacheKey(cacheKey); - if (asset && !AssetFileExists(asset->path)) { + // Runtime-only keys like "BulbMaterial#2" are not file paths + const bool looksLikePath = asset && + asset->path.find_first_of(":/\\") != std::string::npos; + if (looksLikePath && !AssetFileExists(asset->path)) { report(objectName, cacheKey); } } @@ -275,12 +342,44 @@ namespace { } } } + + [[nodiscard]] bool WriteFileAtomically(const std::filesystem::path& path, const std::string& contents) { + std::filesystem::path tempPath = path; + tempPath += ".tmp"; + + { + std::ofstream file(tempPath, std::ios::binary | std::ios::trunc); + if (!file.is_open()) { + spdlog::error("Failed to open scene file for writing: {}", tempPath.string()); + return false; + } + + file << contents; + file.flush(); + if (!file.good()) { + spdlog::error("Failed to write scene file: {}", tempPath.string()); + file.close(); + std::error_code ignored; + std::filesystem::remove(tempPath, ignored); + return false; + } + } + + std::error_code error; + std::filesystem::rename(tempPath, path, error); + if (error) { + spdlog::error("Failed to replace scene file {}: {}", path.string(), error.message()); + std::error_code ignored; + std::filesystem::remove(tempPath, ignored); + return false; + } + return true; + } } bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { if (scene.IsIterating()) { - std::cerr << "Cannot save a scene during an update or render phase: " - << scene.GetName() << '\n'; + spdlog::error("Cannot save a scene during an update or render phase: {}", scene.GetName()); return false; } @@ -288,13 +387,12 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { try { scene.CommitPendingAdditions(); } catch (const std::exception& exception) { - std::cerr << "Failed to commit scene additions before save: " - << exception.what() << '\n'; + spdlog::error("Failed to commit scene additions before save: {}", exception.what()); return false; } json root; - root["version"] = 2; + root["version"] = CurrentSceneVersion; root["name"] = scene.GetName(); root["objects"] = json::array(); if (scene.m_CurrentCameraId != InvalidObjectId) { @@ -302,9 +400,9 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { } const SceneEnvironment& environment = scene.GetEnvironment(); root["environment"] = { - {"ambientColor", {environment.ambientColor.x, environment.ambientColor.y, environment.ambientColor.z}}, + {"ambientColor", environment.ambientColor}, {"ambientIntensity", environment.ambientIntensity}, - {"fogColor", {environment.fogColor.x, environment.fogColor.y, environment.fogColor.z}}, + {"fogColor", environment.fogColor}, {"fogDensity", environment.fogDensity}, {"skyboxRotationSpeed", environment.skyboxRotationSpeed}, {"skyboxRotation", environment.skyboxRotation}, @@ -318,23 +416,19 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { const GameObject& object = *objectPtr; if (object.GetComponent() != nullptr && object.GetComponent() == nullptr) { - std::cerr << "Cannot save object with a Rigidbody but no Collider: " - << object.GetName() << '\n'; + spdlog::error("Cannot save object with a Rigidbody but no Collider: {}", object.GetName()); return false; } const Transform& transform = object.GetTransform(); - const glm::vec3& position = transform.GetLocalPosition(); - const glm::quat& rotation = transform.GetLocalRotation(); - const glm::vec3& scale = transform.GetLocalScale(); json objectJson; objectJson["id"] = object.GetId(); objectJson["name"] = object.GetName(); objectJson["active"] = object.IsActive(); objectJson["transform"] = { - {"position", {position.x, position.y, position.z}}, - {"rotation", {rotation.x, rotation.y, rotation.z, rotation.w}}, - {"scale", {scale.x, scale.y, scale.z}} + {"position", transform.GetLocalPosition()}, + {"rotation", transform.GetLocalRotation()}, + {"scale", transform.GetLocalScale()} }; const Transform* parent = transform.GetParent(); @@ -351,51 +445,78 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { } const Component& component = *componentPtr; + json data; + try { + data = component.Serialize(); + } catch (const std::exception& exception) { + spdlog::error("Failed to serialize {} on \"{}\": {}", + component.GetTypeName(), object.GetName(), exception.what()); + return false; + } + + if (data.is_null()) { + data = json::object(); + } + if (auto bad = FindNonFinite(data, "data")) { + spdlog::error("Cannot save {} on \"{}\": {} is NaN or Inf", + component.GetTypeName(), object.GetName(), *bad); + return false; + } + objectJson["components"].push_back({ {"type", component.GetTypeName()}, {"enabled", component.isEnabled()}, - {"data", component.Serialize()} + {"data", std::move(data)} }); } root["objects"].push_back(std::move(objectJson)); } - std::ofstream file(path); - if (!file.is_open()) { - std::cerr << "Failed to open scene file for writing: " << path << '\n'; + if (auto bad = FindNonFinite(root, "scene")) { + spdlog::error("Cannot save scene \"{}\": {} is NaN or Inf", scene.GetName(), *bad); return false; } - file << root.dump(4); - if (!file.good()) { - std::cerr << "Failed to write scene file: " << path << '\n'; + // Refuse to write anything Load would reject + if (auto error = ValidateSceneJson(root)) { + spdlog::error("Cannot save scene \"{}\", it would not load back: {}", scene.GetName(), *error); return false; } - return true; + std::string contents; + try { + contents = root.dump(4); + } catch (const std::exception& exception) { + spdlog::error("Failed to encode scene \"{}\": {}", scene.GetName(), exception.what()); + return false; + } + + return WriteFileAtomically(path, contents); } SceneSerializer::LoadResult SceneSerializer::LoadSceneFile(const std::filesystem::path& path) { LoadResult result; + RegisterEngineComponents(); std::ifstream file(path); if (!file.is_open()) { result.errorMessage = "Failed to open scene file for reading: " + path.string(); - std::cerr << result.errorMessage << '\n'; + spdlog::error("{}", result.errorMessage); return result; } try { file >> result.root; - if (!ValidateSceneJson(result.root)) { - result.errorMessage = "Invalid scene data: " + path.string(); - std::cerr << result.errorMessage << '\n'; - return result; - } } catch (const std::exception& exception) { - result.errorMessage = "Failed to parse scene file: " + std::string(exception.what()); - std::cerr << result.errorMessage << '\n'; + result.errorMessage = "Failed to parse scene file " + path.string() + ": " + exception.what(); + spdlog::error("{}", result.errorMessage); + return result; + } + + if (auto error = ValidateSceneJson(result.root)) { + result.errorMessage = "Invalid scene data in " + path.string() + ": " + *error; + spdlog::error("{}", result.errorMessage); return result; } @@ -407,19 +528,17 @@ SceneSerializer::LoadResult SceneSerializer::LoadSceneFile(const std::filesystem bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, std::function progressCallback) { if (scene.IsIterating()) { - std::cerr << "Cannot load a scene during an update or render phase: " - << scene.GetName() << '\n'; + spdlog::error("Cannot load a scene during an update or render phase: {}", scene.GetName()); return false; } if (!result.success) { - std::cerr << "Cannot construct scene from a failed load result: " - << result.errorMessage << '\n'; + spdlog::error("Cannot construct scene from a failed load result: {}", result.errorMessage); return false; } if (scene.IsBeingUnloaded()) { - std::cerr << "Cannot load a scene that is being unloaded: " << scene.GetName() << '\n'; + spdlog::error("Cannot load a scene that is being unloaded: {}", scene.GetName()); return false; } @@ -436,6 +555,7 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, std::unordered_map idMap; std::vector objectJsonList; std::vector registeredObjects; + std::string context = "scene"; try { for (const auto& objectJson : root.at("objects")) { @@ -465,42 +585,24 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, for (const json* objectJson : objectJsonList) { const ObjectId id = objectJson->at("id").get(); GameObject* object = idMap.at(id); + context = "\"" + object->GetName() + "\" transform"; if (objectJson->contains("transform") && !objectJson->at("transform").empty()) { const auto& transformJson = objectJson->at("transform"); - const auto& position = transformJson.at("position"); const auto& rotation = transformJson.at("rotation"); - const auto& scale = transformJson.at("scale"); - object->GetTransform().SetLocalPosition({ - position.at(0).get(), - position.at(1).get(), - position.at(2).get() - }); + object->GetTransform().SetLocalPosition(transformJson.at("position").get()); if (rotation.size() == 4) { - object->GetTransform().SetLocalRotation({ - rotation.at(3).get(), - rotation.at(0).get(), - rotation.at(1).get(), - rotation.at(2).get() - }); + object->GetTransform().SetLocalRotation(rotation.get()); } else { // Version 1 scene files used three Euler angles in // degrees. Continue reading that format safely. - object->GetTransform().SetLocalRotation({ - rotation.at(0).get(), - rotation.at(1).get(), - rotation.at(2).get() - }); + object->GetTransform().SetLocalRotation(rotation.get()); } - object->GetTransform().SetLocalScale({ - scale.at(0).get(), - scale.at(1).get(), - scale.at(2).get() - }); + object->GetTransform().SetLocalScale(transformJson.at("scale").get()); } const ObjectId parentId = objectJson->value("parent", InvalidObjectId); @@ -519,9 +621,10 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, for (const auto& componentJson : objectJson->at("components")) { const std::string type = componentJson.at("type").get(); + context = type + " on \"" + object->GetName() + "\""; Component* component = ComponentFactory::Create(type, *object); if (component == nullptr) { - throw std::runtime_error("Unknown component type: " + type); + throw std::runtime_error("unknown component type"); } component->SetEnabled(componentJson.value("enabled", true)); @@ -540,6 +643,7 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, for (const auto& componentPtr : objectPtr->GetComponents()) { if (componentPtr) { + context = componentPtr->GetTypeName() + " on \"" + objectPtr->GetName() + "\" references"; componentPtr->ResolveReferences(idMap); } } @@ -552,10 +656,10 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, // its physics state can remain intact. for (const auto& objectPtr : stagingScene.GetObjects()) { if (objectPtr) { + context = "\"" + objectPtr->GetName() + "\" physics"; if (objectPtr->GetComponent() != nullptr && objectPtr->GetComponent() == nullptr) { - throw std::runtime_error( - "Rigidbody requires a collider on object " + objectPtr->GetName()); + throw std::runtime_error("Rigidbody requires a collider"); } scene.GetPhysics().RegisterGameObject(*objectPtr); registeredObjects.push_back(objectPtr.get()); @@ -564,7 +668,7 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, if (progressCallback) progressCallback(0.8f); } catch (const std::exception& exception) { - std::cerr << "Failed to load scene: " << exception.what() << '\n'; + spdlog::error("Failed to load scene ({}): {}", context, exception.what()); for (GameObject* object : registeredObjects) { if (object != nullptr) { scene.GetPhysics().UnregisterGameObject(*object); @@ -574,6 +678,7 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, return false; } + const bool bindingsWereLoaded = scene.m_bindingsLoaded; scene.UnloadBindings(); scene.RemoveAll(); scene.m_objects = std::move(stagingScene.m_objects); @@ -581,23 +686,29 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, if (root.contains("name")) { scene.m_name = root.at("name").get(); } + scene.m_SelectedObjectId = InvalidObjectId; scene.m_CurrentCameraId = root.value("currentCamera", InvalidObjectId); + if (scene.m_CurrentCameraId != InvalidObjectId && !idMap.contains(scene.m_CurrentCameraId)) { + spdlog::warn("Scene \"{}\" current camera id {} does not exist, ignoring it", + scene.m_name, scene.m_CurrentCameraId); + scene.m_CurrentCameraId = InvalidObjectId; + } scene.m_Environment = SceneEnvironment{}; if (root.contains("environment") && root.at("environment").is_object()) { const auto& data = root.at("environment"); SceneEnvironment& environment = scene.m_Environment; - const auto readColor = [&data](const char* key, glm::vec3& color) { - if (data.contains(key) && data.at(key).is_array() && data.at(key).size() == 3) { - const auto& value = data.at(key); - color = {value.at(0).get(), value.at(1).get(), value.at(2).get()}; - } - }; - readColor("ambientColor", environment.ambientColor); - environment.ambientIntensity = data.value("ambientIntensity", environment.ambientIntensity); - readColor("fogColor", environment.fogColor); - environment.fogDensity = data.value("fogDensity", environment.fogDensity); - environment.skyboxRotationSpeed = data.value("skyboxRotationSpeed", environment.skyboxRotationSpeed); - environment.skyboxRotation = data.value("skyboxRotation", environment.skyboxRotation); + try { + environment.ambientColor = data.value("ambientColor", environment.ambientColor); + environment.ambientIntensity = data.value("ambientIntensity", environment.ambientIntensity); + environment.fogColor = data.value("fogColor", environment.fogColor); + environment.fogDensity = data.value("fogDensity", environment.fogDensity); + environment.skyboxRotationSpeed = data.value("skyboxRotationSpeed", environment.skyboxRotationSpeed); + environment.skyboxRotation = data.value("skyboxRotation", environment.skyboxRotation); + } catch (const std::exception& exception) { + spdlog::warn("Scene \"{}\" has invalid environment settings, using defaults: {}", + scene.m_name, exception.what()); + environment = SceneEnvironment{}; + } } if (progressCallback) progressCallback(0.9f); @@ -613,6 +724,14 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, } } + if (bindingsWereLoaded) { + try { + scene.LoadBindings(); + } catch (const std::exception& exception) { + spdlog::error("Scene \"{}\" loaded but its bindings failed: {}", scene.m_name, exception.what()); + } + } + if (progressCallback) progressCallback(1.0f); return true; } diff --git a/lightkeeper/src/components/Door.cpp b/lightkeeper/src/components/Door.cpp index 0e91ba0..90c79cf 100644 --- a/lightkeeper/src/components/Door.cpp +++ b/lightkeeper/src/components/Door.cpp @@ -3,6 +3,7 @@ #include #include +#include #include @@ -60,7 +61,7 @@ void Door::ApplyPosition() nlohmann::json Door::Serialize() const { return { - {"openOffset", {m_OpenOffset.x, m_OpenOffset.y, m_OpenOffset.z}}, + {"openOffset", m_OpenOffset}, {"openDuration", m_OpenDuration}, {"toggleable", m_Toggleable}, {"open", m_IsOpen}, @@ -70,10 +71,7 @@ nlohmann::json Door::Serialize() const void Door::Deserialize(const nlohmann::json& data) { - if (data.contains("openOffset")) { - const auto& offset = data.at("openOffset"); - m_OpenOffset = {offset.at(0).get(), offset.at(1).get(), offset.at(2).get()}; - } + m_OpenOffset = data.value("openOffset", m_OpenOffset); m_OpenDuration = std::max(data.value("openDuration", m_OpenDuration), 0.0f); m_Toggleable = data.value("toggleable", m_Toggleable); m_IsOpen = data.value("open", m_IsOpen); diff --git a/lightkeeper/src/components/GameComponentRegistry.cpp b/lightkeeper/src/components/GameComponentRegistry.cpp index 581055b..e0106fd 100644 --- a/lightkeeper/src/components/GameComponentRegistry.cpp +++ b/lightkeeper/src/components/GameComponentRegistry.cpp @@ -1,20 +1,18 @@ #include +#include + #include #include void RegisterGameComponents() { - static bool registered = false; - if (registered) { - return; - } - - registered = true; - + static std::once_flag registered; + std::call_once(registered, [] { #define LIGHTKEEPER_REGISTER_COMPONENT(ComponentType) \ - ComponentFactory::Register(#ComponentType); - LIGHTKEEPER_GAME_COMPONENTS(LIGHTKEEPER_REGISTER_COMPONENT) + ComponentFactory::Register(#ComponentType); + LIGHTKEEPER_GAME_COMPONENTS(LIGHTKEEPER_REGISTER_COMPONENT) #undef LIGHTKEEPER_REGISTER_COMPONENT + }); } diff --git a/lightkeeper/src/components/MovingPlatform.cpp b/lightkeeper/src/components/MovingPlatform.cpp index c3edcb1..f08f337 100644 --- a/lightkeeper/src/components/MovingPlatform.cpp +++ b/lightkeeper/src/components/MovingPlatform.cpp @@ -4,6 +4,7 @@ #include #include +#include #include @@ -52,7 +53,7 @@ glm::vec3 MovingPlatform::GetOffsetAt(float progress) const nlohmann::json MovingPlatform::Serialize() const { return { - {"travelOffset", {m_TravelOffset.x, m_TravelOffset.y, m_TravelOffset.z}}, + {"travelOffset", m_TravelOffset}, {"travelDuration", m_TravelDuration}, {"pauseDuration", m_PauseDuration}, {"elapsedTime", m_ElapsedTime}, @@ -61,10 +62,7 @@ nlohmann::json MovingPlatform::Serialize() const void MovingPlatform::Deserialize(const nlohmann::json& data) { - if (data.contains("travelOffset")) { - const auto& offset = data.at("travelOffset"); - m_TravelOffset = {offset.at(0).get(), offset.at(1).get(), offset.at(2).get()}; - } + m_TravelOffset = data.value("travelOffset", m_TravelOffset); m_TravelDuration = std::max(data.value("travelDuration", m_TravelDuration), 0.01f); m_PauseDuration = std::max(data.value("pauseDuration", m_PauseDuration), 0.0f); m_ElapsedTime = std::max(data.value("elapsedTime", m_ElapsedTime), 0.0f); diff --git a/lightkeeper/src/components/PrintComponent.cpp b/lightkeeper/src/components/PrintComponent.cpp index f7562af..a65b618 100644 --- a/lightkeeper/src/components/PrintComponent.cpp +++ b/lightkeeper/src/components/PrintComponent.cpp @@ -22,7 +22,7 @@ void PrintComponent::ImGuiInspector() nlohmann::json PrintComponent::Serialize() const { - return {"message", m_message}; + return {{"message", m_message}}; } void PrintComponent::Deserialize(const nlohmann::json& basic_jsons) diff --git a/tests/destrum_tests.cpp b/tests/destrum_tests.cpp index b32a859..66b0c0c 100644 --- a/tests/destrum_tests.cpp +++ b/tests/destrum_tests.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -1341,6 +1342,137 @@ namespace { SceneManager::GetInstance().Destroy(); } + std::string readFile(const std::filesystem::path& path) + { + std::ifstream file(path, std::ios::binary); + return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; + } + + void testSceneSaveRejectsNonFinite() + { + Scene& scene = SceneManager::GetInstance().CreateScene("non-finite"); + GameObject* object = scene.CreateGameObject("object"); + scene.CommitPendingAdditions(); + + const auto path = std::filesystem::temp_directory_path() / "destrum_non_finite_scene.json"; + check(SceneSerializer::Save(scene, path), "a finite scene must save"); + const std::string goodContents = readFile(path); + + object->GetTransform().SetLocalPosition({std::nanf(""), 0.0f, 0.0f}); + check(!SceneSerializer::Save(scene, path), "a scene with NaN values must not save"); + check(readFile(path) == goodContents, "a failed save must leave the previous file untouched"); + check(!std::filesystem::exists(std::filesystem::path{path} += ".tmp"), + "a failed save must not leave its temp file behind"); + + std::filesystem::remove(path); + SceneManager::GetInstance().Destroy(); + } + + void testSceneValidationErrors() + { + const auto path = std::filesystem::temp_directory_path() / "destrum_invalid_scene.json"; + std::ofstream{path} + << R"({"version":2,"name":"bad","objects":[{"id":7,"name":"Lamp","transform":{"position":[0,0,0],"rotation":[0,0,0,1],"scale":[1,1,1]},"parent":0,"components":[{"type":"NotAComponent","enabled":true,"data":{}}]}]})"; + + const auto result = SceneSerializer::LoadSceneFile(path); + check(!result.success, "a scene with an unknown component must not load"); + check(result.errorMessage.find("\"Lamp\"") != std::string::npos && + result.errorMessage.find("NotAComponent") != std::string::npos && + result.errorMessage.find("unknown component type") != std::string::npos, + "scene validation errors must name the object, component and problem"); + + std::ofstream{path} + << R"({"version":2,"objects":[{"id":7,"name":"Lamp","components":[{"type":"Spinner","data":["message","hi"]}]}]})"; + const auto arrayData = SceneSerializer::LoadSceneFile(path); + check(!arrayData.success && arrayData.errorMessage.find("must be an object") != std::string::npos, + "component data that is not an object must be rejected with a reason"); + + std::filesystem::remove(path); + } + + void testSceneLoadRestoresBindings() + { + Scene& scene = SceneManager::GetInstance().CreateScene("bindings"); + int registered = 0; + int unregistered = 0; + scene.SetRegisterBindings([®istered] { ++registered; }); + scene.SetUnregisterBindings([&unregistered] { ++unregistered; }); + scene.Load(); + const int registeredBefore = registered; + const int unregisteredBefore = unregistered; + scene.CreateGameObject("object"); + scene.CommitPendingAdditions(); + + const auto path = std::filesystem::temp_directory_path() / "destrum_bindings_scene.json"; + check(SceneSerializer::Save(scene, path), "the bindings scene must save"); + check(SceneSerializer::Load(scene, path), "the bindings scene must load"); + std::filesystem::remove(path); + + check(registered - registeredBefore == 1 && unregistered - unregisteredBefore == 1, "loading a scene in place must register its bindings again"); + + scene.SetRegisterBindings(nullptr); + scene.SetUnregisterBindings(nullptr); + SceneManager::GetInstance().Destroy(); + } + + void testSceneLoadDropsMissingCamera() + { + Scene& scene = SceneManager::GetInstance().CreateScene("missing camera"); + const auto path = std::filesystem::temp_directory_path() / "destrum_missing_camera_scene.json"; + std::ofstream{path} + << R"({"version":2,"currentCamera":4242,"objects":[{"id":11,"name":"object"}]})"; + + check(SceneSerializer::Load(scene, path), "a scene with a dangling camera id must still load"); + check(scene.GetCurrentCamera() == nullptr, "a dangling current camera id must be ignored"); + + std::filesystem::remove(path); + SceneManager::GetInstance().Destroy(); + } + + void testGlmJson() + { + const nlohmann::json vec = glm::vec3{1.0f, 2.5f, -3.0f}; + check(vec == nlohmann::json::array({1.0f, 2.5f, -3.0f}), "vec3 must serialize as a 3 number array"); + check(vec.get() == glm::vec3{1.0f, 2.5f, -3.0f}, "vec3 must round-trip through json"); + + const glm::quat rotation = glm::angleAxis(0.5f, glm::vec3{0.0f, 1.0f, 0.0f}); + const nlohmann::json quat = rotation; + check(quat.size() == 4 && close(quat.at(3).get(), rotation.w), + "quaternions must serialize as [x, y, z, w]"); + check(quat.get() == rotation, "quaternions must round-trip through json"); + + bool rejected = false; + try { + (void)nlohmann::json::array({1.0f, 2.0f}).get(); + } catch (const std::exception&) { + rejected = true; + } + check(rejected, "a vec3 must reject arrays of the wrong size"); + + const nlohmann::json data = {{"axis", nlohmann::json::array({0, 1, 0})}}; + check(data.value("axis", glm::vec3{0.0f}) == glm::vec3{0.0f, 1.0f, 0.0f} && + data.value("missing", glm::vec3{7.0f}) == glm::vec3{7.0f}, + "json value() must work with glm defaults"); + } + + void testPrimitiveMeshKeys() + { + const glm::vec3 size{0.35f, 0.12f, 3.0f}; + const std::string key = PrimitiveMeshes::BoxKey(size); + check(PrimitiveMeshes::IsPrimitiveKey(key), "box keys must be primitive keys"); + check(PrimitiveMeshes::MakeBox(size).name == key, "boxes must be named by their key"); + + const auto rebuilt = PrimitiveMeshes::MakeFromKey(key + "#3"); + check(rebuilt.has_value() && rebuilt->maxPos == size * 0.5f, + "a box must rebuild exactly from its key, ignoring the duplicate suffix"); + check(PrimitiveMeshes::StripDuplicateSuffix("BulbMaterial#12") == "BulbMaterial" && + PrimitiveMeshes::StripDuplicateSuffix("game://a.glb#mesh") == "game://a.glb#mesh", + "only numeric duplicate suffixes must be stripped"); + check(!PrimitiveMeshes::MakeFromKey("prim://box?size=1,2").has_value() && + !PrimitiveMeshes::MakeFromKey("prim://box?size=1,-2,3").has_value() && + !PrimitiveMeshes::MakeFromKey("Box").has_value(), + "invalid primitive keys must not build a mesh"); + } } int main() @@ -1380,6 +1512,12 @@ int main() testKinematicBodiesMoveWithVelocity(); testRenderer2DShapes(); testFontAtlasAndLayout(); + testSceneSaveRejectsNonFinite(); + testSceneValidationErrors(); + testSceneLoadRestoresBindings(); + testSceneLoadDropsMissingCamera(); + testGlmJson(); + testPrimitiveMeshKeys(); std::cout << "destrum tests passed\n"; return EXIT_SUCCESS; }