Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
364 changes: 364 additions & 0 deletions qml/PropertiesPanel.qml

Large diffs are not rendered by default.

205 changes: 205 additions & 0 deletions src/AreaLight.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
#include "AreaLight.h"

#include "Manager.h"

#include <OgreSceneManager.h>
#include <OgreSceneNode.h>

#include <QtMath>

#include <vector>

namespace
{

struct SamplePoint
{
Ogre::Vector3 localPos;
};

std::vector<SamplePoint> sampleRectangle(float width, float height, int count)
{
std::vector<SamplePoint> points;
const int cols = qMax(1, static_cast<int>(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<int>(points.size()) >= count)
break;
const float u = (cols == 1) ? 0.5f : static_cast<float>(c) / static_cast<float>(cols - 1);
const float v = (rows == 1) ? 0.5f : static_cast<float>(r) / static_cast<float>(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<SamplePoint> sampleDisk(float radius, int count)
{
std::vector<SamplePoint> points;
const int n = qMax(1, count);
for (int i = 0; i < n; ++i)
{
const float t = static_cast<float>(i) / static_cast<float>(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<SamplePoint> sampleLine(float length, int count)
{
std::vector<SamplePoint> points;
const int n = qMax(2, count);
for (int i = 0; i < n; ++i)
{
const float t = static_cast<float>(i) / static_cast<float>(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<bool>(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<std::string>(any));
}
catch (...)

Check warning on line 97 in src/AreaLight.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

"catch" a specific exception type.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ9O0IOp1sKQ6MLEwaLZ&open=AZ9O0IOp1sKQ6MLEwaLZ&pullRequest=842
{
return {};
}
}

void removeProxies(const QString& ownerLightName)
{
auto* mgr = Manager::getSingletonPtr();

Check warning on line 105 in src/AreaLight.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a pointer-to-const. The current type of "mgr" is "class Manager *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ9O0IOp1sKQ6MLEwaLb&open=AZ9O0IOp1sKQ6MLEwaLb&pullRequest=842
if (!mgr || ownerLightName.isEmpty())
return;

Ogre::SceneManager* sceneMgr = mgr->getSceneMgr();

Check warning on line 109 in src/AreaLight.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a pointer-to-const. The current type of "sceneMgr" is "class Ogre::SceneManager *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ9O0IOp1sKQ6MLEwaLc&open=AZ9O0IOp1sKQ6MLEwaLc&pullRequest=842
if (!sceneMgr)

Check warning on line 110 in src/AreaLight.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use the init-statement to declare "sceneMgr" inside the if statement.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ9O0IOp1sKQ6MLEwaLa&open=AZ9O0IOp1sKQ6MLEwaLa&pullRequest=842
return;

const LightHandle* owner = LightManager::getSingletonPtr()
? LightManager::getSingleton()->findLight(ownerLightName)
: nullptr;
if (!owner || !owner->sceneNode)
return;

std::vector<Ogre::SceneNode*> toDestroy;
for (const auto& child : owner->sceneNode->getChildren())
{
auto* childNode = static_cast<Ogre::SceneNode*>(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<Ogre::Light*>(obj);

Check warning on line 128 in src/AreaLight.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a pointer-to-const. The current type of "light" is "class Ogre::Light *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ9O0IOp1sKQ6MLEwaLd&open=AZ9O0IOp1sKQ6MLEwaLd&pullRequest=842
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<SamplePoint> 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;

Comment thread
fernandotonon marked this conversation as resolved.
owner.light->setVisible(false);

auto* lights = LightManager::getSingletonPtr();

Check warning on line 173 in src/AreaLight.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a pointer-to-const. The current type of "lights" is "class LightManager *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ9O0IOp1sKQ6MLEwaLe&open=AZ9O0IOp1sKQ6MLEwaLe&pullRequest=842
auto* mgr = Manager::getSingletonPtr();

Check warning on line 174 in src/AreaLight.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make the type of this variable a pointer-to-const. The current type of "mgr" is "class Manager *".

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ9O0IOp1sKQ6MLEwaLf&open=AZ9O0IOp1sKQ6MLEwaLf&pullRequest=842
if (!lights || !mgr || !mgr->getSceneMgr())
return;

Ogre::SceneManager* sceneMgr = mgr->getSceneMgr();
const float weight = 1.0f / static_cast<float>(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
22 changes: 22 additions & 0 deletions src/AreaLight.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#pragma once

#include "LightManager.h"

#include <QString>

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
5 changes: 5 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading