diff --git a/CLAUDE.md b/CLAUDE.md index e826b4bb..0008532f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -203,6 +203,10 @@ Three singletons manage core state. All run on the main thread. Access via `Clas - **Intensity units**: GUI/CLI/MCP use Ogre `powerScale` (Inspector “Intensity”). glTF export writes both `qtmesh.scene.lights` (exact) and best-effort KHR punctual intensity derived from `powerScale × diffuse luminance`. FBX lights are Assimp best-effort; use the `.lights.json` sidecar for bit-exact round-trip. - **Sentry breadcrumbs**: `scene.light.create|delete|duplicate|rename|edit|shadow_toggle|apply_rig|gizmo_toggle` on core ops; `ui.action` on menu/toolbar clicks. - **Light linking (Slice I #491)**: per-light include/exclude lists map to Ogre `Light::setLightMask` / `Entity::setLightMask` (32 channel bits; bit 0 reserved; max 31 simultaneous link rules). Persisted in `qtmesh.scene.lights` JSON as `linkMode`, `linkedEntities`, `linkChannelBit`. Exclude sets excluded entities to the channel bit only (no overlap with the light's inverted mask). Combining include + exclude on the same entity is best-effort. RTSS PBR may not honour masks on every pass. +- **Light collections (#491)**: user-created groups via `LightGroupLibrary` / `LightGroupController` — select 2+ lights, parent under a tagged `light_group` scene node; transform/enable/disable as one unit. Persisted in scene JSON as `rigGroups[]` with `groupKind: "collection"`. +- **Viewport solo (#491)**: `ViewportLightSoloController` — per-viewport runtime toggle to show only one light during that viewport's render pass (`OgreWidget::frameEnded` begin/end hooks). Inspector picker under Scene → Lighting. +- **IES profiles (#491)**: `IesProfile` LM-63 parser + `IesLightApply` maps beam/field angles to spotlight cones (approximation; not full shader 1D texture yet). Inspector Browse/Clear; polar plot overlay in `LightVisualizer`. Persisted as `iesProfilePath` in scene JSON. +- **Area lights (#491, `ENABLE_AREA_LIGHTS`)**: multi-point proxy approximation on rectangle/disk/line shapes; parent light hidden while active. Inspector shape/size/samples; wire gizmo in `LightVisualizer`. Persisted as `areaShape`, `areaWidth`, `areaHeight`, `areaSampleCount`. ### Debug Overlays diff --git a/CMakeLists.txt b/CMakeLists.txt index e26a6d6c..35206780 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -324,6 +324,15 @@ if(ENABLE_PS1_RIP) add_subdirectory(plugins/ps1core_stub) endif() ############################################################## +# Area lights (Slice I #491 — multi-point approximation) +############################################################## +option(ENABLE_AREA_LIGHTS "Enable area-light gizmo + multi-point approximation" ON) + +if(ENABLE_AREA_LIGHTS) + add_definitions(-DENABLE_AREA_LIGHTS) + message(STATUS "Area lights enabled (multi-point approximation)") +endif() +############################################################## # In-app auto-updater (portable Windows/macOS/Linux tarballs) ############################################################## option(ENABLE_AUTO_UPDATER "Enable in-app auto-updater UI and download path" ON) diff --git a/qml/PropertiesPanel.qml b/qml/PropertiesPanel.qml index eb2832f4..ab4e7dd5 100644 --- a/qml/PropertiesPanel.qml +++ b/qml/PropertiesPanel.qml @@ -4829,6 +4829,176 @@ Rectangle { } } } + + // IES profile (Slice I #491) + Text { + visible: LightPropertiesController.isPointOrSpot + text: qsTr("IES profile") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + topPadding: 8 + } + Text { + visible: LightPropertiesController.isPointOrSpot + width: parent.width - 16 + elide: Text.ElideMiddle + text: LightPropertiesController.hasIesProfile + ? LightPropertiesController.iesProfilePath + : qsTr("No IES file loaded") + color: PropertiesPanelController.textColor + font.pixelSize: 10 + opacity: 0.85 + } + Row { + visible: LightPropertiesController.isPointOrSpot + spacing: 6 + width: parent.width - 16 + Rectangle { + width: (parent.width - 62) / 2 + height: 22 + radius: 3 + color: browseIesMa.containsMouse + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { + anchors.centerIn: parent + text: qsTr("Browse…") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: browseIesMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: LightPropertiesController.browseIesProfile() + } + } + Rectangle { + width: 56 + height: 22 + radius: 3 + opacity: LightPropertiesController.hasIesProfile ? 1.0 : 0.45 + color: clearIesMa.containsMouse && LightPropertiesController.hasIesProfile + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { + anchors.centerIn: parent + text: qsTr("Clear") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: clearIesMa + anchors.fill: parent + hoverEnabled: true + enabled: LightPropertiesController.hasIesProfile + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: LightPropertiesController.clearIesProfile() + } + } + } + + // Area light (Slice I #491) + Text { + visible: LightPropertiesController.areaLightsEnabled + && LightPropertiesController.isPointOrSpot + text: qsTr("Area light") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + topPadding: 8 + } + Text { + visible: LightPropertiesController.areaLightsEnabled + && LightPropertiesController.isPointOrSpot + width: parent.width - 16 + wrapMode: Text.WordWrap + text: qsTr("Approximates area lighting with multiple point samples. Parent light is hidden while active.") + color: PropertiesPanelController.textColor + font.pixelSize: 10 + opacity: 0.8 + } + ThemedComboBox { + visible: LightPropertiesController.areaLightsEnabled + && LightPropertiesController.isPointOrSpot + width: parent.width - 16 + height: 22 + font.pixelSize: 11 + model: [qsTr("Punctual"), qsTr("Rectangle"), qsTr("Disk"), qsTr("Line")] + currentIndex: { + const s = LightPropertiesController.areaShape + if (s === "rectangle") return 1 + if (s === "disk") return 2 + if (s === "line") return 3 + return 0 + } + onActivated: index => { + const shapes = ["", "rectangle", "disk", "line"] + LightPropertiesController.areaShape = shapes[index] + } + } + Row { + visible: LightPropertiesController.areaLightsEnabled + && LightPropertiesController.isPointOrSpot + && LightPropertiesController.areaShape !== "" + spacing: 6 + width: parent.width - 16 + TransformField { + width: LightPropertiesController.areaShape === "line" + ? parent.width + : (parent.width - 6) / 2 + label: LightPropertiesController.areaShape === "line" ? qsTr("Length") : qsTr("Width") + value: LightPropertiesController.areaWidth + color: "#808080" + step: 0.1 + decimals: 2 + onNewValue: function(val) { LightPropertiesController.areaWidth = val } + } + TransformField { + visible: LightPropertiesController.areaShape === "rectangle" + width: (parent.width - 6) / 2 + label: qsTr("Height") + value: LightPropertiesController.areaHeight + color: "#808080" + step: 0.1 + decimals: 2 + onNewValue: function(val) { LightPropertiesController.areaHeight = val } + } + } + Row { + visible: LightPropertiesController.areaLightsEnabled + && LightPropertiesController.isPointOrSpot + && LightPropertiesController.areaShape !== "" + spacing: 6 + width: parent.width - 16 + Text { + text: qsTr("Samples") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Slider { + width: parent.width - 72 + height: 22 + from: 2 + to: 16 + stepSize: 1 + value: LightPropertiesController.areaSampleCount + onMoved: LightPropertiesController.areaSampleCount = value + } + Text { + width: 24 + anchors.verticalCenter: parent.verticalCenter + horizontalAlignment: Text.AlignRight + color: PropertiesPanelController.textColor + font.pixelSize: 10 + text: LightPropertiesController.areaSampleCount + } + } } } @@ -5697,6 +5867,200 @@ Rectangle { onClicked: SceneLightingController.applySelectedRig() } } + + Text { + text: qsTr("Light collections") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + topPadding: 8 + } + Text { + width: parent.width - 16 + wrapMode: Text.WordWrap + text: qsTr("Select 2+ lights, name a group, and create. Transform/enable the group node as one unit.") + color: PropertiesPanelController.textColor + font.pixelSize: 10 + opacity: 0.8 + } + Row { + spacing: 6 + width: parent.width - 16 + Rectangle { + width: parent.width - createGroupBtn.width - 6 + height: 22 + radius: 3 + color: PropertiesPanelController.inputColor + border.color: PropertiesPanelController.borderColor + TextInput { + id: lightGroupNameField + anchors.fill: parent + anchors.margins: 4 + text: qsTr("LightGroup") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + verticalAlignment: TextInput.AlignVCenter + selectByMouse: true + } + } + Rectangle { + id: createGroupBtn + width: 56 + height: 22 + radius: 3 + opacity: LightGroupController.canGroupSelection ? 1.0 : 0.45 + color: createGroupMa.containsMouse && LightGroupController.canGroupSelection + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { + anchors.centerIn: parent + text: qsTr("Group") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: createGroupMa + anchors.fill: parent + hoverEnabled: true + enabled: LightGroupController.canGroupSelection + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: LightGroupController.createGroupFromSelection(lightGroupNameField.text) + } + } + } + Row { + visible: LightGroupController.hasLightGroupSelection + spacing: 8 + width: parent.width - 16 + Text { + text: qsTr("Selected group") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: LightGroupController.selectedGroupName + color: PropertiesPanelController.textColor + font.pixelSize: 10 + font.italic: true + anchors.verticalCenter: parent.verticalCenter + } + } + Row { + visible: LightGroupController.hasLightGroupSelection + spacing: 6 + width: parent.width - 16 + Rectangle { + width: (parent.width - 6) / 2 + height: 22 + radius: 3 + color: enableGroupMa.containsMouse + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { + anchors.centerIn: parent + text: qsTr("Enable") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: enableGroupMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: LightGroupController.setSelectedGroupEnabled(true) + } + } + Rectangle { + width: (parent.width - 6) / 2 + height: 22 + radius: 3 + color: disableGroupMa.containsMouse + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { + anchors.centerIn: parent + text: qsTr("Disable") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: disableGroupMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: LightGroupController.setSelectedGroupEnabled(false) + } + } + } + Rectangle { + visible: LightGroupController.hasLightGroupSelection + width: parent.width - 16 + height: 22 + radius: 3 + color: dissolveGroupMa.containsMouse + ? PropertiesPanelController.highlightColor + : PropertiesPanelController.controlBgColor + border.color: PropertiesPanelController.borderColor + Text { + anchors.centerIn: parent + text: qsTr("Dissolve group") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + } + MouseArea { + id: dissolveGroupMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: LightGroupController.dissolveSelectedGroup() + } + } + + Text { + text: qsTr("Viewport solo") + color: PropertiesPanelController.textColor + font.pixelSize: 11 + font.bold: true + topPadding: 8 + } + Text { + width: parent.width - 16 + wrapMode: Text.WordWrap + text: qsTr("Per-viewport audit: show only one light in the active viewport (runtime only).") + color: PropertiesPanelController.textColor + font.pixelSize: 10 + opacity: 0.8 + } + ThemedComboBox { + id: viewportSoloPicker + width: parent.width - 16 + height: 22 + font.pixelSize: 11 + enabled: ViewportLightSoloController.hasActiveViewport + model: [qsTr("All lights")].concat(ViewportLightSoloController.lightNames) + currentIndex: { + const solo = ViewportLightSoloController.activeViewportSoloLight + if (!solo || solo.length === 0) + return 0 + const idx = ViewportLightSoloController.lightNames.indexOf(solo) + return idx >= 0 ? idx + 1 : 0 + } + onActivated: index => { + if (index <= 0) + ViewportLightSoloController.activeViewportSoloLight = "" + else + ViewportLightSoloController.activeViewportSoloLight = model[index] + } + Connections { + target: ViewportLightSoloController + function onActiveViewportSoloChanged() { viewportSoloPicker.currentIndex = viewportSoloPicker.currentIndex } + function onLightsChanged() { viewportSoloPicker.currentIndex = viewportSoloPicker.currentIndex } + } + } } } diff --git a/src/AreaLight.cpp b/src/AreaLight.cpp new file mode 100644 index 00000000..9ffa3065 --- /dev/null +++ b/src/AreaLight.cpp @@ -0,0 +1,205 @@ +#include "AreaLight.h" + +#include "Manager.h" + +#include +#include + +#include + +#include + +namespace +{ + +struct SamplePoint +{ + Ogre::Vector3 localPos; +}; + +std::vector sampleRectangle(float width, float height, int count) +{ + std::vector points; + const int cols = qMax(1, static_cast(std::ceil(std::sqrt(count)))); + const int rows = qMax(1, (count + cols - 1) / cols); + for (int r = 0; r < rows; ++r) + { + for (int c = 0; c < cols; ++c) + { + if (static_cast(points.size()) >= count) + break; + const float u = (cols == 1) ? 0.5f : static_cast(c) / static_cast(cols - 1); + const float v = (rows == 1) ? 0.5f : static_cast(r) / static_cast(rows - 1); + SamplePoint p; + p.localPos = Ogre::Vector3((u - 0.5f) * width, (v - 0.5f) * height, 0.0f); + points.push_back(p); + } + } + return points; +} + +std::vector sampleDisk(float radius, int count) +{ + std::vector points; + const int n = qMax(1, count); + for (int i = 0; i < n; ++i) + { + const float t = static_cast(i) / static_cast(n); + const float angle = t * 2.0f * Ogre::Math::PI; + const float ring = (i % 2 == 0) ? 1.0f : 0.55f; + SamplePoint p; + p.localPos = Ogre::Vector3(std::cos(angle) * radius * ring, + std::sin(angle) * radius * ring, + 0.0f); + points.push_back(p); + } + return points; +} + +std::vector sampleLine(float length, int count) +{ + std::vector points; + const int n = qMax(2, count); + for (int i = 0; i < n; ++i) + { + const float t = static_cast(i) / static_cast(n - 1); + SamplePoint p; + p.localPos = Ogre::Vector3((t - 0.5f) * length, 0.0f, 0.0f); + points.push_back(p); + } + return points; +} + +} // namespace + +namespace AreaLight +{ + +bool isProxyLight(const Ogre::Light* light) +{ + if (!light) + return false; + const auto any = light->getUserObjectBindings().getUserAny(kProxyLightTag); + return any.has_value() && Ogre::any_cast(any); +} + +QString ownerLightName(const Ogre::Light* light) +{ + if (!light) + return {}; + const auto any = light->getUserObjectBindings().getUserAny(kProxyOwnerKey); + if (!any.has_value()) + return {}; + try + { + return QString::fromStdString(Ogre::any_cast(any)); + } + catch (...) + { + return {}; + } +} + +void removeProxies(const QString& ownerLightName) +{ + auto* mgr = Manager::getSingletonPtr(); + if (!mgr || ownerLightName.isEmpty()) + return; + + Ogre::SceneManager* sceneMgr = mgr->getSceneMgr(); + if (!sceneMgr) + return; + + const LightHandle* owner = LightManager::getSingletonPtr() + ? LightManager::getSingleton()->findLight(ownerLightName) + : nullptr; + if (!owner || !owner->sceneNode) + return; + + std::vector toDestroy; + for (const auto& child : owner->sceneNode->getChildren()) + { + auto* childNode = static_cast(child); + for (unsigned short i = 0; i < childNode->numAttachedObjects(); ++i) + { + Ogre::MovableObject* obj = childNode->getAttachedObject(i); + if (!obj || obj->getMovableType() != QStringLiteral("Light")) + continue; + auto* light = static_cast(obj); + if (isProxyLight(light) && AreaLight::ownerLightName(light) == ownerLightName) + toDestroy.push_back(childNode); + } + } + + for (Ogre::SceneNode* node : toDestroy) + { + node->removeAndDestroyAllChildren(); + node->getCreator()->destroySceneNode(node); + } +} + +void syncProxies(const LightHandle& owner, const LightSnapshot& snapshot) +{ +#ifndef ENABLE_AREA_LIGHTS + Q_UNUSED(owner); + Q_UNUSED(snapshot); + return; +#else + if (!owner.isValid()) + return; + + removeProxies(owner.name); + + const QString shape = snapshot.areaShape.trimmed().toLower(); + if (shape.isEmpty()) + { + owner.light->setVisible(snapshot.enabled); + return; + } + + std::vector samples; + const int count = qBound(2, snapshot.areaSampleCount, 16); + if (shape == QStringLiteral("rectangle")) + samples = sampleRectangle(snapshot.areaWidth, snapshot.areaHeight, count); + else if (shape == QStringLiteral("disk")) + samples = sampleDisk(qMax(0.05f, snapshot.areaWidth * 0.5f), count); + else if (shape == QStringLiteral("line")) + samples = sampleLine(snapshot.areaWidth, count); + else + return; + + owner.light->setVisible(false); + + auto* lights = LightManager::getSingletonPtr(); + auto* mgr = Manager::getSingletonPtr(); + if (!lights || !mgr || !mgr->getSceneMgr()) + return; + + Ogre::SceneManager* sceneMgr = mgr->getSceneMgr(); + const float weight = 1.0f / static_cast(samples.size()); + int index = 0; + for (const SamplePoint& sample : samples) + { + const QString proxyName = owner.name + QStringLiteral("_area%1").arg(index++); + Ogre::SceneNode* node = owner.sceneNode->createChildSceneNode(proxyName.toStdString()); + Ogre::Light* proxyLight = sceneMgr->createLight(proxyName.toStdString()); + proxyLight->setType(Ogre::Light::LT_POINT); + proxyLight->setDiffuseColour(owner.light->getDiffuseColour()); + proxyLight->setSpecularColour(owner.light->getSpecularColour()); + proxyLight->setPowerScale(owner.light->getPowerScale() * weight); + proxyLight->setAttenuation(owner.light->getAttenuationRange(), + owner.light->getAttenuationConstant(), + owner.light->getAttenuationLinear(), + owner.light->getAttenuationQuadric()); + proxyLight->setVisible(snapshot.enabled); + proxyLight->setCastShadows(false); + proxyLight->getUserObjectBindings().setUserAny(kProxyLightTag, Ogre::Any(true)); + proxyLight->getUserObjectBindings().setUserAny(kProxyOwnerKey, + Ogre::Any(owner.name.toStdString())); + node->attachObject(proxyLight); + node->setPosition(sample.localPos); + } +#endif +} + +} // namespace AreaLight diff --git a/src/AreaLight.h b/src/AreaLight.h new file mode 100644 index 00000000..17016517 --- /dev/null +++ b/src/AreaLight.h @@ -0,0 +1,22 @@ +#pragma once + +#include "LightManager.h" + +#include + +namespace AreaLight +{ + +inline constexpr const char* kProxyLightTag = "area_light_proxy"; +inline constexpr const char* kProxyOwnerKey = "area_light_owner"; + +bool isProxyLight(const Ogre::Light* light); +QString ownerLightName(const Ogre::Light* light); + +/// Rebuild child point-light proxies for an area-shaped user light. +void syncProxies(const LightHandle& owner, const LightSnapshot& snapshot); + +/// Remove every proxy owned by @p ownerLightName. +void removeProxies(const QString& ownerLightName); + +} // namespace AreaLight diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1a6c736d..68a0bec5 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -37,6 +37,11 @@ SelectionSet.cpp LightManager.cpp LightRigLibrary.cpp LightLinking.cpp +LightGroupLibrary.cpp +LightGroupController.cpp +ViewportLightSoloController.cpp +IesProfile.cpp +AreaLight.cpp LightVisualizer.cpp LightsController.cpp LightPropertiesController.cpp diff --git a/src/IesProfile.cpp b/src/IesProfile.cpp new file mode 100644 index 00000000..6c23c6d5 --- /dev/null +++ b/src/IesProfile.cpp @@ -0,0 +1,209 @@ +#include "IesProfile.h" + +#include +#include + +#include +#include +#include + +#include +#include + +namespace +{ + +float angleAtFraction(const QVector& angles, const QVector& values, float fraction) +{ + if (angles.isEmpty() || values.isEmpty() || angles.size() != values.size()) + return 90.0f; + + const float peak = *std::max_element(values.constBegin(), values.constEnd()); + if (peak <= 0.0f) + return 90.0f; + + const float target = peak * fraction; + for (int i = 0; i < values.size(); ++i) + { + if (values[i] >= target) + continue; + if (i == 0) + return angles[0]; + const float t = (target - values[i - 1]) / (values[i] - values[i - 1]); + return angles[i - 1] + t * (angles[i] - angles[i - 1]); + } + return angles.last(); +} + +QVector tokenizeLines(const QByteArray& bytes) +{ + QVector lines; + const QList raw = bytes.split('\n'); + lines.reserve(raw.size()); + for (const QByteArray& line : raw) + lines.append(QString::fromUtf8(line.trimmed())); + return lines; +} + +} // namespace + +IesProfile IesProfile::parseBytes(const QByteArray& bytes, QString* error) +{ + IesProfile profile; + + const QVector lines = tokenizeLines(bytes); + int tiltLine = -1; + for (int i = 0; i < lines.size(); ++i) + { + if (lines[i].startsWith(QStringLiteral("TILT"), Qt::CaseInsensitive)) + { + tiltLine = i; + break; + } + } + if (tiltLine < 0) + { + if (error) + *error = QStringLiteral("Missing TILT keyword"); + return profile; + } + + int cursor = tiltLine + 1; + auto readFloats = [&](int count) -> QVector { + QVector out; + out.reserve(count); + while (out.size() < count && cursor < lines.size()) + { + const QString line = lines[cursor++].trimmed(); + if (line.isEmpty()) + continue; + + const QStringList parts = + line.split(QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); + for (const QString& part : parts) + { + bool ok = false; + const float value = part.toFloat(&ok); + if (ok) + out.append(value); + if (out.size() >= count) + break; + } + } + return out; + }; + + const QVector header = readFloats(10); + if (header.size() < 10) + { + if (error) + *error = QStringLiteral("Truncated IES header"); + return profile; + } + + const int numVertical = static_cast(header[3]); + const int numHorizontal = static_cast(header[4]); + if (numVertical <= 0 || numHorizontal <= 0) + { + if (error) + *error = QStringLiteral("Invalid IES angle counts"); + return profile; + } + + readFloats(5); // skip electrical + multipliers we don't need for v1 + + const QVector vertical = readFloats(numVertical); + const QVector horizontal = readFloats(numHorizontal); + const QVector candelaFlat = readFloats(numVertical * numHorizontal); + + if (vertical.size() != numVertical || candelaFlat.size() != numVertical * numHorizontal) + { + if (error) + *error = QStringLiteral("Truncated IES candela table"); + return profile; + } + + Q_UNUSED(horizontal); + + profile.verticalAnglesDeg = vertical; + profile.candela = candelaFlat; + profile.maxCandela = 0.0f; + for (float c : candelaFlat) + profile.maxCandela = std::max(profile.maxCandela, c); + + QVector slice0; + slice0.reserve(numVertical); + for (int v = 0; v < numVertical; ++v) + slice0.append(candelaFlat[v]); // horizontal index 0 → candela[h * numVertical + v] + + profile.beamAngleDeg = angleAtFraction(vertical, slice0, 0.5f); + profile.fieldAngleDeg = angleAtFraction(vertical, slice0, 0.1f); + profile.totalLumens = header[1]; + profile.valid = profile.maxCandela > 0.0f; + if (!profile.valid && error) + *error = QStringLiteral("IES candela table is empty"); + return profile; +} + +IesProfile IesProfile::parseFile(const QString& path, QString* error) +{ + IesProfile profile; + profile.sourcePath = path; + + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + { + if (error) + *error = QStringLiteral("Cannot open IES file"); + return profile; + } + + profile = parseBytes(file.readAll(), error); + profile.sourcePath = path; + return profile; +} + +QVector IesProfile::polarSlice() const +{ + QVector out; + if (!valid || verticalAnglesDeg.isEmpty()) + return out; + + const int numVertical = verticalAnglesDeg.size(); + out.reserve(numVertical); + for (int v = 0; v < numVertical; ++v) + { + const float c = candela[v]; // horizontal index 0 + out.append(maxCandela > 0.0f ? c / maxCandela : 0.0f); + } + return out; +} + +namespace IesLightApply +{ + +void applyToLight(const IesProfile& profile, Ogre::Light* light, float basePowerScale) +{ + if (!profile.valid || !light) + return; + + const float inner = qBound(1.0f, profile.beamAngleDeg * 0.65f, 179.0f); + const float outer = qBound(inner + 1.0f, profile.fieldAngleDeg, 179.0f); + const float falloff = qBound(0.1f, profile.beamAngleDeg / qMax(1.0f, profile.fieldAngleDeg), 4.0f); + + if (light->getType() == Ogre::Light::LT_SPOTLIGHT) + { + light->setSpotlightRange(Ogre::Degree(inner), Ogre::Degree(outer), falloff); + } + else if (light->getType() == Ogre::Light::LT_POINT) + { + light->setType(Ogre::Light::LT_SPOTLIGHT); + light->setSpotlightRange(Ogre::Degree(inner), Ogre::Degree(outer), falloff); + } + + const float reference = 1000.0f; + const float scale = qBound(0.05f, profile.maxCandela / reference, 8.0f); + light->setPowerScale(basePowerScale * scale); +} + +} // namespace IesLightApply diff --git a/src/IesProfile.h b/src/IesProfile.h new file mode 100644 index 00000000..e4c0bef3 --- /dev/null +++ b/src/IesProfile.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +namespace Ogre +{ +class Light; +} + +struct IesProfile +{ + bool valid = false; + QString sourcePath; + QVector verticalAnglesDeg; + QVector candela; ///< flattened LM-63 horizontal-major: index = h * numVertical + v + float totalLumens = 0.0f; + float maxCandela = 0.0f; + float beamAngleDeg = 90.0f; ///< angle where intensity drops to 50% of peak (0° slice) + float fieldAngleDeg = 120.0f; ///< angle where intensity drops to 10% of peak + + static IesProfile parseFile(const QString& path, QString* error = nullptr); + static IesProfile parseBytes(const QByteArray& bytes, QString* error = nullptr); + QVector polarSlice() const; ///< normalized [0..1] candela vs angle for gizmo plot +}; + +namespace IesLightApply +{ + +/// Approximate IES distribution on a point/spot light using Ogre spotlight cones. +void applyToLight(const IesProfile& profile, Ogre::Light* light, float basePowerScale = 1.0f); + +} // namespace IesLightApply diff --git a/src/IesProfile_test.cpp b/src/IesProfile_test.cpp new file mode 100644 index 00000000..23730dc0 --- /dev/null +++ b/src/IesProfile_test.cpp @@ -0,0 +1,97 @@ +#include + +#include + +#include "IesProfile.h" + +#include +#include + +namespace +{ + +QByteArray sampleIesBytes() +{ + return QByteArray( + "IESNA:LM-63-2002\n" + "TILT=NONE\n" + "1 1 1 3 1 1 1 0 0 0\n" + "1 1 1 0 0\n" + "0 45 90\n" + "0\n" + "1000 500 100\n"); +} + +} // namespace + +TEST(IesProfileTest, SampleFixtureHasExpectedLayout) +{ + const QByteArray bytes = sampleIesBytes(); + const QList raw = bytes.split('\n'); + ASSERT_GE(raw.size(), 3); + EXPECT_TRUE(QString::fromUtf8(raw[1]).contains(QStringLiteral("TILT"), Qt::CaseInsensitive)); + const QStringList headerParts = + QString::fromUtf8(raw[2]).split(QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); + EXPECT_EQ(headerParts.size(), 10); +} + +TEST(IesProfileTest, ParsesMinimalTypeC) +{ + QString error; + const IesProfile profile = IesProfile::parseBytes(sampleIesBytes(), &error); + ASSERT_TRUE(profile.valid) << error.toStdString(); + EXPECT_EQ(profile.verticalAnglesDeg.size(), 3); + EXPECT_GT(profile.maxCandela, 0.0f); + EXPECT_GT(profile.beamAngleDeg, 0.0f); + + const QVector slice = profile.polarSlice(); + ASSERT_EQ(slice.size(), 3); + EXPECT_FLOAT_EQ(slice[0], 1.0f); + EXPECT_LT(slice[2], slice[0]); +} + +TEST(IesProfileTest, ParseFileReadsFromDisk) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("sample.ies")); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + ASSERT_EQ(file.write(sampleIesBytes()), sampleIesBytes().size()); + file.close(); + + QString error; + const IesProfile profile = IesProfile::parseFile(path, &error); + ASSERT_TRUE(profile.valid) << error.toStdString(); +} + +TEST(IesProfileTest, MissingTiltFails) +{ + QString error; + const IesProfile profile = IesProfile::parseBytes(QByteArray("NOT AN IES FILE\n"), &error); + EXPECT_FALSE(profile.valid); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(IesProfileTest, HorizontalMajorCandelaLayout) +{ + const QByteArray bytes = + "IESNA:LM-63-2002\n" + "TILT=NONE\n" + "1 1 1 2 2 1 1 1 0 0 0\n" + "1 1 1 0 0\n" + "0 90\n" + "0 180\n" + "1000 2000 3000 4000\n"; + + QString error; + const IesProfile profile = IesProfile::parseBytes(bytes, &error); + ASSERT_TRUE(profile.valid) << error.toStdString(); + ASSERT_EQ(profile.verticalAnglesDeg.size(), 2); + ASSERT_EQ(profile.candela.size(), 4); + + const QVector slice = profile.polarSlice(); + ASSERT_EQ(slice.size(), 2); + EXPECT_FLOAT_EQ(slice[0], 0.25f); + EXPECT_FLOAT_EQ(slice[1], 0.5f); +} diff --git a/src/LightGroupController.cpp b/src/LightGroupController.cpp new file mode 100644 index 00000000..578db855 --- /dev/null +++ b/src/LightGroupController.cpp @@ -0,0 +1,130 @@ +#include "LightGroupController.h" + +#include "LightGroupLibrary.h" +#include "LightManager.h" +#include "Manager.h" +#include "SelectionSet.h" +#include "SentryReporter.h" + +#include + +LightGroupController* LightGroupController::s_singleton = nullptr; + +LightGroupController* LightGroupController::instance() +{ + if (!s_singleton) + s_singleton = new LightGroupController(); // NOSONAR — singleton + return s_singleton; +} + +LightGroupController* LightGroupController::qmlInstance(QQmlEngine*, QJSEngine*) +{ + return instance(); +} + +void LightGroupController::kill() +{ + delete s_singleton; // NOSONAR — singleton + s_singleton = nullptr; +} + +LightGroupController::LightGroupController(QObject* parent) + : QObject(parent) +{ + if (auto* sel = SelectionSet::getSingletonPtr()) + connect(sel, &SelectionSet::selectionChanged, this, &LightGroupController::selectionChanged); + if (auto* lights = LightManager::getSingletonPtr()) + { + connect(lights, &LightManager::lightCreated, this, &LightGroupController::groupsChanged); + connect(lights, &LightManager::lightDeleted, this, &LightGroupController::groupsChanged); + connect(lights, &LightManager::lightChanged, this, &LightGroupController::groupsChanged); + } +} + +QStringList LightGroupController::selectedLightNames() const +{ + QStringList names; + auto* sel = SelectionSet::getSingletonPtr(); + if (!sel) + return names; + + for (Ogre::SceneNode* node : sel->getNodesSelectionList()) + { + if (node && LightManager::sceneNodeIsUserLight(node)) + names.append(QString::fromStdString(node->getName())); + } + return names; +} + +QString LightGroupController::selectedLightGroupNodeName() const +{ + auto* sel = SelectionSet::getSingletonPtr(); + auto* mgr = Manager::getSingletonPtr(); + if (!sel || !mgr) + return {}; + + for (Ogre::SceneNode* node : sel->getNodesSelectionList()) + { + if (!node) + continue; + if (LightGroupLibrary::sceneNodeIsLightGroup(node)) + return QString::fromStdString(node->getName()); + } + return {}; +} + +bool LightGroupController::canGroupSelection() const +{ + return selectedLightNames().size() >= 2; +} + +bool LightGroupController::hasLightGroupSelection() const +{ + return !selectedLightGroupNodeName().isEmpty(); +} + +QStringList LightGroupController::lightGroupNames() const +{ + return LightGroupLibrary::listLightGroupNames(); +} + +QString LightGroupController::selectedGroupName() const +{ + return selectedLightGroupNodeName(); +} + +void LightGroupController::createGroupFromSelection(const QString& groupName) +{ + const LightGroupCreateResult result = + LightGroupLibrary::createGroup(groupName, selectedLightNames()); + if (!result.ok) + return; + + emit groupsChanged(); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Create light group: %1").arg(result.groupNodeName)); +} + +void LightGroupController::dissolveSelectedGroup() +{ + const QString groupName = selectedLightGroupNodeName(); + if (groupName.isEmpty()) + return; + if (!LightGroupLibrary::dissolveGroup(groupName)) + return; + + emit groupsChanged(); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Dissolve light group: %1").arg(groupName)); +} + +void LightGroupController::setSelectedGroupEnabled(bool enabled) +{ + const QString groupName = selectedLightGroupNodeName(); + if (groupName.isEmpty()) + return; + if (!LightGroupLibrary::setGroupEnabled(groupName, enabled)) + return; + + emit groupsChanged(); +} diff --git a/src/LightGroupController.h b/src/LightGroupController.h new file mode 100644 index 00000000..9cf3a030 --- /dev/null +++ b/src/LightGroupController.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +class LightGroupController : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool canGroupSelection READ canGroupSelection NOTIFY selectionChanged) + Q_PROPERTY(bool hasLightGroupSelection READ hasLightGroupSelection NOTIFY selectionChanged) + Q_PROPERTY(QStringList lightGroupNames READ lightGroupNames NOTIFY groupsChanged) + Q_PROPERTY(QString selectedGroupName READ selectedGroupName NOTIFY selectionChanged) + +public: + static LightGroupController* instance(); + static LightGroupController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + bool canGroupSelection() const; + bool hasLightGroupSelection() const; + QStringList lightGroupNames() const; + QString selectedGroupName() const; + + Q_INVOKABLE void createGroupFromSelection(const QString& groupName); + Q_INVOKABLE void dissolveSelectedGroup(); + Q_INVOKABLE void setSelectedGroupEnabled(bool enabled); + +signals: + void selectionChanged(); + void groupsChanged(); + +private: + explicit LightGroupController(QObject* parent = nullptr); + + QStringList selectedLightNames() const; + QString selectedLightGroupNodeName() const; + + static LightGroupController* s_singleton; +}; diff --git a/src/LightGroupLibrary.cpp b/src/LightGroupLibrary.cpp new file mode 100644 index 00000000..e1bb120b --- /dev/null +++ b/src/LightGroupLibrary.cpp @@ -0,0 +1,211 @@ +#include "LightGroupLibrary.h" + +#include "Manager.h" +#include "SentryReporter.h" + +#include + +#include + +namespace +{ + +Ogre::SceneNode* rootNode() +{ + auto* mgr = Manager::getSingletonPtr(); + if (!mgr || !mgr->getSceneMgr()) + return nullptr; + return mgr->getSceneMgr()->getRootSceneNode(); +} + +} // namespace + +namespace LightGroupLibrary +{ + +bool sceneNodeIsLightGroup(Ogre::SceneNode* node) +{ + if (!node) + return false; + const auto& bindings = node->getUserObjectBindings(); + const auto any = bindings.getUserAny(kLightGroupTag); + return any.has_value() && Ogre::any_cast(any); +} + +void tagLightGroupNode(Ogre::SceneNode* node) +{ + if (!node) + return; + node->getUserObjectBindings().setUserAny(kLightGroupTag, Ogre::Any(true)); +} + +QStringList listLightGroupNames() +{ + QStringList names; + Ogre::SceneNode* root = rootNode(); + if (!root) + return names; + + for (const auto& child : root->getChildren()) + { + auto* node = static_cast(child); + if (sceneNodeIsLightGroup(node)) + names.append(QString::fromStdString(node->getName())); + } + names.sort(Qt::CaseInsensitive); + return names; +} + +LightGroupCreateResult createGroup(const QString& groupName, const QStringList& lightNames) +{ + LightGroupCreateResult result; + if (groupName.trimmed().isEmpty()) + { + result.error = QStringLiteral("Group name is required"); + return result; + } + if (lightNames.isEmpty()) + { + result.error = QStringLiteral("Select at least one light"); + return result; + } + + auto* lights = LightManager::getSingletonPtr(); + if (!lights) + { + result.error = QStringLiteral("Light manager unavailable"); + return result; + } + + auto* mgr = Manager::getSingletonPtr(); + if (!mgr) + { + result.error = QStringLiteral("Scene manager unavailable"); + return result; + } + + Ogre::SceneNode* groupNode = lights->createRigGroupNode(groupName.trimmed()); + if (!groupNode) + { + result.error = QStringLiteral("Failed to create group node"); + return result; + } + tagLightGroupNode(groupNode); + result.groupNodeName = QString::fromStdString(groupNode->getName()); + + QSet seen; + for (const QString& lightName : lightNames) + { + const QString trimmed = lightName.trimmed(); + if (trimmed.isEmpty() || seen.contains(trimmed)) + continue; + seen.insert(trimmed); + + LightHandle* handle = lights->findLight(trimmed); + if (!handle || !handle->sceneNode) + continue; + + mgr->reparentNode(handle->sceneNode, groupNode); + result.movedLightNames.append(trimmed); + } + + if (result.movedLightNames.isEmpty()) + { + Manager::getSingletonPtr()->destroySceneNode(result.groupNodeName); + result.groupNodeName.clear(); + result.error = QStringLiteral("No valid lights to group"); + return result; + } + + result.ok = true; + SentryReporter::addBreadcrumb(QStringLiteral("scene.light.group_create"), + QStringLiteral("Created light group %1 (%2 lights)") + .arg(result.groupNodeName) + .arg(result.movedLightNames.size())); + return result; +} + +bool dissolveGroup(const QString& groupNodeName) +{ + auto* mgr = Manager::getSingletonPtr(); + auto* lights = LightManager::getSingletonPtr(); + Ogre::SceneNode* root = rootNode(); + if (!mgr || !lights || !root || groupNodeName.isEmpty()) + return false; + + Ogre::SceneNode* groupNode = mgr->getSceneNode(groupNodeName); + if (!groupNode || !sceneNodeIsLightGroup(groupNode)) + return false; + + const auto children = groupNode->getChildren(); + for (const auto& child : children) + { + auto* childNode = static_cast(child); + mgr->reparentNode(childNode, root); + } + + mgr->destroySceneNode(groupNodeName); + SentryReporter::addBreadcrumb(QStringLiteral("scene.light.group_dissolve"), + QStringLiteral("Dissolved light group %1").arg(groupNodeName)); + return true; +} + +bool setGroupEnabled(const QString& groupNodeName, bool enabled) +{ + auto* mgr = Manager::getSingletonPtr(); + auto* lights = LightManager::getSingletonPtr(); + if (!mgr || !lights || groupNodeName.isEmpty()) + return false; + + Ogre::SceneNode* groupNode = mgr->getSceneNode(groupNodeName); + if (!groupNode || !sceneNodeIsLightGroup(groupNode)) + return false; + + for (const auto& child : groupNode->getChildren()) + { + auto* childNode = static_cast(child); + if (!LightManager::sceneNodeIsUserLight(childNode)) + continue; + if (LightHandle* handle = lights->findLightBySceneNode(childNode)) + { + if (handle->light) + handle->light->setVisible(enabled); + } + } + + SentryReporter::addBreadcrumb(QStringLiteral("scene.light.group_enable"), + QStringLiteral("%1 group %2") + .arg(enabled ? QStringLiteral("Enable") + : QStringLiteral("Disable")) + .arg(groupNodeName)); + return true; +} + +bool renameGroup(const QString& oldName, const QString& newName) +{ + auto* mgr = Manager::getSingletonPtr(); + if (!mgr || oldName.isEmpty() || newName.trimmed().isEmpty()) + return false; + + Ogre::SceneNode* groupNode = mgr->getSceneNode(oldName); + if (!groupNode || !sceneNodeIsLightGroup(groupNode)) + return false; + + const QString trimmed = newName.trimmed(); + if (mgr->hasSceneNode(trimmed)) + return false; + + Ogre::SceneNode* renamedNode = mgr->addSceneNode(trimmed); + if (!renamedNode) + return false; + tagLightGroupNode(renamedNode); + + const auto children = groupNode->getChildren(); + for (const auto& child : children) + mgr->reparentNode(static_cast(child), renamedNode); + + mgr->destroySceneNode(groupNode); + return true; +} + +} // namespace LightGroupLibrary diff --git a/src/LightGroupLibrary.h b/src/LightGroupLibrary.h new file mode 100644 index 00000000..5025afbb --- /dev/null +++ b/src/LightGroupLibrary.h @@ -0,0 +1,36 @@ +#pragma once + +#include "LightManager.h" + +#include +#include + +namespace Ogre +{ +class SceneNode; +} + +struct LightGroupCreateResult +{ + bool ok = false; + QString error; + QString groupNodeName; + QStringList movedLightNames; +}; + +namespace LightGroupLibrary +{ + +inline constexpr const char* kLightGroupTag = "light_group"; + +bool sceneNodeIsLightGroup(Ogre::SceneNode* node); +void tagLightGroupNode(Ogre::SceneNode* node); + +QStringList listLightGroupNames(); + +LightGroupCreateResult createGroup(const QString& groupName, const QStringList& lightNames); +bool dissolveGroup(const QString& groupNodeName); +bool setGroupEnabled(const QString& groupNodeName, bool enabled); +bool renameGroup(const QString& oldName, const QString& newName); + +} // namespace LightGroupLibrary diff --git a/src/LightGroupLibrary_test.cpp b/src/LightGroupLibrary_test.cpp new file mode 100644 index 00000000..4a2c3c5f --- /dev/null +++ b/src/LightGroupLibrary_test.cpp @@ -0,0 +1,74 @@ +#include + +#include "LightGroupLibrary.h" +#include "LightLinking.h" +#include "LightManager.h" +#include "Manager.h" +#include "SceneLightsIO.h" +#include "TestHelpers.h" + +#include + +class LightGroupLibraryOgreTest : public ::testing::Test +{ +protected: + void SetUp() override + { + Manager::kill(); + ASSERT_TRUE(tryInitOgre()); + createStandardOgreMaterials(); + LightManager::getSingleton()->tryConnectToManager(); + LightLinking::clearAllRules(); + } + + void TearDown() override + { + LightLinking::clearAllRules(); + LightManager::kill(); + Manager::kill(); + } +}; + +TEST_F(LightGroupLibraryOgreTest, CreateAndDissolveGroup) +{ + const LightHandle a = + LightManager::getSingleton()->createLight(Ogre::Light::LT_POINT, QStringLiteral("A")); + const LightHandle b = + LightManager::getSingleton()->createLight(Ogre::Light::LT_POINT, QStringLiteral("B")); + ASSERT_TRUE(a.isValid()); + ASSERT_TRUE(b.isValid()); + + const LightGroupCreateResult result = + LightGroupLibrary::createGroup(QStringLiteral("HeroRig"), {a.name, b.name}); + ASSERT_TRUE(result.ok); + EXPECT_EQ(result.movedLightNames.size(), 2); + + auto* mgr = Manager::getSingletonPtr(); + Ogre::SceneNode* groupNode = mgr->getSceneNode(result.groupNodeName); + ASSERT_NE(groupNode, nullptr); + EXPECT_TRUE(LightGroupLibrary::sceneNodeIsLightGroup(groupNode)); + EXPECT_EQ(groupNode->numChildren(), 2u); + + EXPECT_TRUE(LightGroupLibrary::dissolveGroup(result.groupNodeName)); + EXPECT_FALSE(mgr->hasSceneNode(result.groupNodeName)); +} + +TEST_F(LightGroupLibraryOgreTest, CollectionRoundTripsInSceneJson) +{ + const LightHandle light = + LightManager::getSingleton()->createLight(Ogre::Light::LT_POINT, QStringLiteral("Key")); + ASSERT_TRUE(light.isValid()); + + LightGroupLibrary::createGroup(QStringLiteral("Set"), {light.name}); + + const SceneLightsIO::SceneLightsDocument captured = SceneLightsIO::captureFromScene(); + ASSERT_EQ(captured.rigGroups.size(), 1); + EXPECT_TRUE(captured.rigGroups.first().isUserCollection); + EXPECT_EQ(captured.rigGroups.first().lights.size(), 1); + + const QByteArray json = SceneLightsIO::documentToJson(captured); + SceneLightsIO::SceneLightsDocument restored; + ASSERT_TRUE(SceneLightsIO::documentFromJson(json, restored)); + ASSERT_EQ(restored.rigGroups.size(), 1); + EXPECT_TRUE(restored.rigGroups.first().isUserCollection); +} diff --git a/src/LightManager.cpp b/src/LightManager.cpp index 115a6449..d01deed6 100644 --- a/src/LightManager.cpp +++ b/src/LightManager.cpp @@ -5,6 +5,11 @@ #include "SentryReporter.h" #include "ShadowController.h" +#include "IesProfile.h" +#ifdef ENABLE_AREA_LIGHTS +#include "AreaLight.h" +#endif + #include #include #include @@ -20,7 +25,23 @@ LightSnapshot LightSnapshot::fromHandle(const LightHandle& handle) snapshot.name = handle.name; snapshot.type = handle.light->getType(); - snapshot.enabled = handle.light->isVisible(); + const auto& bindings = handle.light->getUserObjectBindings(); + const auto enabledAny = bindings.getUserAny(QStringLiteral("light_user_enabled").toStdString()); + if (enabledAny.has_value()) + { + try + { + snapshot.enabled = Ogre::any_cast(enabledAny); + } + catch (...) + { + snapshot.enabled = handle.light->isVisible(); + } + } + else + { + snapshot.enabled = handle.light->isVisible(); + } snapshot.diffuse = handle.light->getDiffuseColour(); snapshot.specular = handle.light->getSpecularColour(); snapshot.powerScale = handle.light->getPowerScale(); @@ -45,7 +66,6 @@ LightSnapshot LightSnapshot::fromHandle(const LightHandle& handle) } snapshot.castShadows = handle.light->getCastShadows(); - const auto& bindings = handle.light->getUserObjectBindings(); const auto depthAny = bindings.getUserAny(QStringLiteral("shadow_depth_bias").toStdString()); const auto slopeAny = bindings.getUserAny(QStringLiteral("shadow_slope_bias").toStdString()); if (depthAny.has_value()) @@ -96,6 +116,43 @@ LightSnapshot LightSnapshot::fromHandle(const LightHandle& handle) } } + const auto iesAny = bindings.getUserAny(QStringLiteral("light_ies_path").toStdString()); + if (iesAny.has_value()) + { + try + { + snapshot.iesProfilePath = QString::fromStdString(Ogre::any_cast(iesAny)); + } + catch (...) + { + snapshot.iesProfilePath.clear(); + } + } + +#ifdef ENABLE_AREA_LIGHTS + const auto areaShapeAny = bindings.getUserAny(QStringLiteral("light_area_shape").toStdString()); + if (areaShapeAny.has_value()) + { + try + { + snapshot.areaShape = QString::fromStdString(Ogre::any_cast(areaShapeAny)); + } + catch (...) + { + snapshot.areaShape.clear(); + } + } + const auto areaWidthAny = bindings.getUserAny(QStringLiteral("light_area_width").toStdString()); + if (areaWidthAny.has_value()) + snapshot.areaWidth = Ogre::any_cast(areaWidthAny); + const auto areaHeightAny = bindings.getUserAny(QStringLiteral("light_area_height").toStdString()); + if (areaHeightAny.has_value()) + snapshot.areaHeight = Ogre::any_cast(areaHeightAny); + const auto areaSamplesAny = bindings.getUserAny(QStringLiteral("light_area_samples").toStdString()); + if (areaSamplesAny.has_value()) + snapshot.areaSampleCount = static_cast(Ogre::any_cast(areaSamplesAny)); +#endif + return snapshot; } @@ -118,7 +175,13 @@ bool LightSnapshot::operator==(const LightSnapshot& other) const && shadowSlopeBias == other.shadowSlopeBias && linkMode == other.linkMode && linkedEntityNames == other.linkedEntityNames - && linkChannelBit == other.linkChannelBit; + && linkChannelBit == other.linkChannelBit + && iesProfilePath == other.iesProfilePath +#ifdef ENABLE_AREA_LIGHTS + && areaShape == other.areaShape && areaWidth == other.areaWidth + && areaHeight == other.areaHeight && areaSampleCount == other.areaSampleCount +#endif + ; } LightManager* LightManager::getSingleton() @@ -235,7 +298,6 @@ void LightManager::applySnapshotToHandle(const LightSnapshot& snapshot, LightHan return; handle.light->setType(snapshot.type); - handle.light->setVisible(snapshot.enabled); handle.light->setDiffuseColour(snapshot.diffuse); handle.light->setSpecularColour(snapshot.specular); handle.light->setPowerScale(snapshot.powerScale); @@ -270,7 +332,33 @@ void LightManager::applySnapshotToHandle(const LightSnapshot& snapshot, LightHan bindings.setUserAny(QStringLiteral("light_link_channel").toStdString(), Ogre::Any(snapshot.linkChannelBit)); + bindings.setUserAny(QStringLiteral("light_user_enabled").toStdString(), + Ogre::Any(snapshot.enabled)); + bindings.setUserAny(QStringLiteral("light_ies_path").toStdString(), + Ogre::Any(snapshot.iesProfilePath.toStdString())); +#ifdef ENABLE_AREA_LIGHTS + bindings.setUserAny(QStringLiteral("light_area_shape").toStdString(), + Ogre::Any(snapshot.areaShape.toStdString())); + bindings.setUserAny(QStringLiteral("light_area_width").toStdString(), + Ogre::Any(snapshot.areaWidth)); + bindings.setUserAny(QStringLiteral("light_area_height").toStdString(), + Ogre::Any(snapshot.areaHeight)); + bindings.setUserAny(QStringLiteral("light_area_samples").toStdString(), + Ogre::Any(static_cast(snapshot.areaSampleCount))); +#endif + LightLinking::applyFromSnapshot(snapshot, handle.light); + + if (!snapshot.iesProfilePath.isEmpty()) + { + const IesProfile profile = IesProfile::parseFile(snapshot.iesProfilePath); + IesLightApply::applyToLight(profile, handle.light, snapshot.powerScale); + } +#ifdef ENABLE_AREA_LIGHTS + AreaLight::syncProxies(handle, snapshot); +#else + handle.light->setVisible(snapshot.enabled); +#endif } LightHandle LightManager::createLightInternal(Ogre::Light::LightTypes type, const QString& baseName) diff --git a/src/LightManager.h b/src/LightManager.h index 73c70836..9c81f7c1 100644 --- a/src/LightManager.h +++ b/src/LightManager.h @@ -50,6 +50,17 @@ struct LightSnapshot float shadowDepthBias = 0.00005f; float shadowSlopeBias = 1.0f; + /// Slice I (#491): IES photometric profile path (point/spot). + QString iesProfilePath; + +#ifdef ENABLE_AREA_LIGHTS + /// Area-light approximation (#491) — rectangle / disk / line via child point lights. + QString areaShape; ///< empty = punctual, "rectangle", "disk", "line" + float areaWidth = 1.0f; + float areaHeight = 1.0f; + int areaSampleCount = 4; +#endif + /// Slice I (#491): entity include/exclude via Ogre light masks (bits 1..31). LightLinking::Mode linkMode = LightLinking::Mode::None; QStringList linkedEntityNames; diff --git a/src/LightPropertiesController.cpp b/src/LightPropertiesController.cpp index 7ef2086a..445a2f25 100644 --- a/src/LightPropertiesController.cpp +++ b/src/LightPropertiesController.cpp @@ -2,6 +2,7 @@ #include "LightManager.h" #include "LightLinking.h" +#include "IesProfile.h" #include "Manager.h" #include "SelectionSet.h" #include "ShadowController.h" @@ -13,6 +14,7 @@ #include #include +#include #include #include #include @@ -922,6 +924,116 @@ void LightPropertiesController::removeLinkedEntity(const QString& entityName) }); } +QString LightPropertiesController::iesProfilePath() const +{ + const QList snapshots = selectedSnapshots(); + if (snapshots.size() != 1) + return {}; + return snapshots.first().iesProfilePath; +} + +bool LightPropertiesController::hasIesProfile() const +{ + return !iesProfilePath().isEmpty(); +} + +bool LightPropertiesController::areaLightsEnabled() const +{ +#ifdef ENABLE_AREA_LIGHTS + return true; +#else + return false; +#endif +} + +void LightPropertiesController::browseIesProfile() +{ + QWidget* parent = Manager::getSingletonPtr() ? Manager::getSingleton()->getMainWindow() : nullptr; + const QString path = QFileDialog::getOpenFileName( + parent, QObject::tr("Select IES profile"), QString(), QObject::tr("IES files (*.ies)")); + if (path.isEmpty()) + return; + + QString error; + const IesProfile profile = IesProfile::parseFile(path, &error); + if (!profile.valid) + return; + + pushImmediateEdit(LightPropertyClass::IesProfile, [path](LightSnapshot& snapshot) { + snapshot.iesProfilePath = path; + }); + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Assign IES profile: %1").arg(path)); +} + +void LightPropertiesController::clearIesProfile() +{ + pushImmediateEdit(LightPropertyClass::IesProfile, [](LightSnapshot& snapshot) { + snapshot.iesProfilePath.clear(); + }); +} + +#ifdef ENABLE_AREA_LIGHTS +QString LightPropertiesController::areaShape() const +{ + const QList snapshots = selectedSnapshots(); + return snapshots.size() == 1 ? snapshots.first().areaShape : QString(); +} + +void LightPropertiesController::setAreaShape(const QString& shape) +{ + const QString trimmed = shape.trimmed().toLower(); + pushImmediateEdit(LightPropertyClass::AreaShape, [trimmed](LightSnapshot& snapshot) { + snapshot.areaShape = trimmed; + }); +} + +double LightPropertiesController::areaWidth() const +{ + const QList snapshots = selectedSnapshots(); + return snapshots.size() == 1 ? snapshots.first().areaWidth : 1.0; +} + +void LightPropertiesController::setAreaWidth(double value) +{ + pushImmediateEdit(LightPropertyClass::AreaShape, [value](LightSnapshot& snapshot) { + snapshot.areaWidth = static_cast(qMax(0.01, value)); + }); +} + +double LightPropertiesController::areaHeight() const +{ + const QList snapshots = selectedSnapshots(); + return snapshots.size() == 1 ? snapshots.first().areaHeight : 1.0; +} + +void LightPropertiesController::setAreaHeight(double value) +{ + pushImmediateEdit(LightPropertyClass::AreaShape, [value](LightSnapshot& snapshot) { + snapshot.areaHeight = static_cast(qMax(0.01, value)); + }); +} + +int LightPropertiesController::areaSampleCount() const +{ + const QList snapshots = selectedSnapshots(); + return snapshots.size() == 1 ? snapshots.first().areaSampleCount : 4; +} + +void LightPropertiesController::setAreaSampleCount(int count) +{ + const int clamped = std::clamp(count, 2, 16); + pushImmediateEdit(LightPropertyClass::AreaShape, [clamped](LightSnapshot& snapshot) { + snapshot.areaSampleCount = clamped; + }); +} + +QStringList LightPropertiesController::areaShapeChoices() const +{ + return {QString(), QStringLiteral("rectangle"), QStringLiteral("disk"), QStringLiteral("line")}; +} +#endif + void LightPropertiesController::beginSliderEdit(int propertyClass) { if (m_sliderEditActive) diff --git a/src/LightPropertiesController.h b/src/LightPropertiesController.h index 5a9da143..cafa6e6a 100644 --- a/src/LightPropertiesController.h +++ b/src/LightPropertiesController.h @@ -63,6 +63,16 @@ class LightPropertiesController : public QObject Q_PROPERTY(QStringList linkedEntityNames READ linkedEntityNames NOTIFY propertiesChanged) Q_PROPERTY(QStringList linkModeChoices READ linkModeChoices CONSTANT) Q_PROPERTY(QStringList availableLinkTargets READ availableLinkTargets NOTIFY propertiesChanged) + Q_PROPERTY(QString iesProfilePath READ iesProfilePath NOTIFY propertiesChanged) + Q_PROPERTY(bool hasIesProfile READ hasIesProfile NOTIFY propertiesChanged) + Q_PROPERTY(bool areaLightsEnabled READ areaLightsEnabled CONSTANT) +#ifdef ENABLE_AREA_LIGHTS + Q_PROPERTY(QString areaShape READ areaShape WRITE setAreaShape NOTIFY propertiesChanged) + Q_PROPERTY(double areaWidth READ areaWidth WRITE setAreaWidth NOTIFY propertiesChanged) + Q_PROPERTY(double areaHeight READ areaHeight WRITE setAreaHeight NOTIFY propertiesChanged) + Q_PROPERTY(int areaSampleCount READ areaSampleCount WRITE setAreaSampleCount NOTIFY propertiesChanged) + Q_PROPERTY(QStringList areaShapeChoices READ areaShapeChoices CONSTANT) +#endif public: static LightPropertiesController* instance(); @@ -153,6 +163,24 @@ class LightPropertiesController : public QObject QStringList linkModeChoices() const; QStringList availableLinkTargets() const; + QString iesProfilePath() const; + bool hasIesProfile() const; + bool areaLightsEnabled() const; + Q_INVOKABLE void browseIesProfile(); + Q_INVOKABLE void clearIesProfile(); + +#ifdef ENABLE_AREA_LIGHTS + QString areaShape() const; + void setAreaShape(const QString& shape); + double areaWidth() const; + void setAreaWidth(double value); + double areaHeight() const; + void setAreaHeight(double value); + int areaSampleCount() const; + void setAreaSampleCount(int count); + QStringList areaShapeChoices() const; +#endif + Q_INVOKABLE void addLinkedEntity(const QString& entityName); Q_INVOKABLE void removeLinkedEntity(const QString& entityName); diff --git a/src/LightVisualizer.cpp b/src/LightVisualizer.cpp index 1810b9ec..14db4445 100644 --- a/src/LightVisualizer.cpp +++ b/src/LightVisualizer.cpp @@ -1,6 +1,7 @@ #include "LightVisualizer.h" #include "GlobalDefinitions.h" +#include "IesProfile.h" #include "Manager.h" #include "SelectionSet.h" #include "SentryReporter.h" @@ -127,6 +128,56 @@ void addSpotGizmo(Ogre::ManualObject* mo, } } +void addIesPolarGizmo(Ogre::ManualObject* mo, + const IesProfile& profile, + float scale, + const Ogre::ColourValue& colour) +{ + const QVector slice = profile.polarSlice(); + if (slice.size() < 2) + return; + + const Ogre::Vector3 forward = Ogre::Vector3::NEGATIVE_UNIT_Z; + const Ogre::Vector3 u = orthoBasisU(forward); + + Ogre::Vector3 prev = Ogre::Vector3::ZERO; + for (int i = 0; i < slice.size(); ++i) + { + const float angleDeg = profile.verticalAnglesDeg.value(i, 0.0f); + const float angleRad = Ogre::Degree(angleDeg).valueRadians(); + const float radius = slice[i] * scale; + const Ogre::Vector3 dir = + (forward * std::cos(angleRad) + u * std::sin(angleRad)).normalisedCopy(); + const Ogre::Vector3 point = dir * radius; + if (i > 0) + addLine(mo, prev, point, colour); + prev = point; + } + if (slice.size() > 1) + addLine(mo, prev, Ogre::Vector3::ZERO, colour); +} + +void addAreaRectangleGizmo(Ogre::ManualObject* mo, float width, float height, const Ogre::ColourValue& colour) +{ + const float hw = width * 0.5f; + const float hh = height * 0.5f; + const Ogre::Vector3 corners[4] = { + {hw, hh, 0}, {-hw, hh, 0}, {-hw, -hh, 0}, {hw, -hh, 0}}; + for (int i = 0; i < 4; ++i) + addLine(mo, corners[i], corners[(i + 1) % 4], colour); +} + +void addAreaDiskGizmo(Ogre::ManualObject* mo, float radius, const Ogre::ColourValue& colour) +{ + addWireCircle(mo, Ogre::Vector3::ZERO, Ogre::Vector3::UNIT_X, Ogre::Vector3::UNIT_Y, radius, colour, + kConeSegments); +} + +void addAreaLineGizmo(Ogre::ManualObject* mo, float length, const Ogre::ColourValue& colour) +{ + addLine(mo, Ogre::Vector3(-length * 0.5f, 0, 0), Ogre::Vector3(length * 0.5f, 0, 0), colour); +} + QImage makeIconImage(const QString& kind) { QImage image(64, 64, QImage::Format_RGBA8888); @@ -516,25 +567,51 @@ void LightVisualizer::rebuildGizmoGeometry(OverlayData& data, const LightHandle& const float alpha = selected ? 1.0f : 0.5f; const Ogre::ColourValue colour = tintColour(handle.light->getDiffuseColour(), alpha, selected); + const LightSnapshot snapshot = LightSnapshot::fromHandle(handle); data.gizmo->clear(); data.gizmo->begin(mGizmoMaterial->getName(), Ogre::RenderOperation::OT_LINE_LIST); - switch (handle.light->getType()) +#ifdef ENABLE_AREA_LIGHTS + const QString areaShape = snapshot.areaShape.trimmed().toLower(); + if (!areaShape.isEmpty()) { - case Ogre::Light::LT_DIRECTIONAL: - addDirectionalGizmo(data.gizmo, colour); - break; - case Ogre::Light::LT_SPOTLIGHT: - addSpotGizmo(data.gizmo, - handle.light->getAttenuationRange(), - handle.light->getSpotlightInnerAngle().valueDegrees(), - handle.light->getSpotlightOuterAngle().valueDegrees(), - colour); - break; - default: - addPointGizmo(data.gizmo, handle.light->getAttenuationRange(), colour); - break; + if (areaShape == QStringLiteral("rectangle")) + addAreaRectangleGizmo(data.gizmo, snapshot.areaWidth, snapshot.areaHeight, colour); + else if (areaShape == QStringLiteral("disk")) + addAreaDiskGizmo(data.gizmo, std::max(0.05f, snapshot.areaWidth * 0.5f), colour); + else if (areaShape == QStringLiteral("line")) + addAreaLineGizmo(data.gizmo, snapshot.areaWidth, colour); + } + else +#endif + { + switch (handle.light->getType()) + { + case Ogre::Light::LT_DIRECTIONAL: + addDirectionalGizmo(data.gizmo, colour); + break; + case Ogre::Light::LT_SPOTLIGHT: + addSpotGizmo(data.gizmo, + handle.light->getAttenuationRange(), + handle.light->getSpotlightInnerAngle().valueDegrees(), + handle.light->getSpotlightOuterAngle().valueDegrees(), + colour); + break; + default: + addPointGizmo(data.gizmo, handle.light->getAttenuationRange(), colour); + break; + } + } + + if (!snapshot.iesProfilePath.isEmpty()) + { + const IesProfile profile = IesProfile::parseFile(snapshot.iesProfilePath); + if (profile.valid) + { + const float scale = std::max(0.2f, handle.light->getAttenuationRange() * 0.35f); + addIesPolarGizmo(data.gizmo, profile, scale, colour); + } } data.gizmo->end(); diff --git a/src/OgreWidget.cpp b/src/OgreWidget.cpp index 9428a04b..37db72c3 100755 --- a/src/OgreWidget.cpp +++ b/src/OgreWidget.cpp @@ -48,6 +48,7 @@ THE SOFTWARE. #include "TransformOperator.h" #include "HDR/HdrViewportController.h" #include "ShadowController.h" +#include "ViewportLightSoloController.h" #include "SentryReporter.h" namespace { @@ -135,6 +136,7 @@ OgreWidget::~OgreWidget() { HdrViewportController::getSingleton()->unregisterWidget(this); ShadowController::instance()->unregisterViewport(this); + ViewportLightSoloController::instance()->unregisterWidget(this); // Safely clean up OGRE resources // Order is important: remove listeners first, then destroy camera, then detach render target, then remove viewports @@ -302,6 +304,7 @@ void OgreWidget::initOgreWindow(void) HdrViewportController::getSingleton()->registerWidget(this); ShadowController::instance()->registerViewport(this); + ViewportLightSoloController::instance()->registerWidget(this); } void OgreWidget::teardownOgreWindow() @@ -362,11 +365,13 @@ void OgreWidget::rebuildRenderWindow() HdrViewportController::getSingleton()->unregisterWidget(this); ShadowController::instance()->unregisterViewport(this); + ViewportLightSoloController::instance()->unregisterWidget(this); teardownOgreWindow(); initOgreWindow(); HdrViewportController::getSingleton()->registerWidget(this); + ViewportLightSoloController::instance()->registerWidget(this); setBackgroundColor(bg); if (mViewport) @@ -430,7 +435,9 @@ bool OgreWidget::frameEnded(const Ogre::FrameEvent& e) { try { mOgreWindow->windowMovedOrResized(); + ViewportLightSoloController::instance()->beginRenderPass(this); mOgreWindow->update(); + ViewportLightSoloController::instance()->endRenderPass(this); } catch (...) { // Ignore exceptions during shutdown return false; diff --git a/src/SceneLightsIO.cpp b/src/SceneLightsIO.cpp index 71ec4cb0..15b834ef 100644 --- a/src/SceneLightsIO.cpp +++ b/src/SceneLightsIO.cpp @@ -2,6 +2,7 @@ #include "LightManager.h" #include "LightLinking.h" +#include "LightGroupLibrary.h" #include "LightRigLibrary.h" #include "Manager.h" #include "ShadowController.h" @@ -215,6 +216,18 @@ QJsonObject snapshotToJson(const LightSnapshot& snapshot) obj.insert(QStringLiteral("linkMode"), LightLinking::modeToString(snapshot.linkMode)); obj.insert(QStringLiteral("linkedEntities"), QJsonArray::fromStringList(snapshot.linkedEntityNames)); obj.insert(QStringLiteral("linkChannelBit"), static_cast(snapshot.linkChannelBit)); + if (!snapshot.iesProfilePath.isEmpty()) + obj.insert(QStringLiteral("iesProfilePath"), snapshot.iesProfilePath); +#ifdef ENABLE_AREA_LIGHTS + if (!snapshot.areaShape.isEmpty()) + obj.insert(QStringLiteral("areaShape"), snapshot.areaShape); + if (snapshot.areaWidth != 1.0f) + obj.insert(QStringLiteral("areaWidth"), snapshot.areaWidth); + if (snapshot.areaHeight != 1.0f) + obj.insert(QStringLiteral("areaHeight"), snapshot.areaHeight); + if (snapshot.areaSampleCount != 4) + obj.insert(QStringLiteral("areaSampleCount"), snapshot.areaSampleCount); +#endif return obj; } @@ -273,6 +286,13 @@ bool snapshotFromJson(const QJsonObject& obj, LightSnapshot& snapshot) } snapshot.linkChannelBit = static_cast(obj.value(QStringLiteral("linkChannelBit")).toInt(0)); + snapshot.iesProfilePath = obj.value(QStringLiteral("iesProfilePath")).toString(); +#ifdef ENABLE_AREA_LIGHTS + snapshot.areaShape = obj.value(QStringLiteral("areaShape")).toString(); + snapshot.areaWidth = static_cast(obj.value(QStringLiteral("areaWidth")).toDouble(1.0)); + snapshot.areaHeight = static_cast(obj.value(QStringLiteral("areaHeight")).toDouble(1.0)); + snapshot.areaSampleCount = obj.value(QStringLiteral("areaSampleCount")).toInt(4); +#endif return true; } @@ -534,12 +554,15 @@ SceneLightsDocument captureFromScene() for (const auto& child : root->getChildren()) { auto* node = static_cast(child); - if (!LightRigLibrary::sceneNodeIsRigGroup(node)) + const bool isRig = LightRigLibrary::sceneNodeIsRigGroup(node); + const bool isCollection = LightGroupLibrary::sceneNodeIsLightGroup(node); + if (!isRig && !isCollection) continue; RigGroupExport group; group.name = QString::fromStdString(node->getName()); group.rigId = rigIdFromSceneNode(node); + group.isUserCollection = isCollection; group.preserveGrouping = true; groupIndexByNode[node] = doc.rigGroups.size(); doc.rigGroups.append(group); @@ -591,7 +614,10 @@ bool applyToLightManager(const SceneLightsDocument& doc, bool useDefaultWhenEmpt if (!rigNode) continue; - LightRigLibrary::tagRigGroupNode(rigNode); + if (group.isUserCollection) + LightGroupLibrary::tagLightGroupNode(rigNode); + else + LightRigLibrary::tagRigGroupNode(rigNode); if (!group.rigId.isEmpty()) { rigNode->getUserObjectBindings().setUserAny( @@ -624,6 +650,8 @@ QByteArray documentToJson(const SceneLightsDocument& doc) obj.insert(QStringLiteral("name"), group.name); if (!group.rigId.isEmpty()) obj.insert(QStringLiteral("rigId"), group.rigId); + if (group.isUserCollection) + obj.insert(QStringLiteral("groupKind"), QStringLiteral("collection")); obj.insert(QStringLiteral("preserveGrouping"), group.preserveGrouping); QJsonArray lightsArr; for (const LightSnapshot& snapshot : group.lights) @@ -661,6 +689,8 @@ bool documentFromJson(const QByteArray& json, SceneLightsDocument& out) RigGroupExport group; group.name = obj.value(QStringLiteral("name")).toString(); group.rigId = obj.value(QStringLiteral("rigId")).toString(); + group.isUserCollection = + obj.value(QStringLiteral("groupKind")).toString() == QStringLiteral("collection"); group.preserveGrouping = obj.value(QStringLiteral("preserveGrouping")).toBool(true); const QJsonArray lightsArr = obj.value(QStringLiteral("lights")).toArray(); for (const QJsonValue& lightValue : lightsArr) diff --git a/src/SceneLightsIO.h b/src/SceneLightsIO.h index cbc12b82..33847b61 100644 --- a/src/SceneLightsIO.h +++ b/src/SceneLightsIO.h @@ -26,6 +26,8 @@ struct RigGroupExport QString name; QString rigId; bool preserveGrouping = true; + /// True for user-created light collections (#491); false for preset rig groups. + bool isUserCollection = false; QList lights; }; diff --git a/src/ViewportLightSoloController.cpp b/src/ViewportLightSoloController.cpp new file mode 100644 index 00000000..a385dbd8 --- /dev/null +++ b/src/ViewportLightSoloController.cpp @@ -0,0 +1,163 @@ +#include "ViewportLightSoloController.h" + +#include "LightManager.h" +#include "OgreWidget.h" +#include "SentryReporter.h" + +#include + +#include + +ViewportLightSoloController* ViewportLightSoloController::s_singleton = nullptr; + +ViewportLightSoloController* ViewportLightSoloController::instance() +{ + if (!s_singleton) + s_singleton = new ViewportLightSoloController(); // NOSONAR — singleton + return s_singleton; +} + +ViewportLightSoloController* ViewportLightSoloController::qmlInstance(QQmlEngine*, QJSEngine*) +{ + return instance(); +} + +void ViewportLightSoloController::kill() +{ + delete s_singleton; // NOSONAR — singleton + s_singleton = nullptr; +} + +ViewportLightSoloController::ViewportLightSoloController(QObject* parent) + : QObject(parent) +{ + if (auto* lights = LightManager::getSingletonPtr()) + { + connect(lights, &LightManager::lightCreated, this, &ViewportLightSoloController::lightsChanged); + connect(lights, &LightManager::lightDeleted, this, &ViewportLightSoloController::lightsChanged); + connect(lights, &LightManager::lightChanged, this, &ViewportLightSoloController::lightsChanged); + } +} + +void ViewportLightSoloController::registerWidget(OgreWidget* widget) +{ + if (!widget) + return; + if (m_soloByWidget.find(widget) == m_soloByWidget.end()) + m_soloByWidget.emplace(widget, QString()); +} + +void ViewportLightSoloController::unregisterWidget(OgreWidget* widget) +{ + if (!widget) + return; + m_soloByWidget.erase(widget); + if (m_activeWidget == widget) + { + m_activeWidget = nullptr; + emit activeViewportChanged(); + emit activeViewportSoloChanged(); + } +} + +void ViewportLightSoloController::setActiveWidget(OgreWidget* widget) +{ + if (m_activeWidget == widget) + return; + m_activeWidget = widget; + emit activeViewportChanged(); + emit activeViewportSoloChanged(); +} + +QStringList ViewportLightSoloController::lightNames() const +{ + QStringList names; + if (auto* lights = LightManager::getSingletonPtr()) + { + for (const LightHandle& handle : lights->lights()) + names.append(handle.name); + } + names.sort(Qt::CaseInsensitive); + return names; +} + +QString ViewportLightSoloController::activeViewportSoloLight() const +{ + return soloLightFor(m_activeWidget); +} + +void ViewportLightSoloController::setActiveViewportSoloLight(const QString& lightName) +{ + if (!m_activeWidget) + return; + + const QString trimmed = lightName.trimmed(); + m_soloByWidget[m_activeWidget] = trimmed; + emit activeViewportSoloChanged(); + SentryReporter::addBreadcrumb(QStringLiteral("scene.light.solo"), + trimmed.isEmpty() + ? QStringLiteral("Viewport solo cleared") + : QStringLiteral("Viewport solo: %1").arg(trimmed)); +} + +bool ViewportLightSoloController::hasActiveViewport() const +{ + return m_activeWidget != nullptr; +} + +QString ViewportLightSoloController::soloLightFor(OgreWidget* widget) const +{ + const auto it = m_soloByWidget.find(widget); + if (it == m_soloByWidget.end()) + return {}; + return it->second; +} + +void ViewportLightSoloController::beginRenderPass(OgreWidget* widget) +{ + if (m_inRenderPass) + return; + + const QString soloName = soloLightFor(widget); + if (soloName.isEmpty()) + return; + + auto* lights = LightManager::getSingletonPtr(); + if (!lights) + return; + + m_inRenderPass = true; + m_savedVisibility.clear(); + for (const LightHandle& handle : lights->lights()) + { + if (!handle.light) + continue; + m_savedVisibility.insert(handle.name, handle.light->getVisible()); + handle.light->setVisible(handle.name == soloName); + } +} + +void ViewportLightSoloController::endRenderPass(OgreWidget* widget) +{ + Q_UNUSED(widget); + if (!m_inRenderPass) + return; + + auto* lights = LightManager::getSingletonPtr(); + if (!lights) + { + m_inRenderPass = false; + return; + } + + for (const LightHandle& handle : lights->lights()) + { + if (!handle.light) + continue; + const auto it = m_savedVisibility.constFind(handle.name); + handle.light->setVisible(it != m_savedVisibility.constEnd() ? it.value() + : handle.light->getVisible()); + } + m_savedVisibility.clear(); + m_inRenderPass = false; +} diff --git a/src/ViewportLightSoloController.h b/src/ViewportLightSoloController.h new file mode 100644 index 00000000..4bc1e3fa --- /dev/null +++ b/src/ViewportLightSoloController.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +class OgreWidget; + +/// Per-viewport runtime light solo — only one light contributes in a viewport +/// while others are temporarily hidden during that viewport's render pass. +class ViewportLightSoloController : public QObject +{ + Q_OBJECT + Q_PROPERTY(QStringList lightNames READ lightNames NOTIFY lightsChanged) + Q_PROPERTY(QString activeViewportSoloLight READ activeViewportSoloLight WRITE setActiveViewportSoloLight + NOTIFY activeViewportSoloChanged) + Q_PROPERTY(bool hasActiveViewport READ hasActiveViewport NOTIFY activeViewportChanged) + +public: + static ViewportLightSoloController* instance(); + static ViewportLightSoloController* qmlInstance(QQmlEngine* engine, QJSEngine* scriptEngine); + static void kill(); + + void registerWidget(OgreWidget* widget); + void unregisterWidget(OgreWidget* widget); + void setActiveWidget(OgreWidget* widget); + + QStringList lightNames() const; + QString activeViewportSoloLight() const; + void setActiveViewportSoloLight(const QString& lightName); + bool hasActiveViewport() const; + + /// Called from each widget's render-target listener. + void beginRenderPass(OgreWidget* widget); + void endRenderPass(OgreWidget* widget); + +signals: + void lightsChanged(); + void activeViewportSoloChanged(); + void activeViewportChanged(); + +private: + explicit ViewportLightSoloController(QObject* parent = nullptr); + + QString soloLightFor(OgreWidget* widget) const; + + static ViewportLightSoloController* s_singleton; + OgreWidget* m_activeWidget = nullptr; + std::unordered_map m_soloByWidget; + QHash m_savedVisibility; + bool m_inRenderPass = false; +}; diff --git a/src/commands/LightCommands.cpp b/src/commands/LightCommands.cpp index 82ac01ff..7ec78b7f 100644 --- a/src/commands/LightCommands.cpp +++ b/src/commands/LightCommands.cpp @@ -127,6 +127,10 @@ QString lightPropertyClassLabel(LightPropertyClass propertyClass) return QStringLiteral("shadow"); case LightPropertyClass::Linking: return QStringLiteral("light linking"); + case LightPropertyClass::IesProfile: + return QStringLiteral("IES profile"); + case LightPropertyClass::AreaShape: + return QStringLiteral("area light"); } return QStringLiteral("property"); } diff --git a/src/commands/LightCommands.h b/src/commands/LightCommands.h index 310a7282..350f5461 100644 --- a/src/commands/LightCommands.h +++ b/src/commands/LightCommands.h @@ -72,7 +72,9 @@ enum class LightPropertyClass Attenuation, SpotCone, Shadow, - Linking + Linking, + IesProfile, + AreaShape }; QString lightPropertyClassLabel(LightPropertyClass propertyClass); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index de6e0612..27e5714a 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -89,6 +89,8 @@ #include "MCPServer.h" #include "NormalVisualizer.h" #include "LightVisualizer.h" +#include "LightGroupController.h" +#include "ViewportLightSoloController.h" #include "MeshInfoOverlay.h" #include "SubEntityHighlight.h" #include "SpaceCamera.h" @@ -572,6 +574,8 @@ MainWindow::~MainWindow() LightsController::kill(); LightPropertiesController::kill(); SceneLightingController::kill(); + LightGroupController::kill(); + ViewportLightSoloController::kill(); IsometricSpritesController::kill(); MeshGenController::kill(); MeshDepthRenderer::shutdown(); @@ -775,6 +779,16 @@ void MainWindow::initToolBar() [](QQmlEngine* engine, QJSEngine*) -> QObject* { return SceneLightingController::qmlInstance(engine, nullptr); }); + qmlRegisterSingletonType( + "PropertiesPanel", 1, 0, "LightGroupController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return LightGroupController::qmlInstance(engine, nullptr); + }); + qmlRegisterSingletonType( + "PropertiesPanel", 1, 0, "ViewportLightSoloController", + [](QQmlEngine* engine, QJSEngine*) -> QObject* { + return ViewportLightSoloController::qmlInstance(engine, nullptr); + }); qmlRegisterSingletonType( "PropertiesPanel", 1, 0, "ShadowController", [](QQmlEngine* engine, QJSEngine*) -> QObject* { @@ -2653,6 +2667,7 @@ void MainWindow::initToolBar() if (auto* ctrl = HdrViewportController::getSingletonPtr()) ctrl->setActiveWidget(widget); HdrEnvironmentController::instance()->setActiveWidget(widget); + ViewportLightSoloController::instance()->setActiveWidget(widget); }); } @@ -4935,6 +4950,7 @@ void MainWindow::createEditorViewport(/*TODO add the type of view (perspective, if (auto* ctrl = HdrViewportController::getSingletonPtr()) ctrl->setActiveWidget(widget); HdrEnvironmentController::instance()->setActiveWidget(widget); + ViewportLightSoloController::instance()->setActiveWidget(widget); }); if (!mDockWidgetList.isEmpty()) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9c9b7a3c..df247221 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -49,6 +49,11 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/LightManager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/LightRigLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/LightLinking.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/LightGroupLibrary.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/LightGroupController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/ViewportLightSoloController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/IesProfile.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/AreaLight.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/LightVisualizer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/LightsController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/LightPropertiesController.cpp