From e6e4b76c36266e601b8331daf85befc1815dae15 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 8 Jul 2026 18:17:50 -0400 Subject: [PATCH 1/5] feat(lights): scene round-trip for lights via glTF metadata and FBX sidecar (#489). Persist rig-grouped lights through scene save/load using qtmesh.scene.lights metadata, export FBX with a .lights.json sidecar for bit-exact restore, and surface light properties in qtmesh info --json. Co-authored-by: Cursor --- src/Assimp/Importer.h | 2 + src/CLIPipeline.cpp | 35 +- src/CMakeLists.txt | 2 + src/LightRigLibrary.cpp | 7 + src/MeshImporterExporter.cpp | 15 + src/SceneLightsIO.cpp | 809 +++++++++++++++++++++++++++++++++++ src/SceneLightsIO.h | 75 ++++ src/SceneLightsIO_test.cpp | 197 +++++++++ tests/CMakeLists.txt | 2 + 9 files changed, 1140 insertions(+), 4 deletions(-) create mode 100644 src/SceneLightsIO.cpp create mode 100644 src/SceneLightsIO.h create mode 100644 src/SceneLightsIO_test.cpp diff --git a/src/Assimp/Importer.h b/src/Assimp/Importer.h index 784301544..cdd4dac36 100644 --- a/src/Assimp/Importer.h +++ b/src/Assimp/Importer.h @@ -42,6 +42,8 @@ class AssimpToOgreImporter { // Non-null only when loadModel() processed an animation-only file (no mesh geometry). Ogre::SkeletonPtr getLoadedSkeleton() const { return skeleton; } + const aiScene* getImportedScene() const { return importer.GetScene(); } + // Returns the UpAxis from FBX metadata of the last loaded scene. // 1 = Y-up (Mixamo, default), 2 = Z-up (Unreal Engine). // Always returns 1 for non-FBX formats or when metadata is absent. diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 11e67eb3b..ae03571fc 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -3,6 +3,7 @@ #include "GamificationManager.h" #include "Manager.h" #include "MeshImporterExporter.h" +#include "SceneLightsIO.h" #include "AnimationMerger.h" #include "MotionInbetween.h" #include "MotionLibrary.h" @@ -1617,6 +1618,12 @@ int CLIPipeline::cmdInfo(int argc, char* argv[]) SentryReporter::addBreadcrumb("cli.info", QString("Inspect .%1%2").arg(fi.suffix(), jsonOutput ? " json=true" : "")); + QString lightError; + const QJsonObject lightsPayload = + SceneLightsIO::lightsInfoJsonFromFile(fi.absoluteFilePath(), &lightError); + const int lightsInFile = lightsPayload.value(QStringLiteral("lightCount")).toInt(); + const bool hasLightsInFile = lightsInFile > 0; + // Load the file; animation-only files produce no entity but populate animOnlySkeletons. QList animOnlySkeletons; int upAxis = 1; @@ -1660,6 +1667,12 @@ int CLIPipeline::cmdInfo(int argc, char* argv[]) } if (entities.isEmpty()) { + if (jsonOutput && hasLightsInFile) { + cliWrite(QString::fromUtf8( + QJsonDocument(lightsPayload).toJson(QJsonDocument::Indented))); + maybePrintCloudPromo(jsonOutput); + return 0; + } SentryReporter::captureMessage(QString("CLI info: import failed (.%1)").arg(fi.suffix()), "error"); err() << "Error: Failed to load file: " << filePath << Qt::endl; return 1; @@ -1675,10 +1688,24 @@ int CLIPipeline::cmdInfo(int argc, char* argv[]) arr.append(doc.object()); } // Single entity: emit object directly; multiple: emit array - if (arr.size() == 1) - cliWrite(QString::fromUtf8(QJsonDocument(arr[0].toObject()).toJson(QJsonDocument::Indented))); - else - cliWrite(QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Indented))); + if (arr.size() == 1) { + QJsonObject root = arr[0].toObject(); + if (hasLightsInFile) { + root.insert(QStringLiteral("lights"), lightsPayload.value(QStringLiteral("lights"))); + root.insert(QStringLiteral("ambient"), lightsPayload.value(QStringLiteral("ambient"))); + root.insert(QStringLiteral("lightCount"), lightsInFile); + } + cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented))); + } else { + QJsonObject root; + root.insert(QStringLiteral("meshes"), arr); + if (hasLightsInFile) { + root.insert(QStringLiteral("lights"), lightsPayload.value(QStringLiteral("lights"))); + root.insert(QStringLiteral("ambient"), lightsPayload.value(QStringLiteral("ambient"))); + root.insert(QStringLiteral("lightCount"), lightsInFile); + } + cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented))); + } } else { for (Ogre::Entity* entity : entities) { MeshInfo info = extractMeshInfo(entity, fi.fileName()); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a32f8046c..8174078ca 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -41,6 +41,7 @@ LightsController.cpp LightPropertiesController.cpp SceneLightingController.cpp ShadowController.cpp +SceneLightsIO.cpp SelectionBoxObject.cpp ObjectItemModel.cpp MaterialComboDelegate.cpp @@ -245,6 +246,7 @@ LightsController.h LightPropertiesController.h SceneLightingController.h ShadowController.h +SceneLightsIO.h SelectionBoxObject.h ObjectItemModel.h MaterialComboDelegate.h diff --git a/src/LightRigLibrary.cpp b/src/LightRigLibrary.cpp index fa1a832f7..fe5b442b1 100644 --- a/src/LightRigLibrary.cpp +++ b/src/LightRigLibrary.cpp @@ -2,6 +2,7 @@ #include "AppSettingsKeys.h" #include "Manager.h" +#include "SceneLightsIO.h" #include "SentryReporter.h" #include "ShadowController.h" @@ -359,7 +360,11 @@ Ogre::SceneNode* createRigGroupForRig(const QString& rigId) Ogre::SceneNode* rigGroup = lights->createRigGroupNode(spec->groupName); if (rigGroup) + { tagRigGroup(rigGroup); + rigGroup->getUserObjectBindings().setUserAny( + SceneLightsIO::kRigIdUserKey, Ogre::Any(rigId.toStdString())); + } return rigGroup; } @@ -412,6 +417,8 @@ LightRigApplyResult apply(const QString& rigId, bool replaceExisting) } tagRigGroup(rigGroup); result.rigGroupNodeName = QString::fromStdString(rigGroup->getName()); + rigGroup->getUserObjectBindings().setUserAny( + SceneLightsIO::kRigIdUserKey, Ogre::Any(rigId.toStdString())); for (const RigLightSpec& lightSpec : spec->lights) { diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index e214e3348..5d2b9bd9b 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -61,7 +61,9 @@ THE SOFTWARE. #include "OgreXML/pugixml.hpp" #include "AnimationMerger.h" +#include "LightManager.h" #include "Manager.h" +#include "SceneLightsIO.h" #include "SelectionSet.h" #include "SentryReporter.h" #include "ExportOptimizer.h" @@ -2675,6 +2677,10 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad bool convertLH = (file.suffix().compare("x", Qt::CaseInsensitive) != 0); const std::string sourcePath = file.filePath().toStdString(); Ogre::MeshPtr mesh = importer.loadModel(sourcePath, convertLH, additionalFlags); + if (!SceneLightsIO::importLightsSidecar(file.filePath(), false)) { + if (const aiScene* importScene = importer.getImportedScene()) + SceneLightsIO::importFromAssimpScene(importScene, false); + } // Read coordinate system from metadata immediately — valid for both mesh and animation-only files. if (outUpAxis) *outUpAxis = importer.getSceneUpAxis(); if (mesh) { @@ -2959,6 +2965,7 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // .material and extracted image files next to the FBX. if (!ok) return -1; + SceneLightsIO::writeLightsSidecar(_uri); } else if (_format == QStringLiteral("PlayStation TMD (*.tmd)")) { if (!PS1TMD::exportEntity(e, _uri)) return -1; @@ -3739,6 +3746,7 @@ static aiScene* buildSceneAiScene() scene->mNumMaterials = 1; scene->mMaterials = new aiMaterial*[1]; scene->mMaterials[0] = new aiMaterial(); + SceneLightsIO::appendLightsToAiScene(scene, SceneLightsIO::captureFromScene()); return scene; } @@ -3763,6 +3771,7 @@ static aiScene* buildSceneAiScene() scene->mNumMaterials = 1; scene->mMaterials = new aiMaterial*[1]; scene->mMaterials[0] = new aiMaterial(); + SceneLightsIO::appendLightsToAiScene(scene, SceneLightsIO::captureFromScene()); return scene; } @@ -3926,6 +3935,8 @@ static aiScene* buildSceneAiScene() scene->mAnimations[i] = allAnimations[i]; } + SceneLightsIO::appendLightsToAiScene(scene, SceneLightsIO::captureFromScene()); + return scene; } @@ -4050,6 +4061,8 @@ bool MeshImporterExporter::sceneImporter(const QString &_uri) SelectionSet::getSingleton()->clearList(); auto* manager = Manager::getSingleton(); emit manager->sceneClearing(); // let listeners clean up before nodes are destroyed + if (auto* lights = LightManager::getSingletonPtr()) + lights->deleteAllUserLights(); auto sceneNodesCopy = manager->getSceneNodes(); for (auto* sn : sceneNodesCopy) manager->destroySceneNode(sn); @@ -4406,6 +4419,8 @@ bool MeshImporterExporter::sceneImporter(const QString &_uri) manager->createEntity(sn, ogreMesh); } + SceneLightsIO::importFromAssimpScene(scene, true); + return true; } catch (Ogre::Exception& e) { Ogre::LogManager::getSingleton().logError("Scene import failed: " + e.getFullDescription()); diff --git a/src/SceneLightsIO.cpp b/src/SceneLightsIO.cpp new file mode 100644 index 000000000..72d428328 --- /dev/null +++ b/src/SceneLightsIO.cpp @@ -0,0 +1,809 @@ +#include "SceneLightsIO.h" + +#include "LightManager.h" +#include "LightRigLibrary.h" +#include "Manager.h" +#include "ShadowController.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace +{ + +QString lightTypeToString(Ogre::Light::LightTypes type) +{ + switch (type) + { + case Ogre::Light::LT_DIRECTIONAL: + return QStringLiteral("directional"); + case Ogre::Light::LT_POINT: + return QStringLiteral("point"); + case Ogre::Light::LT_SPOTLIGHT: + return QStringLiteral("spot"); + default: + return QStringLiteral("point"); + } +} + +bool lightTypeFromString(const QString& text, Ogre::Light::LightTypes& out) +{ + if (text == QStringLiteral("directional")) + { + out = Ogre::Light::LT_DIRECTIONAL; + return true; + } + if (text == QStringLiteral("point")) + { + out = Ogre::Light::LT_POINT; + return true; + } + if (text == QStringLiteral("spot") || text == QStringLiteral("spotlight")) + { + out = Ogre::Light::LT_SPOTLIGHT; + return true; + } + return false; +} + +QJsonArray colourToJson(const Ogre::ColourValue& c) +{ + return QJsonArray{c.r, c.g, c.b, c.a}; +} + +bool colourFromJson(const QJsonValue& value, Ogre::ColourValue& out) +{ + if (!value.isArray()) + return false; + const QJsonArray arr = value.toArray(); + if (arr.size() < 3) + return false; + out.r = static_cast(arr.at(0).toDouble()); + out.g = static_cast(arr.at(1).toDouble()); + out.b = static_cast(arr.at(2).toDouble()); + out.a = arr.size() > 3 ? static_cast(arr.at(3).toDouble()) : 1.0f; + return true; +} + +QJsonArray vector3ToJson(const Ogre::Vector3& v) +{ + return QJsonArray{v.x, v.y, v.z}; +} + +bool vector3FromJson(const QJsonValue& value, Ogre::Vector3& out) +{ + if (!value.isArray()) + return false; + const QJsonArray arr = value.toArray(); + if (arr.size() < 3) + return false; + out.x = static_cast(arr.at(0).toDouble()); + out.y = static_cast(arr.at(1).toDouble()); + out.z = static_cast(arr.at(2).toDouble()); + return true; +} + +QJsonArray quaternionToJson(const Ogre::Quaternion& q) +{ + return QJsonArray{q.w, q.x, q.y, q.z}; +} + +bool quaternionFromJson(const QJsonValue& value, Ogre::Quaternion& out) +{ + if (!value.isArray()) + return false; + const QJsonArray arr = value.toArray(); + if (arr.size() < 4) + return false; + out.w = static_cast(arr.at(0).toDouble()); + out.x = static_cast(arr.at(1).toDouble()); + out.y = static_cast(arr.at(2).toDouble()); + out.z = static_cast(arr.at(3).toDouble()); + return true; +} + +QJsonObject snapshotToJson(const LightSnapshot& snapshot) +{ + QJsonObject obj; + obj.insert(QStringLiteral("name"), snapshot.name); + obj.insert(QStringLiteral("type"), lightTypeToString(snapshot.type)); + obj.insert(QStringLiteral("enabled"), snapshot.enabled); + obj.insert(QStringLiteral("diffuse"), colourToJson(snapshot.diffuse)); + obj.insert(QStringLiteral("specular"), colourToJson(snapshot.specular)); + obj.insert(QStringLiteral("powerScale"), snapshot.powerScale); + obj.insert(QStringLiteral("position"), vector3ToJson(snapshot.position)); + obj.insert(QStringLiteral("orientation"), quaternionToJson(snapshot.orientation)); + obj.insert(QStringLiteral("scale"), vector3ToJson(snapshot.scale)); + obj.insert(QStringLiteral("usesDirection"), snapshot.usesDirection); + obj.insert(QStringLiteral("direction"), vector3ToJson(snapshot.direction)); + obj.insert(QStringLiteral("attenuationRange"), snapshot.attenuationRange); + obj.insert(QStringLiteral("attenuationConstant"), snapshot.attenuationConstant); + obj.insert(QStringLiteral("attenuationLinear"), snapshot.attenuationLinear); + obj.insert(QStringLiteral("attenuationQuadratic"), snapshot.attenuationQuadratic); + obj.insert(QStringLiteral("spotlightInnerAngleDeg"), snapshot.spotlightInnerAngleDeg); + obj.insert(QStringLiteral("spotlightOuterAngleDeg"), snapshot.spotlightOuterAngleDeg); + obj.insert(QStringLiteral("spotlightFalloff"), snapshot.spotlightFalloff); + obj.insert(QStringLiteral("castShadows"), snapshot.castShadows); + obj.insert(QStringLiteral("shadowDepthBias"), snapshot.shadowDepthBias); + obj.insert(QStringLiteral("shadowSlopeBias"), snapshot.shadowSlopeBias); + return obj; +} + +bool snapshotFromJson(const QJsonObject& obj, LightSnapshot& snapshot) +{ + snapshot = {}; + snapshot.name = obj.value(QStringLiteral("name")).toString(); + if (snapshot.name.isEmpty()) + return false; + + Ogre::Light::LightTypes type = Ogre::Light::LT_POINT; + if (!lightTypeFromString(obj.value(QStringLiteral("type")).toString(), type)) + return false; + snapshot.type = type; + + snapshot.enabled = obj.value(QStringLiteral("enabled")).toBool(true); + if (!colourFromJson(obj.value(QStringLiteral("diffuse")), snapshot.diffuse)) + snapshot.diffuse = Ogre::ColourValue::White; + if (!colourFromJson(obj.value(QStringLiteral("specular")), snapshot.specular)) + snapshot.specular = Ogre::ColourValue(0.8f, 0.8f, 0.8f, 1.0f); + snapshot.powerScale = static_cast(obj.value(QStringLiteral("powerScale")).toDouble(1.0)); + vector3FromJson(obj.value(QStringLiteral("position")), snapshot.position); + quaternionFromJson(obj.value(QStringLiteral("orientation")), snapshot.orientation); + vector3FromJson(obj.value(QStringLiteral("scale")), snapshot.scale); + if (snapshot.scale == Ogre::Vector3::ZERO) + snapshot.scale = Ogre::Vector3::UNIT_SCALE; + snapshot.usesDirection = obj.value(QStringLiteral("usesDirection")).toBool(false); + vector3FromJson(obj.value(QStringLiteral("direction")), snapshot.direction); + snapshot.attenuationRange = + static_cast(obj.value(QStringLiteral("attenuationRange")).toDouble(1000.0)); + snapshot.attenuationConstant = + static_cast(obj.value(QStringLiteral("attenuationConstant")).toDouble(1.0)); + snapshot.attenuationLinear = + static_cast(obj.value(QStringLiteral("attenuationLinear")).toDouble(0.0)); + snapshot.attenuationQuadratic = + static_cast(obj.value(QStringLiteral("attenuationQuadratic")).toDouble(0.0)); + snapshot.spotlightInnerAngleDeg = + static_cast(obj.value(QStringLiteral("spotlightInnerAngleDeg")).toDouble(30.0)); + snapshot.spotlightOuterAngleDeg = + static_cast(obj.value(QStringLiteral("spotlightOuterAngleDeg")).toDouble(40.0)); + snapshot.spotlightFalloff = + static_cast(obj.value(QStringLiteral("spotlightFalloff")).toDouble(1.0)); + snapshot.castShadows = obj.value(QStringLiteral("castShadows")).toBool(false); + snapshot.shadowDepthBias = + static_cast(obj.value(QStringLiteral("shadowDepthBias")).toDouble(0.00005)); + snapshot.shadowSlopeBias = + static_cast(obj.value(QStringLiteral("shadowSlopeBias")).toDouble(1.0)); + return true; +} + +QString rigIdFromSceneNode(Ogre::SceneNode* node) +{ + if (!node) + return {}; + const auto any = node->getUserObjectBindings().getUserAny(SceneLightsIO::kRigIdUserKey); + if (!any.has_value()) + return {}; + try + { + return QString::fromStdString(Ogre::any_cast(any)); + } + catch (...) + { + return {}; + } +} + +aiMatrix4x4 toAiMatrix(const Ogre::Matrix4& m) +{ + aiMatrix4x4 out; + out.a1 = m[0][0]; + out.a2 = m[0][1]; + out.a3 = m[0][2]; + out.a4 = m[0][3]; + out.b1 = m[1][0]; + out.b2 = m[1][1]; + out.b3 = m[1][2]; + out.b4 = m[1][3]; + out.c1 = m[2][0]; + out.c2 = m[2][1]; + out.c3 = m[2][2]; + out.c4 = m[2][3]; + out.d1 = m[3][0]; + out.d2 = m[3][1]; + out.d3 = m[3][2]; + out.d4 = m[3][3]; + return out; +} + +aiMatrix4x4 localAiMatrix(const LightSnapshot& snapshot) +{ + Ogre::Matrix4 m; + m.makeTransform(snapshot.position, snapshot.scale, snapshot.orientation); + return toAiMatrix(m); +} + +aiLightSourceType ogreTypeToAssimp(Ogre::Light::LightTypes type) +{ + switch (type) + { + case Ogre::Light::LT_DIRECTIONAL: + return aiLightSource_DIRECTIONAL; + case Ogre::Light::LT_POINT: + return aiLightSource_POINT; + case Ogre::Light::LT_SPOTLIGHT: + return aiLightSource_SPOT; + default: + return aiLightSource_POINT; + } +} + +Ogre::Light::LightTypes assimpTypeToOgre(aiLightSourceType type) +{ + switch (type) + { + case aiLightSource_DIRECTIONAL: + return Ogre::Light::LT_DIRECTIONAL; + case aiLightSource_POINT: + return Ogre::Light::LT_POINT; + case aiLightSource_SPOT: + return Ogre::Light::LT_SPOTLIGHT; + default: + return Ogre::Light::LT_POINT; + } +} + +const aiNode* findNodeByName(const aiNode* node, const std::string& name) +{ + if (!node) + return nullptr; + if (node->mName == aiString(name)) + return node; + for (unsigned int i = 0; i < node->mNumChildren; ++i) + { + if (const aiNode* found = findNodeByName(node->mChildren[i], name)) + return found; + } + return nullptr; +} + +void decomposeAiMatrix(const aiMatrix4x4& m, + Ogre::Vector3& pos, + Ogre::Quaternion& orient, + Ogre::Vector3& scale) +{ + aiVector3D aiPos; + aiVector3D aiScale; + aiQuaternion aiRot; + m.Decompose(aiScale, aiRot, aiPos); + pos = Ogre::Vector3(aiPos.x, aiPos.y, aiPos.z); + orient = Ogre::Quaternion(aiRot.w, aiRot.x, aiRot.y, aiRot.z); + scale = Ogre::Vector3(aiScale.x, aiScale.y, aiScale.z); +} + +LightSnapshot snapshotFromAssimpLight(const aiLight* light, const aiNode* node) +{ + LightSnapshot snapshot; + if (!light || !node) + return snapshot; + + snapshot.name = QString::fromUtf8(light->mName.C_Str()); + snapshot.type = assimpTypeToOgre(light->mType); + snapshot.enabled = true; + snapshot.diffuse = + Ogre::ColourValue(light->mColorDiffuse.r, light->mColorDiffuse.g, light->mColorDiffuse.b); + snapshot.specular = + Ogre::ColourValue(light->mColorSpecular.r, light->mColorSpecular.g, light->mColorSpecular.b); + + const float importedIntensity = std::max(SceneLightsIO::ogreLuminance(snapshot.diffuse), 1e-6f); + snapshot.powerScale = 1.0f; + snapshot.diffuse.r /= importedIntensity; + snapshot.diffuse.g /= importedIntensity; + snapshot.diffuse.b /= importedIntensity; + + decomposeAiMatrix(node->mTransformation, snapshot.position, snapshot.orientation, snapshot.scale); + if (snapshot.scale == Ogre::Vector3::ZERO) + snapshot.scale = Ogre::Vector3::UNIT_SCALE; + + snapshot.attenuationConstant = light->mAttenuationConstant; + snapshot.attenuationLinear = light->mAttenuationLinear; + snapshot.attenuationQuadratic = light->mAttenuationQuadratic; + snapshot.attenuationRange = 1000.0f; + + if (snapshot.type == Ogre::Light::LT_SPOTLIGHT) + { + snapshot.spotlightInnerAngleDeg = + Ogre::Radian(light->mAngleInnerCone).valueDegrees(); + snapshot.spotlightOuterAngleDeg = + Ogre::Radian(light->mAngleOuterCone).valueDegrees(); + snapshot.spotlightFalloff = 1.0f; + } + + if (snapshot.type == Ogre::Light::LT_DIRECTIONAL || snapshot.type == Ogre::Light::LT_SPOTLIGHT) + { + snapshot.usesDirection = true; + Ogre::Vector3 localDir(light->mDirection.x, light->mDirection.y, light->mDirection.z); + if (localDir.squaredLength() < 1e-8f) + localDir = Ogre::Vector3::NEGATIVE_UNIT_Z; + snapshot.direction = snapshot.orientation * localDir; + snapshot.direction.normalise(); + } + + return snapshot; +} + +aiLight* buildAssimpLight(const LightSnapshot& snapshot) +{ + auto* light = new aiLight(); + light->mName = aiString(snapshot.name.toUtf8().constData()); + light->mType = ogreTypeToAssimp(snapshot.type); + light->mColorDiffuse = + aiColor3D(snapshot.diffuse.r, snapshot.diffuse.g, snapshot.diffuse.b); + light->mColorSpecular = + aiColor3D(snapshot.specular.r, snapshot.specular.g, snapshot.specular.b); + light->mAttenuationConstant = snapshot.attenuationConstant; + light->mAttenuationLinear = snapshot.attenuationLinear; + light->mAttenuationQuadratic = snapshot.attenuationQuadratic; + + const float gltfIntensity = SceneLightsIO::powerScaleToGltfIntensity(snapshot); + const float lum = std::max(SceneLightsIO::ogreLuminance(snapshot.diffuse), 1e-6f); + const float chromaScale = gltfIntensity / lum; + light->mColorDiffuse.r *= chromaScale; + light->mColorDiffuse.g *= chromaScale; + light->mColorDiffuse.b *= chromaScale; + + if (snapshot.type == Ogre::Light::LT_SPOTLIGHT) + { + light->mAngleInnerCone = Ogre::Degree(snapshot.spotlightInnerAngleDeg).valueRadians(); + light->mAngleOuterCone = Ogre::Degree(snapshot.spotlightOuterAngleDeg).valueRadians(); + } + + if (snapshot.usesDirection) + { + Ogre::Vector3 localDir = snapshot.orientation.Inverse() * snapshot.direction; + localDir.normalise(); + light->mDirection = aiVector3D(localDir.x, localDir.y, localDir.z); + } + + return light; +} + +aiNode* makeAiNodeTree(const QString& name, + const aiMatrix4x4& transform, + aiNode* parent, + std::vector& storage) +{ + auto* node = new aiNode(name.toUtf8().constData()); + node->mParent = parent; + node->mTransformation = transform; + storage.push_back(node); + return node; +} + +void appendChildNode(aiNode* parent, aiNode* child, std::vector& rootChildren) +{ + if (!parent) + { + rootChildren.push_back(child); + return; + } + + const unsigned int oldCount = parent->mNumChildren; + auto** newChildren = new aiNode*[oldCount + 1]; + for (unsigned int i = 0; i < oldCount; ++i) + newChildren[i] = parent->mChildren[i]; + newChildren[oldCount] = child; + delete[] parent->mChildren; + parent->mChildren = newChildren; + parent->mNumChildren = oldCount + 1; +} + +} // namespace + +namespace SceneLightsIO +{ + +float ogreLuminance(const Ogre::ColourValue& colour) +{ + return 0.2126f * colour.r + 0.7152f * colour.g + 0.0722f * colour.b; +} + +float powerScaleToGltfIntensity(const LightSnapshot& snapshot) +{ + // KHR_lights_punctual stores a scalar intensity separate from colour. + // QtMeshEditor stores per-light colour × powerScale in Ogre. We map: + // intensity = powerScale × Rec.601_luminance(diffuse) + // Directional lights are interpreted as lux-like; point/spot as candela-like. + // This is best-effort for third-party viewers — use qtmesh.scene.lights metadata + // (written on every scene export) for bit-exact round-trip inside QtMeshEditor. + return snapshot.powerScale * ogreLuminance(snapshot.diffuse); +} + +SceneLightsDocument captureFromScene() +{ + SceneLightsDocument doc; + auto* mgr = Manager::getSingletonPtr(); + auto* lights = LightManager::getSingletonPtr(); + if (!mgr || !mgr->getSceneMgr() || !lights) + return doc; + + doc.ambient = mgr->getSceneMgr()->getAmbientLight(); + + Ogre::SceneNode* root = mgr->getSceneMgr()->getRootSceneNode(); + std::map groupByNode; + + for (const auto& child : root->getChildren()) + { + auto* node = static_cast(child); + if (!LightRigLibrary::sceneNodeIsRigGroup(node)) + continue; + + RigGroupExport group; + group.name = QString::fromStdString(node->getName()); + group.rigId = rigIdFromSceneNode(node); + group.preserveGrouping = true; + doc.rigGroups.append(group); + groupByNode[node] = &doc.rigGroups.last(); + } + + for (const LightHandle& handle : lights->lights()) + { + if (!handle.isValid()) + continue; + const LightSnapshot snapshot = LightSnapshot::fromHandle(handle); + auto* parent = static_cast(handle.sceneNode->getParent()); + if (parent && groupByNode.count(parent)) + groupByNode[parent]->lights.append(snapshot); + else + doc.standaloneLights.append(snapshot); + } + + return doc; +} + +bool applyToLightManager(const SceneLightsDocument& doc, bool useDefaultWhenEmpty) +{ + auto* lights = LightManager::getSingletonPtr(); + auto* mgr = Manager::getSingletonPtr(); + if (!lights || !mgr || !mgr->getSceneMgr()) + return false; + + lights->deleteAllUserLights(); + mgr->getSceneMgr()->setAmbientLight(doc.ambient); + + int totalLights = doc.standaloneLights.size(); + for (const RigGroupExport& group : doc.rigGroups) + totalLights += group.lights.size(); + + if (totalLights == 0) + { + if (useDefaultWhenEmpty) + LightRigLibrary::applyDefaultSceneLighting(); + return true; + } + + for (const RigGroupExport& group : doc.rigGroups) + { + if (group.lights.isEmpty()) + continue; + + Ogre::SceneNode* rigNode = lights->createRigGroupNode(group.name); + if (!rigNode) + continue; + + LightRigLibrary::tagRigGroupNode(rigNode); + if (!group.rigId.isEmpty()) + { + rigNode->getUserObjectBindings().setUserAny( + kRigIdUserKey, Ogre::Any(group.rigId.toStdString())); + } + + for (const LightSnapshot& snapshot : group.lights) + lights->restoreSnapshotUnderParent(rigNode, snapshot); + } + + for (const LightSnapshot& snapshot : doc.standaloneLights) + lights->restoreSnapshot(snapshot); + + if (auto* shadows = ShadowController::instance()) + shadows->syncFromScene(); + + return true; +} + +QByteArray documentToJson(const SceneLightsDocument& doc) +{ + QJsonObject root; + root.insert(QStringLiteral("version"), doc.version); + root.insert(QStringLiteral("ambient"), colourToJson(doc.ambient)); + + QJsonArray rigGroups; + for (const RigGroupExport& group : doc.rigGroups) + { + QJsonObject obj; + obj.insert(QStringLiteral("name"), group.name); + if (!group.rigId.isEmpty()) + obj.insert(QStringLiteral("rigId"), group.rigId); + obj.insert(QStringLiteral("preserveGrouping"), group.preserveGrouping); + QJsonArray lightsArr; + for (const LightSnapshot& snapshot : group.lights) + lightsArr.append(snapshotToJson(snapshot)); + obj.insert(QStringLiteral("lights"), lightsArr); + rigGroups.append(obj); + } + root.insert(QStringLiteral("rigGroups"), rigGroups); + + QJsonArray standalone; + for (const LightSnapshot& snapshot : doc.standaloneLights) + standalone.append(snapshotToJson(snapshot)); + root.insert(QStringLiteral("lights"), standalone); + + return QJsonDocument(root).toJson(QJsonDocument::Compact); +} + +bool documentFromJson(const QByteArray& json, SceneLightsDocument& out) +{ + out = {}; + const QJsonDocument doc = QJsonDocument::fromJson(json); + if (!doc.isObject()) + return false; + + const QJsonObject root = doc.object(); + out.version = root.value(QStringLiteral("version")).toInt(kDocumentVersion); + colourFromJson(root.value(QStringLiteral("ambient")), out.ambient); + + const QJsonArray rigGroups = root.value(QStringLiteral("rigGroups")).toArray(); + for (const QJsonValue& value : rigGroups) + { + if (!value.isObject()) + continue; + const QJsonObject obj = value.toObject(); + RigGroupExport group; + group.name = obj.value(QStringLiteral("name")).toString(); + group.rigId = obj.value(QStringLiteral("rigId")).toString(); + group.preserveGrouping = obj.value(QStringLiteral("preserveGrouping")).toBool(true); + const QJsonArray lightsArr = obj.value(QStringLiteral("lights")).toArray(); + for (const QJsonValue& lightValue : lightsArr) + { + LightSnapshot snapshot; + if (!snapshotFromJson(lightValue.toObject(), snapshot)) + return false; + group.lights.append(snapshot); + } + if (!group.lights.isEmpty()) + out.rigGroups.append(group); + } + + const QJsonArray standalone = root.value(QStringLiteral("lights")).toArray(); + for (const QJsonValue& value : standalone) + { + LightSnapshot snapshot; + if (!snapshotFromJson(value.toObject(), snapshot)) + return false; + out.standaloneLights.append(snapshot); + } + + return true; +} + +bool readDocumentFromAiScene(const aiScene* scene, SceneLightsDocument& out) +{ + out = {}; + if (!scene) + return false; + + if (scene->mMetaData) + { + aiString encoded; + if (scene->mMetaData->Get(kSceneLightsMetadataKey, encoded)) + { + if (documentFromJson(QByteArray(encoded.C_Str()), out)) + return true; + } + } + + if (!scene->HasLights() || !scene->mRootNode) + return false; + + for (unsigned int i = 0; i < scene->mNumLights; ++i) + { + const aiLight* light = scene->mLights[i]; + if (!light) + continue; + const aiNode* node = findNodeByName(scene->mRootNode, light->mName.C_Str()); + if (!node) + continue; + out.standaloneLights.append(snapshotFromAssimpLight(light, node)); + } + + return !out.standaloneLights.isEmpty() || !out.rigGroups.isEmpty(); +} + +void appendLightsToAiScene(aiScene* scene, const SceneLightsDocument& doc) +{ + if (!scene || !scene->mRootNode) + return; + + if (!scene->mMetaData) + scene->mMetaData = new aiMetadata(); + scene->mMetaData->Add(kSceneLightsMetadataKey, + aiString(documentToJson(doc).constData())); + + std::vector newLights; + std::vector ownedNodes; + std::vector newRootChildren; + const unsigned int oldLightCount = scene->mNumLights; + + auto addLight = [&](const LightSnapshot& snapshot, aiNode* parent) { + aiNode* lightNode = + makeAiNodeTree(snapshot.name, localAiMatrix(snapshot), parent, ownedNodes); + appendChildNode(parent, lightNode, newRootChildren); + newLights.push_back(buildAssimpLight(snapshot)); + }; + + for (const RigGroupExport& group : doc.rigGroups) + { + aiMatrix4x4 identity; + aiNode* rigNode = + makeAiNodeTree(group.name, identity, scene->mRootNode, ownedNodes); + appendChildNode(scene->mRootNode, rigNode, newRootChildren); + for (const LightSnapshot& snapshot : group.lights) + addLight(snapshot, rigNode); + } + + for (const LightSnapshot& snapshot : doc.standaloneLights) + addLight(snapshot, scene->mRootNode); + + if (newLights.empty()) + return; + + auto** combinedLights = new aiLight*[oldLightCount + newLights.size()]; + for (unsigned int i = 0; i < oldLightCount; ++i) + combinedLights[i] = scene->mLights[i]; + for (size_t i = 0; i < newLights.size(); ++i) + combinedLights[oldLightCount + i] = newLights[i]; + delete[] scene->mLights; + scene->mLights = combinedLights; + scene->mNumLights = oldLightCount + static_cast(newLights.size()); + + const unsigned int oldChildCount = scene->mRootNode->mNumChildren; + auto** combinedChildren = new aiNode*[oldChildCount + newRootChildren.size()]; + for (unsigned int i = 0; i < oldChildCount; ++i) + combinedChildren[i] = scene->mRootNode->mChildren[i]; + for (size_t i = 0; i < newRootChildren.size(); ++i) + combinedChildren[oldChildCount + i] = newRootChildren[i]; + delete[] scene->mRootNode->mChildren; + scene->mRootNode->mChildren = combinedChildren; + scene->mRootNode->mNumChildren = + oldChildCount + static_cast(newRootChildren.size()); + + (void)ownedNodes; +} + +bool importFromAssimpScene(const aiScene* scene, bool useDefaultWhenEmpty) +{ + SceneLightsDocument doc; + if (!readDocumentFromAiScene(scene, doc)) + { + if (useDefaultWhenEmpty) + return applyToLightManager({}, true); + return false; + } + return applyToLightManager(doc, useDefaultWhenEmpty); +} + +QJsonObject lightsInfoJsonFromFile(const QString& path, QString* error) +{ + if (error) + error->clear(); + + Assimp::Importer importer; + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_PRESERVE_PIVOTS, false); + const unsigned int flags = aiProcess_Triangulate | aiProcess_ValidateDataStructure; + const aiScene* scene = importer.ReadFile(path.toUtf8().constData(), flags); + if (!scene) + { + if (error) + *error = QString::fromUtf8(importer.GetErrorString()); + return {}; + } + + SceneLightsDocument doc; + if (!readDocumentFromAiScene(scene, doc)) + { + const QFileInfo fi(path); + const QString sidecarPath = + fi.absoluteDir().filePath(fi.completeBaseName() + QStringLiteral(".lights.json")); + if (QFile::exists(sidecarPath)) + { + QFile sidecar(sidecarPath); + if (sidecar.open(QIODevice::ReadOnly)) + documentFromJson(sidecar.readAll(), doc); + } + } + + if (doc.standaloneLights.isEmpty() && doc.rigGroups.isEmpty()) + return QJsonObject{{QStringLiteral("lights"), QJsonArray{}}}; + + QJsonArray lights; + auto appendLightInfo = [&](const LightSnapshot& snapshot, const QString& parentGroup) { + QJsonObject obj = snapshotToJson(snapshot); + obj.insert(QStringLiteral("gltfIntensity"), powerScaleToGltfIntensity(snapshot)); + if (!parentGroup.isEmpty()) + obj.insert(QStringLiteral("rigGroup"), parentGroup); + lights.append(obj); + }; + + for (const RigGroupExport& group : doc.rigGroups) + { + for (const LightSnapshot& snapshot : group.lights) + appendLightInfo(snapshot, group.name); + } + for (const LightSnapshot& snapshot : doc.standaloneLights) + appendLightInfo(snapshot, {}); + + aiString hasQtMeta; + const bool hasQtMeshBlock = + scene->mMetaData && scene->mMetaData->Get(kSceneLightsMetadataKey, hasQtMeta); + + QJsonObject root; + root.insert(QStringLiteral("file"), QFileInfo(path).fileName()); + root.insert(QStringLiteral("ambient"), colourToJson(doc.ambient)); + root.insert(QStringLiteral("lights"), lights); + root.insert(QStringLiteral("lightCount"), lights.size()); + root.insert(QStringLiteral("source"), + hasQtMeshBlock ? QStringLiteral("qtmesh.scene.lights") + : QStringLiteral("assimp")); + return root; +} + +bool writeLightsSidecar(const QString& meshPath) +{ + const QFileInfo fi(meshPath); + if (!fi.exists()) + return false; + + const QString sidecarPath = + fi.absoluteDir().filePath(fi.completeBaseName() + QStringLiteral(".lights.json")); + QFile file(sidecarPath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + file.write(documentToJson(captureFromScene())); + return true; +} + +bool importLightsSidecar(const QString& meshPath, bool useDefaultWhenEmpty) +{ + const QFileInfo fi(meshPath); + const QString sidecarPath = + fi.absoluteDir().filePath(fi.completeBaseName() + QStringLiteral(".lights.json")); + if (!QFile::exists(sidecarPath)) + return false; + + QFile file(sidecarPath); + if (!file.open(QIODevice::ReadOnly)) + return false; + + SceneLightsDocument doc; + if (!documentFromJson(file.readAll(), doc)) + return false; + return applyToLightManager(doc, useDefaultWhenEmpty); +} + +} // namespace SceneLightsIO diff --git a/src/SceneLightsIO.h b/src/SceneLightsIO.h new file mode 100644 index 000000000..7a78eb348 --- /dev/null +++ b/src/SceneLightsIO.h @@ -0,0 +1,75 @@ +#pragma once + +#include "LightManager.h" + +#include + +#include +#include +#include +#include + +struct aiScene; + +namespace SceneLightsIO +{ + +/// Metadata key written to aiScene::mMetaData for bit-exact QtMeshEditor round-trip. +inline constexpr const char* kSceneLightsMetadataKey = "qtmesh.scene.lights"; +inline constexpr int kDocumentVersion = 1; + +/// User binding on rig-group scene nodes — stores the preset rig id when known. +inline constexpr const char* kRigIdUserKey = "light_rig_id"; + +struct RigGroupExport +{ + QString name; + QString rigId; + bool preserveGrouping = true; + QList lights; +}; + +struct SceneLightsDocument +{ + int version = kDocumentVersion; + Ogre::ColourValue ambient = Ogre::ColourValue(0.3f, 0.3f, 0.3f); + QList rigGroups; + QList standaloneLights; +}; + +/// Capture every user light + rig grouping from the live Ogre scene. +SceneLightsDocument captureFromScene(); + +/// Recreate lights via LightManager (emits lightCreated). When the document is +/// empty and @p useDefaultWhenEmpty is true, applies the default scene rig. +bool applyToLightManager(const SceneLightsDocument& doc, bool useDefaultWhenEmpty = true); + +QByteArray documentToJson(const SceneLightsDocument& doc); +bool documentFromJson(const QByteArray& json, SceneLightsDocument& out); + +/// Write qtmesh metadata + best-effort aiLight/aiNode entries for glTF/FBX export. +void appendLightsToAiScene(aiScene* scene, const SceneLightsDocument& doc); + +/// Prefer qtmesh metadata; fall back to Assimp punctual lights when absent. +bool readDocumentFromAiScene(const aiScene* scene, SceneLightsDocument& out); + +/// Import path used by sceneImporter and Assimp mesh import. +bool importFromAssimpScene(const aiScene* scene, bool useDefaultWhenEmpty = true); + +/// Rec.601 luminance used for glTF intensity mapping. +float ogreLuminance(const Ogre::ColourValue& colour); + +/// Map Ogre powerScale × diffuse colour to KHR_lights_punctual intensity. +/// Directional → lux approximation; point/spot → candela approximation. +/// Documented in SceneLightsIO.cpp — not bit-exact; use qtmesh metadata for that. +float powerScaleToGltfIntensity(const LightSnapshot& snapshot); + +/// Headless CLI / info: read lights from a file on disk (Assimp only). +QJsonObject lightsInfoJsonFromFile(const QString& path, QString* error = nullptr); + +/// FBX round-trip: write/read a `.lights.json` sidecar next to the mesh file. +/// Assimp FBX lights are best-effort; the sidecar preserves bit-exact QtMeshEditor state. +bool writeLightsSidecar(const QString& meshPath); +bool importLightsSidecar(const QString& meshPath, bool useDefaultWhenEmpty = true); + +} // namespace SceneLightsIO diff --git a/src/SceneLightsIO_test.cpp b/src/SceneLightsIO_test.cpp new file mode 100644 index 000000000..7630d3f02 --- /dev/null +++ b/src/SceneLightsIO_test.cpp @@ -0,0 +1,197 @@ +#include + +#include "SceneLightsIO.h" + +#include "LightManager.h" +#include "LightRigLibrary.h" +#include "Manager.h" +#include "MeshImporterExporter.h" +#include "ShadowController.h" +#include "TestHelpers.h" + +#include + +class SceneLightsIOTest : public ::testing::Test { +protected: + void TearDown() override + { + ShadowController::kill(); + LightManager::kill(); + Manager::kill(); + } +}; + +class SceneLightsIOOgreTest : public SceneLightsIOTest { +protected: + QTemporaryDir tempDir; + + void SetUp() override + { + Manager::kill(); + ASSERT_TRUE(tryInitOgre()) << "Ogre init failed (Xvfb/GL required in CI)"; + createStandardOgreMaterials(); + LightManager::getSingleton()->tryConnectToManager(); + ASSERT_TRUE(tempDir.isValid()); + } +}; + +TEST(SceneLightsIOTest, JsonRoundTripPreservesLightSnapshot) +{ + LightSnapshot original; + original.name = QStringLiteral("Rim"); + original.type = Ogre::Light::LT_SPOTLIGHT; + original.enabled = true; + original.diffuse = Ogre::ColourValue(0.9f, 0.8f, 0.7f); + original.specular = Ogre::ColourValue(0.5f, 0.5f, 0.5f); + original.powerScale = 2.5f; + original.position = Ogre::Vector3(1.0f, 2.0f, 3.0f); + original.orientation = Ogre::Quaternion(Ogre::Degree(15.0f), Ogre::Vector3::UNIT_Y); + original.scale = Ogre::Vector3(1.0f, 1.0f, 1.0f); + original.usesDirection = true; + original.direction = Ogre::Vector3(0.0f, -1.0f, -0.5f); + original.attenuationRange = 42.0f; + original.attenuationConstant = 1.0f; + original.attenuationLinear = 0.05f; + original.attenuationQuadratic = 0.01f; + original.spotlightInnerAngleDeg = 20.0f; + original.spotlightOuterAngleDeg = 35.0f; + original.spotlightFalloff = 1.25f; + original.castShadows = true; + original.shadowDepthBias = 0.0001f; + original.shadowSlopeBias = 1.5f; + + SceneLightsIO::SceneLightsDocument doc; + doc.ambient = Ogre::ColourValue(0.11f, 0.12f, 0.13f); + doc.standaloneLights.append(original); + + SceneLightsIO::SceneLightsDocument restored; + ASSERT_TRUE(SceneLightsIO::documentFromJson(SceneLightsIO::documentToJson(doc), restored)); + ASSERT_EQ(restored.standaloneLights.size(), 1); + EXPECT_EQ(restored.standaloneLights.first(), original); + EXPECT_EQ(restored.ambient, doc.ambient); +} + +TEST_F(SceneLightsIOOgreTest, SceneGltfRoundTripPreservesLights) +{ + auto* lights = LightManager::getSingleton(); + Manager::getSingleton()->CreateEmptyScene(); + + const QList before = lights->captureAllSnapshots(); + ASSERT_GE(before.size(), 3); + + Manager::getSingleton()->addSceneNode(QStringLiteral("Prop")); + const QString scenePath = tempDir.filePath(QStringLiteral("lit.scene.gltf")); + ASSERT_EQ(MeshImporterExporter::sceneExporter(scenePath, nullptr), 0); + + lights->deleteAllUserLights(); + EXPECT_TRUE(lights->lights().isEmpty()); + + ASSERT_TRUE(MeshImporterExporter::sceneImporter(scenePath)); + const QList after = lights->captureAllSnapshots(); + ASSERT_EQ(after.size(), before.size()); + for (const LightSnapshot& snapshot : before) + { + bool found = false; + for (const LightSnapshot& imported : after) + { + if (imported.name == snapshot.name) + { + EXPECT_EQ(imported, snapshot) << snapshot.name.toStdString(); + found = true; + break; + } + } + EXPECT_TRUE(found) << snapshot.name.toStdString(); + } +} + +TEST_F(SceneLightsIOOgreTest, EmptyLightsBlockRestoresDefaultRig) +{ + auto* lights = LightManager::getSingleton(); + lights->deleteAllUserLights(); + Manager::getSingleton()->addSceneNode(QStringLiteral("Solo")); + + const QString scenePath = tempDir.filePath(QStringLiteral("mesh_only.scene.gltf")); + ASSERT_EQ(MeshImporterExporter::sceneExporter(scenePath, nullptr), 0); + + lights->deleteAllUserLights(); + ASSERT_TRUE(MeshImporterExporter::sceneImporter(scenePath)); + EXPECT_GE(lights->lights().size(), 3); +} + +TEST_F(SceneLightsIOOgreTest, RigGroupRoundTripPreservesGrouping) +{ + auto* lights = LightManager::getSingleton(); + Manager::getSingleton()->CreateEmptyScene(); + + const LightRigApplyResult applied = + LightRigLibrary::apply(QStringLiteral("three_point_studio"), true); + ASSERT_TRUE(applied.ok) << applied.error.toStdString(); + ASSERT_FALSE(applied.addedLights.isEmpty()); + + const SceneLightsIO::SceneLightsDocument captured = SceneLightsIO::captureFromScene(); + ASSERT_FALSE(captured.rigGroups.isEmpty()); + + const QString scenePath = tempDir.filePath(QStringLiteral("rig.scene.gltf")); + ASSERT_EQ(MeshImporterExporter::sceneExporter(scenePath, nullptr), 0); + + lights->deleteAllUserLights(); + ASSERT_TRUE(MeshImporterExporter::sceneImporter(scenePath)); + + const SceneLightsIO::SceneLightsDocument restored = SceneLightsIO::captureFromScene(); + ASSERT_EQ(restored.rigGroups.size(), captured.rigGroups.size()); + EXPECT_FALSE(restored.rigGroups.first().lights.isEmpty()); + EXPECT_FALSE(restored.rigGroups.first().rigId.isEmpty()); +} + +TEST_F(SceneLightsIOOgreTest, FbxSidecarRoundTripPreservesLights) +{ + auto* lights = LightManager::getSingleton(); + Manager::getSingleton()->CreateEmptyScene(); + + const QString robot = testRobotMeshPath(); + if (robot.isEmpty() || !QFile::exists(robot)) + GTEST_SKIP() << "robot.mesh fixture unavailable"; + + MeshImporterExporter::importer({robot}); + const QList before = lights->captureAllSnapshots(); + ASSERT_GE(before.size(), 3); + + Ogre::Entity* entity = nullptr; + for (auto* obj : Manager::getSingleton()->getEntities()) + { + if (obj && obj->getMovableType() == QStringLiteral("Entity")) + { + entity = static_cast(obj); + break; + } + } + ASSERT_NE(entity, nullptr); + + const QString meshPath = tempDir.filePath(QStringLiteral("lit.fbx")); + ASSERT_EQ(MeshImporterExporter::exporter(entity->getParentSceneNode(), meshPath, + QStringLiteral("FBX Binary (*.fbx)")), + 0); + ASSERT_TRUE(QFile::exists(tempDir.filePath(QStringLiteral("lit.lights.json")))); + + lights->deleteAllUserLights(); + ASSERT_TRUE(lights->lights().isEmpty()); + + MeshImporterExporter::importer({meshPath}); + const QList after = lights->captureAllSnapshots(); + ASSERT_EQ(after.size(), before.size()); + for (const LightSnapshot& snapshot : before) + { + bool found = false; + for (const LightSnapshot& imported : after) + { + if (imported.name == snapshot.name) + { + EXPECT_EQ(imported, snapshot) << snapshot.name.toStdString(); + found = true; + break; + } + } + EXPECT_TRUE(found) << snapshot.name.toStdString(); + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c746429df..2484fe1dd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -53,6 +53,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/LightPropertiesController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/SceneLightingController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ShadowController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/SceneLightsIO.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/SelectionBoxObject.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ObjectItemModel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialComboDelegate.cpp @@ -306,6 +307,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/LightPropertiesController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/SceneLightingController.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/ShadowController.h + ${CMAKE_CURRENT_SOURCE_DIR}/../src/SceneLightsIO.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/SelectionBoxObject.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/ObjectItemModel.h ${CMAKE_CURRENT_SOURCE_DIR}/../src/MaterialComboDelegate.h From 009662f552c8ba1be575acbc6ec6e91de622471d Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 8 Jul 2026 21:37:04 -0400 Subject: [PATCH 2/5] fix(lights): address PR #824 CI and review feedback for scene round-trip. Chunk large qtmesh.scene.lights metadata across Assimp aiString limits, fix QList invalidation when capturing rig groups, preserve imported Assimp light intensity, keep multi-entity info JSON backward-compatible, and update empty-scene importer tests for restored default lighting. Co-authored-by: Cursor --- src/CLIPipeline.cpp | 8 +- src/MeshImporterExporter.cpp | 5 +- src/MeshImporterExporter_test.cpp | 9 +- src/SceneLightsIO.cpp | 133 +++++++++++++++++++++++++----- src/SceneLightsIO.h | 1 + src/SceneLightsIO_test.cpp | 34 ++++++++ 6 files changed, 161 insertions(+), 29 deletions(-) diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index ae03571fc..750d3baf4 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -1697,14 +1697,16 @@ int CLIPipeline::cmdInfo(int argc, char* argv[]) } cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented))); } else { - QJsonObject root; - root.insert(QStringLiteral("meshes"), arr); if (hasLightsInFile) { + QJsonObject root; + root.insert(QStringLiteral("meshes"), arr); root.insert(QStringLiteral("lights"), lightsPayload.value(QStringLiteral("lights"))); root.insert(QStringLiteral("ambient"), lightsPayload.value(QStringLiteral("ambient"))); root.insert(QStringLiteral("lightCount"), lightsInFile); + cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented))); + } else { + cliWrite(QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Indented))); } - cliWrite(QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented))); } } else { for (Ogre::Entity* entity : entities) { diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 5d2b9bd9b..f2853f9dd 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -2677,10 +2677,7 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad bool convertLH = (file.suffix().compare("x", Qt::CaseInsensitive) != 0); const std::string sourcePath = file.filePath().toStdString(); Ogre::MeshPtr mesh = importer.loadModel(sourcePath, convertLH, additionalFlags); - if (!SceneLightsIO::importLightsSidecar(file.filePath(), false)) { - if (const aiScene* importScene = importer.getImportedScene()) - SceneLightsIO::importFromAssimpScene(importScene, false); - } + SceneLightsIO::importLightsFromFile(file.filePath(), false); // Read coordinate system from metadata immediately — valid for both mesh and animation-only files. if (outUpAxis) *outUpAxis = importer.getSceneUpAxis(); if (mesh) { diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 7909694b0..3124876eb 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -21,6 +21,7 @@ #include #include #include +#include "LightManager.h" #include "Manager.h" #include "MeshImporterExporter.h" #include "EditableMesh.h" @@ -483,7 +484,9 @@ TEST_F(MeshImporterExporterTest, SceneImporter_ExportedEmptySceneClearsExistingN EXPECT_TRUE(MeshImporterExporter::sceneImporter(scenePath)); EXPECT_FALSE(Manager::getSingleton()->getSceneMgr()->hasSceneNode("ExistingNode")); - EXPECT_TRUE(Manager::getSingleton()->getSceneNodes().isEmpty()); + EXPECT_TRUE(Manager::getSingleton()->getEntities().isEmpty()); + if (auto* lights = LightManager::getSingletonPtr()) + EXPECT_GE(lights->lights().size(), 1u); } TEST_F(MeshImporterExporterTest, SceneImporter_NodeOnlyExportBehavesAsValidEmptyScene) @@ -500,7 +503,9 @@ TEST_F(MeshImporterExporterTest, SceneImporter_NodeOnlyExportBehavesAsValidEmpty EXPECT_TRUE(MeshImporterExporter::sceneImporter(scenePath)); EXPECT_FALSE(Manager::getSingleton()->getSceneMgr()->hasSceneNode("ExistingNode")); - EXPECT_TRUE(Manager::getSingleton()->getSceneNodes().isEmpty()); + EXPECT_TRUE(Manager::getSingleton()->getEntities().isEmpty()); + if (auto* lights = LightManager::getSingletonPtr()) + EXPECT_GE(lights->lights().size(), 1u); } TEST_F(MeshImporterExporterTest, SceneExporter_InMemoryMeshEntity_WritesSceneFile) diff --git a/src/SceneLightsIO.cpp b/src/SceneLightsIO.cpp index 72d428328..450682612 100644 --- a/src/SceneLightsIO.cpp +++ b/src/SceneLightsIO.cpp @@ -4,6 +4,7 @@ #include "LightRigLibrary.h" #include "Manager.h" #include "ShadowController.h" +#include "SentryReporter.h" #include #include @@ -18,6 +19,7 @@ #include #include #include +#include #include #include @@ -28,6 +30,71 @@ namespace { +// Assimp aiString stores AI_MAXLEN bytes including the terminator. +constexpr int kAiStringMaxPayload = 1023; +constexpr QLatin1String kSceneLightsChunkCountKey("qtmesh.scene.lights.chunks"); + +bool readLightsJsonFromMetadata(const aiMetadata* meta, QByteArray& jsonOut) +{ + jsonOut.clear(); + if (!meta) + return false; + + aiString encoded; + if (meta->Get(SceneLightsIO::kSceneLightsMetadataKey, encoded)) + { + jsonOut = QByteArray(encoded.C_Str()); + if (!jsonOut.isEmpty()) + return true; + } + + int chunkCount = 0; + if (!meta->Get(kSceneLightsChunkCountKey.data(), chunkCount) || chunkCount <= 0) + return false; + + for (int i = 0; i < chunkCount; ++i) + { + const QString key = QStringLiteral("qtmesh.scene.lights.%1").arg(i); + aiString chunk; + if (!meta->Get(key.toUtf8().constData(), chunk)) + return false; + jsonOut.append(chunk.C_Str()); + } + return !jsonOut.isEmpty(); +} + +void writeLightsJsonToMetadata(aiMetadata* meta, const QByteArray& json) +{ + if (!meta || json.isEmpty()) + return; + + if (json.size() <= kAiStringMaxPayload) + { + meta->Add(SceneLightsIO::kSceneLightsMetadataKey, aiString(json.constData())); + return; + } + + const int chunkCount = + (static_cast(json.size()) + kAiStringMaxPayload - 1) / kAiStringMaxPayload; + meta->Add(kSceneLightsChunkCountKey.data(), chunkCount); + for (int i = 0; i < chunkCount; ++i) + { + const QByteArray slice = json.mid(i * kAiStringMaxPayload, kAiStringMaxPayload); + const QString key = QStringLiteral("qtmesh.scene.lights.%1").arg(i); + meta->Add(key.toUtf8().constData(), aiString(slice.constData())); + } +} + +QString lightsSidecarPath(const QFileInfo& fi) +{ + return fi.absoluteDir().filePath(fi.completeBaseName() + QStringLiteral(".lights.json")); +} + +} // namespace + +namespace +{ + QString lightTypeToString(Ogre::Light::LightTypes type) { switch (type) @@ -312,7 +379,7 @@ LightSnapshot snapshotFromAssimpLight(const aiLight* light, const aiNode* node) Ogre::ColourValue(light->mColorSpecular.r, light->mColorSpecular.g, light->mColorSpecular.b); const float importedIntensity = std::max(SceneLightsIO::ogreLuminance(snapshot.diffuse), 1e-6f); - snapshot.powerScale = 1.0f; + snapshot.powerScale = importedIntensity; snapshot.diffuse.r /= importedIntensity; snapshot.diffuse.g /= importedIntensity; snapshot.diffuse.b /= importedIntensity; @@ -446,7 +513,7 @@ SceneLightsDocument captureFromScene() doc.ambient = mgr->getSceneMgr()->getAmbientLight(); Ogre::SceneNode* root = mgr->getSceneMgr()->getRootSceneNode(); - std::map groupByNode; + std::map groupIndexByNode; for (const auto& child : root->getChildren()) { @@ -458,8 +525,8 @@ SceneLightsDocument captureFromScene() group.name = QString::fromStdString(node->getName()); group.rigId = rigIdFromSceneNode(node); group.preserveGrouping = true; + groupIndexByNode[node] = doc.rigGroups.size(); doc.rigGroups.append(group); - groupByNode[node] = &doc.rigGroups.last(); } for (const LightHandle& handle : lights->lights()) @@ -468,8 +535,9 @@ SceneLightsDocument captureFromScene() continue; const LightSnapshot snapshot = LightSnapshot::fromHandle(handle); auto* parent = static_cast(handle.sceneNode->getParent()); - if (parent && groupByNode.count(parent)) - groupByNode[parent]->lights.append(snapshot); + const auto groupIt = groupIndexByNode.find(parent); + if (groupIt != groupIndexByNode.end()) + doc.rigGroups[groupIt->second].lights.append(snapshot); else doc.standaloneLights.append(snapshot); } @@ -610,11 +678,11 @@ bool readDocumentFromAiScene(const aiScene* scene, SceneLightsDocument& out) if (scene->mMetaData) { - aiString encoded; - if (scene->mMetaData->Get(kSceneLightsMetadataKey, encoded)) + QByteArray encoded; + if (readLightsJsonFromMetadata(scene->mMetaData, encoded) + && documentFromJson(encoded, out)) { - if (documentFromJson(QByteArray(encoded.C_Str()), out)) - return true; + return true; } } @@ -642,8 +710,7 @@ void appendLightsToAiScene(aiScene* scene, const SceneLightsDocument& doc) if (!scene->mMetaData) scene->mMetaData = new aiMetadata(); - scene->mMetaData->Add(kSceneLightsMetadataKey, - aiString(documentToJson(doc).constData())); + writeLightsJsonToMetadata(scene->mMetaData, documentToJson(doc)); std::vector newLights; std::vector ownedNodes; @@ -728,8 +795,7 @@ QJsonObject lightsInfoJsonFromFile(const QString& path, QString* error) if (!readDocumentFromAiScene(scene, doc)) { const QFileInfo fi(path); - const QString sidecarPath = - fi.absoluteDir().filePath(fi.completeBaseName() + QStringLiteral(".lights.json")); + const QString sidecarPath = lightsSidecarPath(fi); if (QFile::exists(sidecarPath)) { QFile sidecar(sidecarPath); @@ -758,9 +824,15 @@ QJsonObject lightsInfoJsonFromFile(const QString& path, QString* error) for (const LightSnapshot& snapshot : doc.standaloneLights) appendLightInfo(snapshot, {}); - aiString hasQtMeta; - const bool hasQtMeshBlock = - scene->mMetaData && scene->mMetaData->Get(kSceneLightsMetadataKey, hasQtMeta); + const bool hasQtMeshBlock = [&]() { + if (!scene->mMetaData) + return false; + aiString single; + if (scene->mMetaData->Get(kSceneLightsMetadataKey, single)) + return true; + int chunkCount = 0; + return scene->mMetaData->Get(kSceneLightsChunkCountKey.data(), chunkCount) && chunkCount > 0; + }(); QJsonObject root; root.insert(QStringLiteral("file"), QFileInfo(path).fileName()); @@ -779,20 +851,20 @@ bool writeLightsSidecar(const QString& meshPath) if (!fi.exists()) return false; - const QString sidecarPath = - fi.absoluteDir().filePath(fi.completeBaseName() + QStringLiteral(".lights.json")); + const QString sidecarPath = lightsSidecarPath(fi); QFile file(sidecarPath); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) return false; file.write(documentToJson(captureFromScene())); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("Wrote lights sidecar %1").arg(sidecarPath)); return true; } bool importLightsSidecar(const QString& meshPath, bool useDefaultWhenEmpty) { const QFileInfo fi(meshPath); - const QString sidecarPath = - fi.absoluteDir().filePath(fi.completeBaseName() + QStringLiteral(".lights.json")); + const QString sidecarPath = lightsSidecarPath(fi); if (!QFile::exists(sidecarPath)) return false; @@ -803,6 +875,27 @@ bool importLightsSidecar(const QString& meshPath, bool useDefaultWhenEmpty) SceneLightsDocument doc; if (!documentFromJson(file.readAll(), doc)) return false; + SentryReporter::addBreadcrumb(QStringLiteral("file.import"), + QStringLiteral("Loaded lights sidecar %1").arg(sidecarPath)); + return applyToLightManager(doc, useDefaultWhenEmpty); +} + +bool importLightsFromFile(const QString& path, bool useDefaultWhenEmpty) +{ + if (importLightsSidecar(path, useDefaultWhenEmpty)) + return true; + + Assimp::Importer importer; + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_PRESERVE_PIVOTS, false); + const unsigned int flags = aiProcess_Triangulate | aiProcess_ValidateDataStructure; + const aiScene* scene = importer.ReadFile(path.toUtf8().constData(), flags); + if (!scene) + return false; + + SceneLightsDocument doc; + if (!readDocumentFromAiScene(scene, doc)) + return false; + return applyToLightManager(doc, useDefaultWhenEmpty); } diff --git a/src/SceneLightsIO.h b/src/SceneLightsIO.h index 7a78eb348..0ce22edf2 100644 --- a/src/SceneLightsIO.h +++ b/src/SceneLightsIO.h @@ -71,5 +71,6 @@ QJsonObject lightsInfoJsonFromFile(const QString& path, QString* error = nullptr /// Assimp FBX lights are best-effort; the sidecar preserves bit-exact QtMeshEditor state. bool writeLightsSidecar(const QString& meshPath); bool importLightsSidecar(const QString& meshPath, bool useDefaultWhenEmpty = true); +bool importLightsFromFile(const QString& path, bool useDefaultWhenEmpty = true); } // namespace SceneLightsIO diff --git a/src/SceneLightsIO_test.cpp b/src/SceneLightsIO_test.cpp index 7630d3f02..606685d0e 100644 --- a/src/SceneLightsIO_test.cpp +++ b/src/SceneLightsIO_test.cpp @@ -11,6 +11,8 @@ #include +#include + class SceneLightsIOTest : public ::testing::Test { protected: void TearDown() override @@ -35,6 +37,38 @@ class SceneLightsIOOgreTest : public SceneLightsIOTest { } }; +TEST(SceneLightsIOTest, ChunkedMetadataRoundTripPreservesLargeDocument) +{ + SceneLightsIO::SceneLightsDocument doc; + doc.ambient = Ogre::ColourValue(0.2f, 0.25f, 0.3f); + for (int i = 0; i < 24; ++i) + { + LightSnapshot light; + light.name = QStringLiteral("Fill_%1").arg(i); + light.type = Ogre::Light::LT_POINT; + light.enabled = true; + light.diffuse = Ogre::ColourValue(0.8f, 0.7f, 0.6f); + light.specular = Ogre::ColourValue(0.4f, 0.4f, 0.4f); + light.powerScale = 1.5f + static_cast(i) * 0.1f; + light.position = Ogre::Vector3(static_cast(i), 1.f, 2.f); + light.castShadows = (i % 3) == 0; + doc.standaloneLights.append(light); + } + + const QByteArray json = SceneLightsIO::documentToJson(doc); + ASSERT_GT(json.size(), 1023); + + aiScene scene; + scene.mRootNode = new aiNode("root"); + SceneLightsIO::appendLightsToAiScene(&scene, doc); + + SceneLightsIO::SceneLightsDocument restored; + ASSERT_TRUE(SceneLightsIO::readDocumentFromAiScene(&scene, restored)); + EXPECT_EQ(restored.standaloneLights.size(), doc.standaloneLights.size()); + EXPECT_EQ(restored.standaloneLights.first().name, doc.standaloneLights.first().name); + EXPECT_EQ(restored.standaloneLights.last().powerScale, doc.standaloneLights.last().powerScale); +} + TEST(SceneLightsIOTest, JsonRoundTripPreservesLightSnapshot) { LightSnapshot original; From e0dcb9e948934d5c02739eb855c5a47713c76851 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 8 Jul 2026 22:14:16 -0400 Subject: [PATCH 3/5] fix(tests): count mesh entity nodes after scene light round-trip. Scene import now restores light rig nodes alongside mesh entities; SceneSaveLoadTest assertions that compared raw scene-node counts were failing in CI. Co-authored-by: Cursor --- src/MeshImporterExporter_test.cpp | 62 ++++++++++++++++++------------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/src/MeshImporterExporter_test.cpp b/src/MeshImporterExporter_test.cpp index 3124876eb..48cd97819 100644 --- a/src/MeshImporterExporter_test.cpp +++ b/src/MeshImporterExporter_test.cpp @@ -680,6 +680,18 @@ class SceneSaveLoadTest : public ::testing::Test { }; namespace { +// Scene import restores light rig nodes alongside mesh entities. +QList meshEntitySceneNodes(Manager* manager) +{ + QList out; + auto* sceneMgr = manager->getSceneMgr(); + for (auto* sn : manager->getSceneNodes()) { + if (sceneMgr->hasEntity(sn->getName())) + out.append(sn); + } + return out; +} + QString writeQuadObjForScene(const QTemporaryDir& dir, const QString& fileName) { if (!dir.isValid()) @@ -792,11 +804,11 @@ TEST_F(SceneSaveLoadTest, RoundTrip_TwoEntities_PreservesTransforms) { ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); - auto& nodes = manager->getSceneNodes(); - ASSERT_EQ(nodes.size(), 2); + const auto entityNodes = meshEntitySceneNodes(manager); + ASSERT_EQ(entityNodes.size(), 2); bool foundNode1 = false, foundNode2 = false; - for (auto* sn : nodes) + for (auto* sn : entityNodes) { auto pos = sn->getPosition(); if (std::abs(pos.x - 1.0f) < 0.1f && std::abs(pos.y - 2.0f) < 0.1f) @@ -848,10 +860,10 @@ TEST_F(SceneSaveLoadTest, RoundTrip_QuadMesh_PreservesNgonFaceBinding_Gltf) ASSERT_EQ(MeshImporterExporter::sceneExporter(sceneFile), 0); ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); - ASSERT_EQ(manager->getSceneNodes().size(), 1); + const auto entityNodes = meshEntitySceneNodes(manager); + ASSERT_EQ(entityNodes.size(), 1); - node = manager->getSceneNodes().front(); - ASSERT_TRUE(manager->getSceneMgr()->hasEntity(node->getName())); + node = entityNodes.first(); entity = manager->getSceneMgr()->getEntity(node->getName()); expectEntityHasSingleQuadBinding(entity, facesBefore[0]); } @@ -879,10 +891,10 @@ TEST_F(SceneSaveLoadTest, RoundTrip_QuadMesh_PreservesNgonFaceBinding_Glb) ASSERT_EQ(MeshImporterExporter::sceneExporter(sceneFile), 0); ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); - ASSERT_EQ(manager->getSceneNodes().size(), 1); + const auto entityNodesGlb = meshEntitySceneNodes(manager); + ASSERT_EQ(entityNodesGlb.size(), 1); - node = manager->getSceneNodes().front(); - ASSERT_TRUE(manager->getSceneMgr()->hasEntity(node->getName())); + node = entityNodesGlb.first(); entity = manager->getSceneMgr()->getEntity(node->getName()); expectEntityHasSingleQuadBinding(entity, facesBefore[0]); } @@ -906,10 +918,10 @@ TEST_F(SceneSaveLoadTest, RoundTrip_QuadMeshWithUnusedSharedVertex_RemapPreserve ASSERT_EQ(MeshImporterExporter::sceneExporter(sceneFile), 0); ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); - ASSERT_EQ(manager->getSceneNodes().size(), 1); + const auto entityNodesCompact = meshEntitySceneNodes(manager); + ASSERT_EQ(entityNodesCompact.size(), 1); - node = manager->getSceneNodes().front(); - ASSERT_TRUE(manager->getSceneMgr()->hasEntity(node->getName())); + node = entityNodesCompact.first(); entity = manager->getSceneMgr()->getEntity(node->getName()); expectEntityHasSingleQuadBinding(entity, {0, 1, 2, 3}); } @@ -1068,7 +1080,7 @@ TEST_F(SceneSaveLoadTest, MaterialDedup_SharedMaterial_ExportedOnce) { // Reimport to verify both entities load correctly ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); - EXPECT_EQ(manager->getSceneNodes().size(), 2); + EXPECT_EQ(meshEntitySceneNodes(manager).size(), 2); } TEST_F(SceneSaveLoadTest, EmptyScene_ExportsValidFile) { @@ -1204,19 +1216,19 @@ TEST_F(SceneSaveLoadTest, SceneImporter_DuplicateNodeNames_AreMadeUnique) gltfFile.close(); ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); - const auto& importedNodes = manager->getSceneNodes(); - ASSERT_EQ(importedNodes.size(), 2); + const auto importedEntityNodes = meshEntitySceneNodes(manager); + ASSERT_EQ(importedEntityNodes.size(), 2); std::set uniqueNames; bool hasSuffixedVariant = false; - for (auto* sn : importedNodes) { + for (auto* sn : importedEntityNodes) { const std::string name = sn->getName(); if (name.rfind("DuplicatedNode_", 0) == 0) hasSuffixedVariant = true; uniqueNames.insert(name); } - EXPECT_EQ(uniqueNames.size(), importedNodes.size()); + EXPECT_EQ(uniqueNames.size(), importedEntityNodes.size()); EXPECT_EQ(uniqueNames.count("DuplicatedNode"), 1u); EXPECT_TRUE(hasSuffixedVariant); } @@ -1241,13 +1253,11 @@ TEST_F(SceneSaveLoadTest, RoundTrip_SkeletonEntity_PreservesAnimations) { ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); - auto& nodes = manager->getSceneNodes(); - ASSERT_EQ(nodes.size(), 1); + const auto entityNodes = meshEntitySceneNodes(manager); + ASSERT_EQ(entityNodes.size(), 1); - auto* reimportedNode = nodes.first(); + auto* reimportedNode = entityNodes.first(); auto* sceneMgr = manager->getSceneMgr(); - ASSERT_TRUE(sceneMgr->hasEntity(reimportedNode->getName())); - auto* reimportedEntity = sceneMgr->getEntity(reimportedNode->getName()); ASSERT_TRUE(reimportedEntity->hasSkeleton()); auto* skel = reimportedEntity->getMesh()->getSkeleton().get(); @@ -1437,11 +1447,11 @@ TEST_F(SceneSaveLoadTest, RoundTrip_MixedSkeletalAndNonSkeletal) { ASSERT_EQ(exportResult, 0); ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); - EXPECT_EQ(manager->getSceneNodes().size(), 2); + EXPECT_EQ(meshEntitySceneNodes(manager).size(), 2); // Verify both entities reimported bool foundSkeletal = false, foundPlain = false; - for (auto* sn : manager->getSceneNodes()) + for (auto* sn : meshEntitySceneNodes(manager)) { if (!manager->getSceneMgr()->hasEntity(sn->getName())) continue; @@ -1483,11 +1493,11 @@ TEST_F(SceneSaveLoadTest, RoundTrip_TwoSkeletalEntities_BonePrefixing) { ASSERT_EQ(exportResult, 0); ASSERT_TRUE(MeshImporterExporter::sceneImporter(sceneFile)); - EXPECT_EQ(manager->getSceneNodes().size(), 2); + EXPECT_EQ(meshEntitySceneNodes(manager).size(), 2); // Verify both reimported entities have skeletons int skelCount = 0; - for (auto* sn : manager->getSceneNodes()) + for (auto* sn : meshEntitySceneNodes(manager)) { if (!manager->getSceneMgr()->hasEntity(sn->getName())) continue; From db5b3576b734c2fcf4f7312b1b9a7a0f0217b70b Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 8 Jul 2026 23:41:20 -0400 Subject: [PATCH 4/5] fix(lights): persist user lights on scene save and clear scene safely. Clear-scene was destroying rig-group light children without unregistering them from LightManager (SIGSEGV). Scene .glb export now writes a lights sidecar like FBX, and scene import prefers that sidecar so user-added lights round-trip reliably. Co-authored-by: Cursor --- src/Manager.cpp | 7 +++++++ src/Manager_test.cpp | 25 +++++++++++++++++++++++++ src/MeshImporterExporter.cpp | 6 +++++- src/PropertiesPanelController.cpp | 1 + src/SceneLightsIO_test.cpp | 26 ++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/Manager.cpp b/src/Manager.cpp index eed5fcdfa..80eddb33f 100755 --- a/src/Manager.cpp +++ b/src/Manager.cpp @@ -618,6 +618,13 @@ void Manager::destroyAllUserRootNodes() SentryReporter::addBreadcrumb("scene", "Destroy all user root scene nodes"); + // Rig-group lights are child scene nodes. destroySceneNode(name) uses + // removeAndDestroyAllChildren() by default, which tears down Ogre light nodes + // without unregistering them from LightManager — dangling handles → SIGSEGV. + emit sceneClearing(); + if (auto* lights = LightManager::getSingletonPtr()) + lights->deleteAllUserLights(); + Ogre::SceneNode* root = mSceneMgr->getRootSceneNode(); QStringList names; for (const auto& child : root->getChildren()) diff --git a/src/Manager_test.cpp b/src/Manager_test.cpp index 44debef37..ba8a5686b 100644 --- a/src/Manager_test.cpp +++ b/src/Manager_test.cpp @@ -3,6 +3,7 @@ #include "Manager.h" #include "GlobalDefinitions.h" #include "PrimitiveObject.h" +#include "LightManager.h" #include #include "SelectionSet.h" #include @@ -642,6 +643,30 @@ TEST_F(ManagerHeadlessTest, DestroyAllUserNodes_ClearsScene) EXPECT_FALSE(mgr->hasSceneNode("ClearNodeEmpty")); } +TEST_F(ManagerHeadlessTest, DestroyAllUserRootNodes_WithLighting_DoesNotCrash) +{ + ASSERT_TRUE(canLoadMeshFiles()) << "entity creation requires GL (Xvfb in CI)"; + auto* mgr = Manager::getSingletonPtr(); + auto* lights = LightManager::getSingleton(); + lights->tryConnectToManager(); + + mgr->CreateEmptyScene(); + const int lightsBefore = lights->lights().size(); + ASSERT_GE(lightsBefore, 3); + + lights->createLight(Ogre::Light::LT_POINT, QStringLiteral("UserPointLight")); + ASSERT_EQ(lights->lights().size(), lightsBefore + 1); + + mgr->addSceneNode(QStringLiteral("MeshProp")); + EXPECT_FALSE(mgr->getSceneNodes().isEmpty()); + + mgr->destroyAllUserRootNodes(); + + EXPECT_TRUE(lights->lights().isEmpty()); + EXPECT_EQ(mgr->getEntities().count(), 0); + EXPECT_FALSE(mgr->hasSceneNode(QStringLiteral("MeshProp"))); +} + // Test getEntities with ManualObjects mixed in -- verifies the type-filtering pitfall // Manager::getEntities() does static_cast without checking movableType, // so attaching a ManualObject to a user node would cause issues. This test diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index f2853f9dd..37a14ecb6 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -4005,6 +4005,9 @@ int MeshImporterExporter::sceneExporter(const QString &_uri, const ProgressCallb } delete scene; + // Assimp's glb2 writer may drop custom aiMetadata; persist a sidecar + // (same strategy as FBX export) so user-added lights always round-trip. + SceneLightsIO::writeLightsSidecar(_uri); reportProgress(100, QStringLiteral("Done.")); } catch (const std::exception& ex) { auto msg = QString("Scene export failed: %1").arg(ex.what()); @@ -4416,7 +4419,8 @@ bool MeshImporterExporter::sceneImporter(const QString &_uri) manager->createEntity(sn, ogreMesh); } - SceneLightsIO::importFromAssimpScene(scene, true); + if (!SceneLightsIO::importLightsSidecar(_uri, true)) + SceneLightsIO::importFromAssimpScene(scene, true); return true; } catch (Ogre::Exception& e) { diff --git a/src/PropertiesPanelController.cpp b/src/PropertiesPanelController.cpp index 62d8bce1b..108d26840 100644 --- a/src/PropertiesPanelController.cpp +++ b/src/PropertiesPanelController.cpp @@ -388,6 +388,7 @@ void PropertiesPanelController::clearSceneTreeAllNodes() Manager::getSingleton()->destroyAllUserRootNodes(); SelectionSet::getSingleton()->clearList(); UndoManager::getSingleton()->clear(); + emit selectionChanged(); } bool PropertiesPanelController::hasEntitySelection() const diff --git a/src/SceneLightsIO_test.cpp b/src/SceneLightsIO_test.cpp index 606685d0e..dc6bf7518 100644 --- a/src/SceneLightsIO_test.cpp +++ b/src/SceneLightsIO_test.cpp @@ -10,6 +10,7 @@ #include "TestHelpers.h" #include +#include #include @@ -139,6 +140,31 @@ TEST_F(SceneLightsIOOgreTest, SceneGltfRoundTripPreservesLights) } } +TEST_F(SceneLightsIOOgreTest, UserAddedLightGlbRoundTripUsesSidecar) +{ + auto* lights = LightManager::getSingleton(); + Manager::getSingleton()->CreateEmptyScene(); + + const int rigLightCount = lights->lights().size(); + ASSERT_GE(rigLightCount, 3); + + const LightHandle added = + lights->createLight(Ogre::Light::LT_POINT, QStringLiteral("UserPointLight")); + ASSERT_TRUE(added.isValid()); + + Manager::getSingleton()->addSceneNode(QStringLiteral("Prop")); + const QString scenePath = tempDir.filePath(QStringLiteral("user_light.scene.glb")); + ASSERT_EQ(MeshImporterExporter::sceneExporter(scenePath, nullptr), 0); + ASSERT_TRUE(QFile::exists(tempDir.filePath(QStringLiteral("user_light.scene.lights.json")))); + + lights->deleteAllUserLights(); + EXPECT_TRUE(lights->lights().isEmpty()); + + ASSERT_TRUE(MeshImporterExporter::sceneImporter(scenePath)); + EXPECT_EQ(lights->lights().size(), rigLightCount + 1); + EXPECT_NE(lights->findLight(QStringLiteral("UserPointLight")), nullptr); +} + TEST_F(SceneLightsIOOgreTest, EmptyLightsBlockRestoresDefaultRig) { auto* lights = LightManager::getSingleton(); From 567048a8fea949f4bd92414bf4d6b8bb15474467 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 8 Jul 2026 23:51:12 -0400 Subject: [PATCH 5/5] fix(lights): address PR review feedback for import and sidecar I/O. Defer lights metadata parsing in `info` to JSON output only, restore mesh lights after successful geometry load, check scene sidecar write failures, and add the missing include. Co-authored-by: Cursor --- src/CLIPipeline.cpp | 15 ++++++++++----- src/MeshImporterExporter.cpp | 17 ++++++++++++++--- src/SceneLightsIO.cpp | 1 + 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index 750d3baf4..73a046359 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -1618,11 +1618,16 @@ int CLIPipeline::cmdInfo(int argc, char* argv[]) SentryReporter::addBreadcrumb("cli.info", QString("Inspect .%1%2").arg(fi.suffix(), jsonOutput ? " json=true" : "")); - QString lightError; - const QJsonObject lightsPayload = - SceneLightsIO::lightsInfoJsonFromFile(fi.absoluteFilePath(), &lightError); - const int lightsInFile = lightsPayload.value(QStringLiteral("lightCount")).toInt(); - const bool hasLightsInFile = lightsInFile > 0; + QJsonObject lightsPayload; + int lightsInFile = 0; + bool hasLightsInFile = false; + if (jsonOutput) { + QString lightError; + lightsPayload = + SceneLightsIO::lightsInfoJsonFromFile(fi.absoluteFilePath(), &lightError); + lightsInFile = lightsPayload.value(QStringLiteral("lightCount")).toInt(); + hasLightsInFile = lightsInFile > 0; + } // Load the file; animation-only files produce no entity but populate animOnlySkeletons. QList animOnlySkeletons; diff --git a/src/MeshImporterExporter.cpp b/src/MeshImporterExporter.cpp index 37a14ecb6..386641df0 100755 --- a/src/MeshImporterExporter.cpp +++ b/src/MeshImporterExporter.cpp @@ -2677,10 +2677,10 @@ void MeshImporterExporter::importer(const QStringList &_uriList, unsigned int ad bool convertLH = (file.suffix().compare("x", Qt::CaseInsensitive) != 0); const std::string sourcePath = file.filePath().toStdString(); Ogre::MeshPtr mesh = importer.loadModel(sourcePath, convertLH, additionalFlags); - SceneLightsIO::importLightsFromFile(file.filePath(), false); // Read coordinate system from metadata immediately — valid for both mesh and animation-only files. if (outUpAxis) *outUpAxis = importer.getSceneUpAxis(); if (mesh) { + SceneLightsIO::importLightsFromFile(file.filePath(), false); // Cache the source file path so EditModeController can // re-import the asset through the n-gon-aware // EditableMesh::loadFromAssimpFile path. Quad-bearing @@ -2962,7 +2962,13 @@ int MeshImporterExporter::exporter(const Ogre::SceneNode *_sn, const QString &_u // .material and extracted image files next to the FBX. if (!ok) return -1; - SceneLightsIO::writeLightsSidecar(_uri); + SentryReporter::addBreadcrumb(QStringLiteral("file.export"), + QStringLiteral("Exported FBX: %1").arg(_uri)); + if (!SceneLightsIO::writeLightsSidecar(_uri)) + { + Ogre::LogManager::getSingleton().logWarning( + "FBX exported but lights sidecar write failed: " + _uri.toStdString()); + } } else if (_format == QStringLiteral("PlayStation TMD (*.tmd)")) { if (!PS1TMD::exportEntity(e, _uri)) return -1; @@ -4007,7 +4013,12 @@ int MeshImporterExporter::sceneExporter(const QString &_uri, const ProgressCallb delete scene; // Assimp's glb2 writer may drop custom aiMetadata; persist a sidecar // (same strategy as FBX export) so user-added lights always round-trip. - SceneLightsIO::writeLightsSidecar(_uri); + if (!SceneLightsIO::writeLightsSidecar(_uri)) + { + Ogre::LogManager::getSingleton().logError( + "Scene exported but lights sidecar write failed: " + _uri.toStdString()); + return -1; + } reportProgress(100, QStringLiteral("Done.")); } catch (const std::exception& ex) { auto msg = QString("Scene export failed: %1").arg(ex.what()); diff --git a/src/SceneLightsIO.cpp b/src/SceneLightsIO.cpp index 450682612..69e832d08 100644 --- a/src/SceneLightsIO.cpp +++ b/src/SceneLightsIO.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include